Files
ShowenV2/clients/flutter/lib/providers/chat_provider.dart
XiuchengWu 6f7e24db63 feat: M2.1 客户端骨架、BLE 修复、仓库整理与现状板
- BLE: 修复 BlueZ LocalName 与 Includes 冲突,串行注册与退避
- HTTP: 语音对话补 Live2D talking 标志与 session;Web 补 X-Session-Id
- Flutter: 角色/对话/模型页 + API,Gradle/依赖升级,麦克风权限
- 文档: 新增 docs/STATUS.md,校准 CLAUDE/PROGRESS/README
- 清理: 移除根目录截图与临时模型,运维脚本迁入 scripts/device
2026-07-09 12:41:34 +08:00

271 lines
7.1 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import '../models/chat_models.dart';
import '../services/http_api_service.dart';
import 'debug_provider.dart';
class ChatProvider extends ChangeNotifier {
ChatProvider({
required HttpApiService httpApiService,
required DebugProvider debugProvider,
}) : _http = httpApiService,
_debug = debugProvider;
final HttpApiService _http;
final DebugProvider _debug;
final AudioRecorder _recorder = AudioRecorder();
final AudioPlayer _player = AudioPlayer();
final List<ChatBubble> _messages = <ChatBubble>[];
String _sessionId = 'app_${DateTime.now().millisecondsSinceEpoch}';
bool _busy = false;
bool _recording = false;
String? _error;
String? _status;
List<ChatBubble> get messages => List<ChatBubble>.unmodifiable(_messages);
bool get isBusy => _busy;
bool get isRecording => _recording;
String? get error => _error;
String? get status => _status;
String get sessionId => _sessionId;
void clearHistory() {
_messages.clear();
_sessionId = 'app_${DateTime.now().millisecondsSinceEpoch}';
_error = null;
_status = '已清空对话';
notifyListeners();
}
Future<void> sendText(String text) async {
final content = text.trim();
if (content.isEmpty || _busy) return;
_messages.add(ChatBubble(role: ChatBubbleRole.user, text: content));
final pending = ChatBubble(
role: ChatBubbleRole.assistant,
text: '思考中...',
isPending: true,
);
_messages.add(pending);
_busy = true;
_error = null;
_status = '角色思考中...';
notifyListeners();
try {
final response = await _http.chatText(
text: content,
sessionId: _sessionId,
);
await _applyResponse(pending, response);
} catch (error) {
pending
..isPending = false
..text = '请求失败: $error';
_error = error.toString();
_debug.addHttpLog('chatText failed', details: _error);
} finally {
_busy = false;
if (_status == '角色思考中...') {
_status = null;
}
notifyListeners();
}
}
Future<bool> startRecording() async {
if (_busy || _recording) return false;
try {
final hasPermission = await _recorder.hasPermission();
if (!hasPermission) {
_error = '需要麦克风权限才能语音对话';
_status = _error;
notifyListeners();
return false;
}
final dir = await getTemporaryDirectory();
final path =
'${dir.path}/showen_rec_${DateTime.now().millisecondsSinceEpoch}.wav';
await _recorder.start(
const RecordConfig(
encoder: AudioEncoder.wav,
sampleRate: 16000,
numChannels: 1,
),
path: path,
);
_recording = true;
_error = null;
_status = '正在录音... 松开发送';
_debug.addBleLog('Voice recording started');
notifyListeners();
return true;
} catch (error) {
_error = '无法开始录音: $error';
_status = _error;
_debug.addHttpLog('startRecording failed', details: _error);
notifyListeners();
return false;
}
}
Future<void> cancelRecording() async {
if (!_recording) return;
try {
final path = await _recorder.stop();
if (path != null) {
final file = File(path);
if (await file.exists()) {
await file.delete();
}
}
} catch (_) {}
_recording = false;
_status = '已取消录音';
notifyListeners();
}
Future<void> stopRecordingAndSend() async {
if (!_recording || _busy) return;
_recording = false;
_status = '正在识别...';
notifyListeners();
String? path;
try {
path = await _recorder.stop();
} catch (error) {
_error = '停止录音失败: $error';
_status = _error;
notifyListeners();
return;
}
if (path == null) {
_error = '没有录到音频';
_status = _error;
notifyListeners();
return;
}
final file = File(path);
if (!await file.exists()) {
_error = '录音文件不存在';
_status = _error;
notifyListeners();
return;
}
final bytes = await file.readAsBytes();
try {
await file.delete();
} catch (_) {}
if (bytes.length < 1000) {
_error = '录音太短,请按住再说一会儿';
_status = _error;
notifyListeners();
return;
}
_messages.add(
ChatBubble(role: ChatBubbleRole.user, text: '🎤 语音消息'),
);
final pending = ChatBubble(
role: ChatBubbleRole.assistant,
text: '思考中...',
isPending: true,
);
_messages.add(pending);
_busy = true;
_status = '角色思考中...';
notifyListeners();
try {
final response = await _http.chatAudio(
bytes: bytes,
format: 'wav',
sessionId: _sessionId,
);
if (response.transcription != null &&
response.transcription!.trim().isNotEmpty) {
// 回填用户气泡为转写文本
for (var i = _messages.length - 1; i >= 0; i--) {
if (_messages[i].role == ChatBubbleRole.user) {
_messages[i].text = response.transcription!;
break;
}
}
}
await _applyResponse(pending, response);
} catch (error) {
pending
..isPending = false
..text = '语音对话失败: $error';
_error = error.toString();
_debug.addHttpLog('chatAudio failed', details: _error);
} finally {
_busy = false;
if (_status == '角色思考中...') {
_status = null;
}
notifyListeners();
}
}
Future<void> _applyResponse(
ChatBubble pending,
ChatTurnResponse response,
) async {
pending.isPending = false;
if (!response.isOk) {
pending.text = response.error ?? '对话失败';
_error = pending.text;
_status = pending.text;
return;
}
if (response.sessionId.isNotEmpty) {
_sessionId = response.sessionId;
}
pending.text = response.replyText.isEmpty ? '(空回复)' : response.replyText;
pending.audioPath = response.replyAudioPath;
_status = '已回复';
_debug.addHttpLog(
'chat ok',
details: 'chars=${pending.text.length} audio=${pending.audioPath != null}',
);
if (pending.audioPath != null && pending.audioPath!.isNotEmpty) {
unawaited(playReply(pending.audioPath!));
}
}
Future<void> playReply(String absolutePath) async {
try {
final url = _http.chatAudioFileUrl(absolutePath);
await _player.stop();
await _player.play(UrlSource(url));
_status = '正在播放回复...';
notifyListeners();
} catch (error) {
_error = '播放失败: $error';
_debug.addHttpLog('playReply failed', details: _error);
notifyListeners();
}
}
@override
void dispose() {
unawaited(_recorder.dispose());
unawaited(_player.dispose());
super.dispose();
}
}