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
This commit is contained in:
83
clients/flutter/lib/providers/character_provider.dart
Normal file
83
clients/flutter/lib/providers/character_provider.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/character_pack.dart';
|
||||
import '../services/http_api_service.dart';
|
||||
import 'debug_provider.dart';
|
||||
|
||||
class CharacterProvider extends ChangeNotifier {
|
||||
CharacterProvider({
|
||||
required HttpApiService httpApiService,
|
||||
required DebugProvider debugProvider,
|
||||
}) : _http = httpApiService,
|
||||
_debug = debugProvider;
|
||||
|
||||
final HttpApiService _http;
|
||||
final DebugProvider _debug;
|
||||
|
||||
List<CharacterPack> _packs = const [];
|
||||
String _active = '';
|
||||
bool _loading = false;
|
||||
bool _switching = false;
|
||||
String? _error;
|
||||
|
||||
List<CharacterPack> get packs => _packs;
|
||||
String get activeFilename => _active;
|
||||
bool get isLoading => _loading;
|
||||
bool get isSwitching => _switching;
|
||||
String? get error => _error;
|
||||
|
||||
CharacterPack? get activePack {
|
||||
for (final pack in _packs) {
|
||||
if (pack.filename == _active) return pack;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> refresh() async {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final snapshot = await _http.getAvailableCharacterConfigs();
|
||||
_packs = snapshot.configs;
|
||||
_active = snapshot.active;
|
||||
_debug.addHttpLog(
|
||||
'Loaded characters',
|
||||
details: 'count=${_packs.length} active=$_active',
|
||||
);
|
||||
} catch (error) {
|
||||
_error = error.toString();
|
||||
_debug.addHttpLog('Load characters failed', details: _error);
|
||||
} finally {
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> switchTo(String filename) async {
|
||||
if (_switching || filename == _active) return false;
|
||||
_switching = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await _http.switchConfig(filename);
|
||||
if (!result.isOk) {
|
||||
_error = result.message;
|
||||
_debug.addHttpLog('Switch character failed', details: result.message);
|
||||
return false;
|
||||
}
|
||||
_active = filename;
|
||||
_debug.addHttpLog('Switched character', details: filename);
|
||||
// 热重载后刷新列表,确保 active 与服务端一致
|
||||
await refresh();
|
||||
return true;
|
||||
} catch (error) {
|
||||
_error = error.toString();
|
||||
_debug.addHttpLog('Switch character failed', details: _error);
|
||||
return false;
|
||||
} finally {
|
||||
_switching = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
270
clients/flutter/lib/providers/chat_provider.dart
Normal file
270
clients/flutter/lib/providers/chat_provider.dart
Normal file
@@ -0,0 +1,270 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
116
clients/flutter/lib/providers/model_provider.dart
Normal file
116
clients/flutter/lib/providers/model_provider.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/ai_model.dart';
|
||||
import '../models/api_response.dart';
|
||||
import '../services/http_api_service.dart';
|
||||
import 'debug_provider.dart';
|
||||
|
||||
class ModelProvider extends ChangeNotifier {
|
||||
ModelProvider({
|
||||
required HttpApiService httpApiService,
|
||||
required DebugProvider debugProvider,
|
||||
}) : _http = httpApiService,
|
||||
_debug = debugProvider;
|
||||
|
||||
final HttpApiService _http;
|
||||
final DebugProvider _debug;
|
||||
|
||||
ModelsSnapshot? _snapshot;
|
||||
bool _loading = false;
|
||||
bool _acting = false;
|
||||
String? _error;
|
||||
String? _status;
|
||||
Timer? _pollTimer;
|
||||
|
||||
ModelsSnapshot? get snapshot => _snapshot;
|
||||
bool get isLoading => _loading;
|
||||
bool get isActing => _acting;
|
||||
String? get error => _error;
|
||||
String? get status => _status;
|
||||
|
||||
Future<void> refresh() async {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
_snapshot = await _http.getModels();
|
||||
_debug.addHttpLog(
|
||||
'Loaded models',
|
||||
details: 'count=${_snapshot?.models.length ?? 0}',
|
||||
);
|
||||
} catch (error) {
|
||||
_error = error.toString();
|
||||
_debug.addHttpLog('Load models failed', details: _error);
|
||||
} finally {
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> download(String modelId) async {
|
||||
await _runAction(() => _http.downloadModel(modelId), '下载已启动: $modelId');
|
||||
_startPolling();
|
||||
}
|
||||
|
||||
Future<void> switchTo({required String modelId, required String kind}) async {
|
||||
await _runAction(
|
||||
() => _http.switchModel(modelId: modelId, kind: kind),
|
||||
'已切换: $modelId',
|
||||
);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> delete(String modelId) async {
|
||||
await _runAction(() => _http.deleteModel(modelId), '已删除: $modelId');
|
||||
await refresh();
|
||||
}
|
||||
|
||||
Future<void> _runAction(
|
||||
Future<ApiResponse> Function() action,
|
||||
String okStatus,
|
||||
) async {
|
||||
if (_acting) return;
|
||||
_acting = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final result = await action();
|
||||
_status = result.message.isNotEmpty ? result.message : okStatus;
|
||||
if (!result.isOk) {
|
||||
_error = _status;
|
||||
}
|
||||
_debug.addHttpLog('Model action ok', details: _status);
|
||||
} catch (error) {
|
||||
_error = error.toString();
|
||||
_status = _error;
|
||||
_debug.addHttpLog('Model action failed', details: _error);
|
||||
} finally {
|
||||
_acting = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
var ticks = 0;
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 2), (timer) async {
|
||||
ticks += 1;
|
||||
await refresh();
|
||||
final downloading = _snapshot?.models.any(
|
||||
(m) => m.downloadProgress > 0 && m.downloadProgress < 100 && !m.downloaded,
|
||||
) ??
|
||||
false;
|
||||
if (!downloading || ticks > 90) {
|
||||
timer.cancel();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user