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:
@@ -2,15 +2,21 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'providers/character_provider.dart';
|
||||
import 'providers/chat_provider.dart';
|
||||
import 'providers/device_provider.dart';
|
||||
import 'providers/debug_provider.dart';
|
||||
import 'providers/ble_provider.dart';
|
||||
import 'providers/model_provider.dart';
|
||||
import 'providers/player_provider.dart';
|
||||
import 'providers/wifi_provider.dart';
|
||||
import 'screens/app_shell.dart';
|
||||
import 'screens/ble_provision_screen.dart';
|
||||
import 'screens/characters_screen.dart';
|
||||
import 'screens/chat_screen.dart';
|
||||
import 'screens/debug_screen.dart';
|
||||
import 'screens/home_screen.dart';
|
||||
import 'screens/models_screen.dart';
|
||||
import 'screens/network_screen.dart';
|
||||
import 'screens/playback_screen.dart';
|
||||
import 'screens/settings_screen.dart';
|
||||
@@ -36,6 +42,7 @@ Future<void> main() async {
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
Provider<HttpApiService>.value(value: httpApiService),
|
||||
Provider<WebSocketService>.value(value: webSocketService),
|
||||
ChangeNotifierProvider<DebugProvider>(
|
||||
create: (_) => DebugProvider(webSocketService: webSocketService),
|
||||
@@ -70,6 +77,24 @@ Future<void> main() async {
|
||||
debugProvider: context.read<DebugProvider>(),
|
||||
)..bootstrap(),
|
||||
),
|
||||
ChangeNotifierProvider<CharacterProvider>(
|
||||
create: (context) => CharacterProvider(
|
||||
httpApiService: httpApiService,
|
||||
debugProvider: context.read<DebugProvider>(),
|
||||
),
|
||||
),
|
||||
ChangeNotifierProvider<ChatProvider>(
|
||||
create: (context) => ChatProvider(
|
||||
httpApiService: httpApiService,
|
||||
debugProvider: context.read<DebugProvider>(),
|
||||
),
|
||||
),
|
||||
ChangeNotifierProvider<ModelProvider>(
|
||||
create: (context) => ModelProvider(
|
||||
httpApiService: httpApiService,
|
||||
debugProvider: context.read<DebugProvider>(),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: const ShowenApp(),
|
||||
),
|
||||
@@ -95,18 +120,27 @@ final GoRouter _router = GoRouter(
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/playback',
|
||||
name: 'playback',
|
||||
builder: (context, state) => const PlaybackScreen(),
|
||||
path: '/characters',
|
||||
name: 'characters',
|
||||
builder: (context, state) => const CharactersScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/trigger',
|
||||
name: 'trigger',
|
||||
builder: (context, state) => const TriggerScreen(),
|
||||
path: '/chat',
|
||||
name: 'chat',
|
||||
builder: (context, state) => const ChatScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/playback',
|
||||
name: 'playback',
|
||||
builder: (context, state) => const PlaybackScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -132,15 +166,23 @@ final GoRouter _router = GoRouter(
|
||||
path: '/settings',
|
||||
name: 'settings',
|
||||
builder: (context, state) => const SettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/debug',
|
||||
name: 'debug',
|
||||
builder: (context, state) => const DebugScreen(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'models',
|
||||
name: 'models',
|
||||
builder: (context, state) => const ModelsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'trigger',
|
||||
name: 'trigger',
|
||||
builder: (context, state) => const TriggerScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: 'debug',
|
||||
name: 'debug',
|
||||
builder: (context, state) => const DebugScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
123
clients/flutter/lib/models/ai_model.dart
Normal file
123
clients/flutter/lib/models/ai_model.dart
Normal file
@@ -0,0 +1,123 @@
|
||||
class AiModelInfo {
|
||||
const AiModelInfo({
|
||||
required this.id,
|
||||
required this.kind,
|
||||
required this.name,
|
||||
required this.version,
|
||||
required this.size,
|
||||
required this.memoryRequired,
|
||||
required this.recommendedTier,
|
||||
required this.downloaded,
|
||||
required this.downloadProgress,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String kind;
|
||||
final String name;
|
||||
final String version;
|
||||
final int size;
|
||||
final int memoryRequired;
|
||||
final String recommendedTier;
|
||||
final bool downloaded;
|
||||
final int downloadProgress;
|
||||
|
||||
String get kindLabel {
|
||||
switch (kind.toLowerCase()) {
|
||||
case 'llm':
|
||||
return 'LLM';
|
||||
case 'asr':
|
||||
return 'ASR';
|
||||
case 'tts':
|
||||
return 'TTS';
|
||||
default:
|
||||
return kind.toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
String get sizeLabel => _formatBytes(size);
|
||||
String get memoryLabel => _formatBytes(memoryRequired);
|
||||
|
||||
factory AiModelInfo.fromJson(Map<String, dynamic> json) {
|
||||
return AiModelInfo(
|
||||
id: json['id']?.toString() ?? '',
|
||||
kind: json['kind']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? json['id']?.toString() ?? '',
|
||||
version: json['version']?.toString() ?? '',
|
||||
size: _asInt(json['size']),
|
||||
memoryRequired: _asInt(json['memory_required']),
|
||||
recommendedTier: json['recommended_tier']?.toString() ?? '',
|
||||
downloaded: json['downloaded'] == true,
|
||||
downloadProgress: _asInt(json['download_progress']),
|
||||
);
|
||||
}
|
||||
|
||||
static int _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '') ?? 0;
|
||||
}
|
||||
|
||||
static String _formatBytes(int bytes) {
|
||||
if (bytes <= 0) return '0 B';
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
if (bytes < 1024 * 1024) {
|
||||
return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||
}
|
||||
if (bytes < 1024 * 1024 * 1024) {
|
||||
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||
}
|
||||
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||
}
|
||||
}
|
||||
|
||||
class ModelsSnapshot {
|
||||
const ModelsSnapshot({
|
||||
required this.models,
|
||||
required this.active,
|
||||
required this.usedSpace,
|
||||
required this.quota,
|
||||
});
|
||||
|
||||
final List<AiModelInfo> models;
|
||||
final Map<String, String> active;
|
||||
final int usedSpace;
|
||||
final int quota;
|
||||
|
||||
String get usedLabel => AiModelInfo._formatBytes(usedSpace);
|
||||
String get quotaLabel => AiModelInfo._formatBytes(quota);
|
||||
double get usageRatio => quota <= 0 ? 0 : (usedSpace / quota).clamp(0.0, 1.0);
|
||||
|
||||
bool isActive(AiModelInfo model) {
|
||||
final current = active[model.kind.toLowerCase()];
|
||||
return current != null && current == model.id;
|
||||
}
|
||||
|
||||
factory ModelsSnapshot.fromJson(Map<String, dynamic> json) {
|
||||
final rawModels = json['models'];
|
||||
final models = <AiModelInfo>[];
|
||||
if (rawModels is List) {
|
||||
for (final item in rawModels) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
models.add(AiModelInfo.fromJson(item));
|
||||
} else if (item is Map) {
|
||||
models.add(AiModelInfo.fromJson(Map<String, dynamic>.from(item)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final active = <String, String>{};
|
||||
final rawActive = json['active'];
|
||||
if (rawActive is Map) {
|
||||
rawActive.forEach((key, value) {
|
||||
active[key.toString().toLowerCase()] = value.toString();
|
||||
});
|
||||
}
|
||||
|
||||
return ModelsSnapshot(
|
||||
models: models,
|
||||
active: active,
|
||||
usedSpace: AiModelInfo._asInt(json['used_space']),
|
||||
quota: AiModelInfo._asInt(json['quota']),
|
||||
);
|
||||
}
|
||||
}
|
||||
92
clients/flutter/lib/models/character_pack.dart
Normal file
92
clients/flutter/lib/models/character_pack.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
class CharacterPack {
|
||||
const CharacterPack({
|
||||
required this.filename,
|
||||
required this.name,
|
||||
required this.characterType,
|
||||
required this.renderType,
|
||||
this.live2dModel,
|
||||
});
|
||||
|
||||
final String filename;
|
||||
final String name;
|
||||
final String characterType;
|
||||
final String renderType;
|
||||
final String? live2dModel;
|
||||
|
||||
bool get isLive2d => renderType.toLowerCase() == 'live2d';
|
||||
|
||||
String get typeLabel {
|
||||
switch (characterType.toLowerCase()) {
|
||||
case 'human':
|
||||
return '数字人';
|
||||
case 'singer':
|
||||
return '歌姬';
|
||||
case 'live2d':
|
||||
return 'Live2D';
|
||||
case 'pet':
|
||||
default:
|
||||
return '宠物';
|
||||
}
|
||||
}
|
||||
|
||||
IconDataLike get iconHint {
|
||||
switch (characterType.toLowerCase()) {
|
||||
case 'human':
|
||||
return IconDataLike.person;
|
||||
case 'singer':
|
||||
return IconDataLike.mic;
|
||||
case 'live2d':
|
||||
return IconDataLike.sparkle;
|
||||
default:
|
||||
return IconDataLike.pets;
|
||||
}
|
||||
}
|
||||
|
||||
factory CharacterPack.fromJson(Map<String, dynamic> json) {
|
||||
final character = json['character'];
|
||||
final Map<String, dynamic> charMap = character is Map
|
||||
? Map<String, dynamic>.from(character)
|
||||
: const <String, dynamic>{};
|
||||
final filename = json['filename']?.toString() ?? '';
|
||||
return CharacterPack(
|
||||
filename: filename,
|
||||
name: charMap['name']?.toString().isNotEmpty == true
|
||||
? charMap['name'].toString()
|
||||
: filename,
|
||||
characterType: charMap['character_type']?.toString() ?? 'pet',
|
||||
renderType: charMap['render_type']?.toString() ?? 'video',
|
||||
live2dModel: charMap['live2d_model']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Avoid importing Flutter material in pure model unit tests.
|
||||
enum IconDataLike { pets, person, mic, sparkle }
|
||||
|
||||
class AvailableConfigs {
|
||||
const AvailableConfigs({
|
||||
required this.configs,
|
||||
required this.active,
|
||||
});
|
||||
|
||||
final List<CharacterPack> configs;
|
||||
final String active;
|
||||
|
||||
factory AvailableConfigs.fromJson(Map<String, dynamic> json) {
|
||||
final raw = json['configs'];
|
||||
final list = <CharacterPack>[];
|
||||
if (raw is List) {
|
||||
for (final item in raw) {
|
||||
if (item is Map<String, dynamic>) {
|
||||
list.add(CharacterPack.fromJson(item));
|
||||
} else if (item is Map) {
|
||||
list.add(CharacterPack.fromJson(Map<String, dynamic>.from(item)));
|
||||
}
|
||||
}
|
||||
}
|
||||
return AvailableConfigs(
|
||||
configs: list,
|
||||
active: json['active']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
45
clients/flutter/lib/models/chat_models.dart
Normal file
45
clients/flutter/lib/models/chat_models.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
class ChatTurnResponse {
|
||||
const ChatTurnResponse({
|
||||
required this.sessionId,
|
||||
required this.replyText,
|
||||
this.transcription,
|
||||
this.replyAudioPath,
|
||||
this.error,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final String replyText;
|
||||
final String? transcription;
|
||||
final String? replyAudioPath;
|
||||
final String? error;
|
||||
|
||||
bool get isOk => error == null || error!.isEmpty;
|
||||
|
||||
factory ChatTurnResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ChatTurnResponse(
|
||||
sessionId: json['session_id']?.toString() ?? '',
|
||||
replyText: json['reply_text']?.toString() ?? '',
|
||||
transcription: json['transcription']?.toString(),
|
||||
replyAudioPath: json['reply_audio_path']?.toString(),
|
||||
error: json['error']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ChatBubbleRole { user, assistant, system }
|
||||
|
||||
class ChatBubble {
|
||||
ChatBubble({
|
||||
required this.role,
|
||||
required this.text,
|
||||
this.audioPath,
|
||||
this.isPending = false,
|
||||
DateTime? createdAt,
|
||||
}) : createdAt = createdAt ?? DateTime.now();
|
||||
|
||||
final ChatBubbleRole role;
|
||||
String text;
|
||||
String? audioPath;
|
||||
bool isPending;
|
||||
final DateTime createdAt;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -31,16 +31,21 @@ class AppShell extends StatelessWidget {
|
||||
selectedIcon: Icon(Icons.home),
|
||||
label: '首页',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.face_outlined),
|
||||
selectedIcon: Icon(Icons.face),
|
||||
label: '角色',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.chat_bubble_outline),
|
||||
selectedIcon: Icon(Icons.chat_bubble),
|
||||
label: '对话',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.play_circle_outline),
|
||||
selectedIcon: Icon(Icons.play_circle),
|
||||
label: '播放',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.bolt_outlined),
|
||||
selectedIcon: Icon(Icons.bolt),
|
||||
label: '触发',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.wifi_outlined),
|
||||
selectedIcon: Icon(Icons.wifi),
|
||||
@@ -51,11 +56,6 @@ class AppShell extends StatelessWidget {
|
||||
selectedIcon: Icon(Icons.settings),
|
||||
label: '设置',
|
||||
),
|
||||
NavigationDestination(
|
||||
icon: Icon(Icons.bug_report_outlined),
|
||||
selectedIcon: Icon(Icons.bug_report),
|
||||
label: '调试',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
202
clients/flutter/lib/screens/characters_screen.dart
Normal file
202
clients/flutter/lib/screens/characters_screen.dart
Normal file
@@ -0,0 +1,202 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/character_pack.dart';
|
||||
import '../providers/character_provider.dart';
|
||||
import '../providers/chat_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class CharactersScreen extends StatefulWidget {
|
||||
const CharactersScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CharactersScreen> createState() => _CharactersScreenState();
|
||||
}
|
||||
|
||||
class _CharactersScreenState extends State<CharactersScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<CharacterProvider>().refresh();
|
||||
});
|
||||
}
|
||||
|
||||
IconData _iconFor(CharacterPack pack) {
|
||||
switch (pack.iconHint) {
|
||||
case IconDataLike.person:
|
||||
return Icons.person_rounded;
|
||||
case IconDataLike.mic:
|
||||
return Icons.mic_rounded;
|
||||
case IconDataLike.sparkle:
|
||||
return Icons.auto_awesome_rounded;
|
||||
case IconDataLike.pets:
|
||||
return Icons.pets_rounded;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<CharacterProvider>();
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('角色'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: provider.isLoading ? null : () => provider.refresh(),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: provider.refresh,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
if (provider.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
child: Text(
|
||||
provider.error!,
|
||||
style: const TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'选择角色内容包,设备画面与对话人设会同步切换。',
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
if (provider.isLoading && provider.packs.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (provider.packs.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: Center(child: Text('暂无角色配置包')),
|
||||
)
|
||||
else
|
||||
...provider.packs.map((pack) {
|
||||
final active = pack.filename == provider.activeFilename;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(AppRadius.large),
|
||||
onTap: provider.isSwitching || active
|
||||
? null
|
||||
: () async {
|
||||
final ok = await provider.switchTo(pack.filename);
|
||||
if (!context.mounted) return;
|
||||
if (ok) {
|
||||
// 切换角色清空对话上下文(PRD §2.4)
|
||||
context.read<ChatProvider>().clearHistory();
|
||||
}
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
ok
|
||||
? '已切换到 ${pack.name}'
|
||||
: (provider.error ?? '切换失败'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: active
|
||||
? AppColors.primary.withValues(alpha: 0.2)
|
||||
: AppColors.background,
|
||||
child: Icon(
|
||||
_iconFor(pack),
|
||||
color: active
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
pack.name,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.titleMedium,
|
||||
),
|
||||
),
|
||||
if (active) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.success
|
||||
.withValues(alpha: 0.15),
|
||||
borderRadius:
|
||||
BorderRadius.circular(999),
|
||||
),
|
||||
child: const Text(
|
||||
'使用中',
|
||||
style: TextStyle(
|
||||
color: AppColors.success,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${pack.typeLabel} · ${pack.renderType} · ${pack.filename}',
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (provider.isSwitching && !active)
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
Icon(
|
||||
active
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.chevron_right_rounded,
|
||||
color: active
|
||||
? AppColors.success
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
232
clients/flutter/lib/screens/chat_screen.dart
Normal file
232
clients/flutter/lib/screens/chat_screen.dart
Normal file
@@ -0,0 +1,232 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/chat_models.dart';
|
||||
import '../providers/character_provider.dart';
|
||||
import '../providers/chat_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
const ChatScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ChatScreen> createState() => _ChatScreenState();
|
||||
}
|
||||
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<CharacterProvider>().refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _scrollToEnd() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!_scrollController.hasClients) return;
|
||||
_scrollController.animateTo(
|
||||
_scrollController.position.maxScrollExtent + 80,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _sendText() async {
|
||||
final text = _controller.text;
|
||||
_controller.clear();
|
||||
await context.read<ChatProvider>().sendText(text);
|
||||
_scrollToEnd();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chat = context.watch<ChatProvider>();
|
||||
final character = context.watch<CharacterProvider>();
|
||||
final activeName = character.activePack?.name ?? '当前角色';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('对话 · $activeName'),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '清空会话',
|
||||
onPressed: chat.isBusy ? null : chat.clearHistory,
|
||||
icon: const Icon(Icons.delete_outline_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (chat.status != null)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
color: AppColors.card,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppSpacing.md,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(
|
||||
chat.status!,
|
||||
style: TextStyle(
|
||||
color: chat.error != null
|
||||
? AppColors.error
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: chat.messages.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
child: Text(
|
||||
'文字发送,或按住下方麦克风说话。\n回复会在手机播放(设备本地不出声)。',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
itemCount: chat.messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = chat.messages[index];
|
||||
final isUser = msg.role == ChatBubbleRole.user;
|
||||
return Align(
|
||||
alignment: isUser
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? AppColors.primary.withValues(alpha: 0.25)
|
||||
: AppColors.card,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
msg.text,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
if (msg.audioPath != null &&
|
||||
msg.audioPath!.isNotEmpty &&
|
||||
!msg.isPending) ...[
|
||||
const SizedBox(height: 8),
|
||||
TextButton.icon(
|
||||
onPressed: () =>
|
||||
chat.playReply(msg.audioPath!),
|
||||
icon: const Icon(Icons.volume_up_rounded),
|
||||
label: const Text('重播语音'),
|
||||
),
|
||||
],
|
||||
if (msg.isPending)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 6),
|
||||
child: SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _controller,
|
||||
enabled: !chat.isBusy && !chat.isRecording,
|
||||
minLines: 1,
|
||||
maxLines: 4,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入文字和角色对话...',
|
||||
),
|
||||
onSubmitted: (_) => _sendText(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filled(
|
||||
onPressed: chat.isBusy || chat.isRecording ? null : _sendText,
|
||||
icon: const Icon(Icons.send_rounded),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onLongPressStart: (_) async {
|
||||
final ok =
|
||||
await context.read<ChatProvider>().startRecording();
|
||||
if (!ok && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
context.read<ChatProvider>().error ?? '无法录音',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPressEnd: (_) async {
|
||||
await context.read<ChatProvider>().stopRecordingAndSend();
|
||||
_scrollToEnd();
|
||||
},
|
||||
onLongPressCancel: () {
|
||||
context.read<ChatProvider>().cancelRecording();
|
||||
},
|
||||
child: CircleAvatar(
|
||||
radius: 26,
|
||||
backgroundColor: chat.isRecording
|
||||
? AppColors.error
|
||||
: AppColors.primary.withValues(alpha: 0.2),
|
||||
child: Icon(
|
||||
chat.isRecording ? Icons.mic : Icons.mic_none_rounded,
|
||||
color: chat.isRecording
|
||||
? Colors.white
|
||||
: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../providers/character_provider.dart';
|
||||
import '../providers/device_provider.dart';
|
||||
import '../providers/player_provider.dart';
|
||||
import '../providers/wifi_provider.dart';
|
||||
@@ -16,9 +18,11 @@ class HomeScreen extends StatelessWidget {
|
||||
final deviceProvider = context.watch<DeviceProvider>();
|
||||
final playerProvider = context.watch<PlayerProvider>();
|
||||
final wifiProvider = context.watch<WifiProvider>();
|
||||
final characterProvider = context.watch<CharacterProvider>();
|
||||
final device = deviceProvider.status;
|
||||
final player = playerProvider.status;
|
||||
final wifi = wifiProvider.status;
|
||||
final characterName = characterProvider.activePack?.name ?? '未同步';
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('ShowenV2 控制台')),
|
||||
@@ -28,6 +32,7 @@ class HomeScreen extends StatelessWidget {
|
||||
context.read<DeviceProvider>().refresh(),
|
||||
context.read<PlayerProvider>().bootstrap(),
|
||||
context.read<WifiProvider>().bootstrap(),
|
||||
context.read<CharacterProvider>().refresh(),
|
||||
]);
|
||||
},
|
||||
child: ListView(
|
||||
@@ -41,6 +46,16 @@ class HomeScreen extends StatelessWidget {
|
||||
accentColor: device.connected ? AppColors.success : AppColors.warning,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
StatusCard(
|
||||
title: '当前角色',
|
||||
value: characterName,
|
||||
subtitle: characterProvider.activeFilename.isEmpty
|
||||
? '点击角色页切换内容包'
|
||||
: characterProvider.activeFilename,
|
||||
icon: Icons.face_rounded,
|
||||
accentColor: AppColors.secondary,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
StatusCard(
|
||||
title: '当前播放状态',
|
||||
value: player.currentVideo ?? '暂无播放视频',
|
||||
@@ -59,6 +74,27 @@ class HomeScreen extends StatelessWidget {
|
||||
accentColor: wifi.connected ? AppColors.info : AppColors.border,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '去对话',
|
||||
icon: Icons.chat_bubble_rounded,
|
||||
onPressed: () => context.go('/chat'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: ControlButton(
|
||||
label: '换角色',
|
||||
icon: Icons.face_rounded,
|
||||
isFilled: false,
|
||||
onPressed: () => context.go('/characters'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Text('快捷控制', style: Theme.of(context).textTheme.headlineSmall),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
Row(
|
||||
|
||||
229
clients/flutter/lib/screens/models_screen.dart
Normal file
229
clients/flutter/lib/screens/models_screen.dart
Normal file
@@ -0,0 +1,229 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/ai_model.dart';
|
||||
import '../providers/model_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class ModelsScreen extends StatefulWidget {
|
||||
const ModelsScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ModelsScreen> createState() => _ModelsScreenState();
|
||||
}
|
||||
|
||||
class _ModelsScreenState extends State<ModelsScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<ModelProvider>().refresh();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final provider = context.watch<ModelProvider>();
|
||||
final snapshot = provider.snapshot;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('模型管理'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: provider.isLoading ? null : () => provider.refresh(),
|
||||
icon: const Icon(Icons.refresh_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: provider.refresh,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
if (provider.error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Text(
|
||||
provider.error!,
|
||||
style: const TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
if (provider.status != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: AppSpacing.sm),
|
||||
child: Text(
|
||||
provider.status!,
|
||||
style: const TextStyle(color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
if (snapshot != null) ...[
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'存储 ${snapshot.usedLabel} / ${snapshot.quotaLabel}',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
LinearProgressIndicator(value: snapshot.usageRatio),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'激活: LLM=${snapshot.active['llm'] ?? '-'} · '
|
||||
'ASR=${snapshot.active['asr'] ?? '-'} · '
|
||||
'TTS=${snapshot.active['tts'] ?? '-'}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
],
|
||||
if (provider.isLoading && snapshot == null)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
)
|
||||
else if (snapshot == null || snapshot.models.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 48),
|
||||
child: Center(child: Text('暂无模型清单(AI 插件可能未就绪)')),
|
||||
)
|
||||
else
|
||||
...snapshot.models.map((model) {
|
||||
final active = snapshot.isActive(model);
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
model.name,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
),
|
||||
if (active)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
AppColors.success.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Text(
|
||||
'使用中',
|
||||
style: TextStyle(
|
||||
color: AppColors.success,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${model.kindLabel} · ${model.sizeLabel} · 内存 ${model.memoryLabel}',
|
||||
style:
|
||||
Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (model.recommendedTier.isNotEmpty)
|
||||
Text(
|
||||
model.recommendedTier,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.copyWith(color: AppColors.textSecondary),
|
||||
),
|
||||
if (!model.downloaded && model.downloadProgress > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: LinearProgressIndicator(
|
||||
value: model.downloadProgress / 100.0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
if (!model.downloaded)
|
||||
FilledButton.tonal(
|
||||
onPressed: provider.isActing
|
||||
? null
|
||||
: () => provider.download(model.id),
|
||||
child: Text(
|
||||
model.downloadProgress > 0 &&
|
||||
model.downloadProgress < 100
|
||||
? '下载中 ${model.downloadProgress}%'
|
||||
: '下载',
|
||||
),
|
||||
),
|
||||
if (model.downloaded && !active)
|
||||
FilledButton(
|
||||
onPressed: provider.isActing
|
||||
? null
|
||||
: () => provider.switchTo(
|
||||
modelId: model.id,
|
||||
kind: model.kind,
|
||||
),
|
||||
child: const Text('切换使用'),
|
||||
),
|
||||
if (model.downloaded && !active)
|
||||
OutlinedButton(
|
||||
onPressed: provider.isActing
|
||||
? null
|
||||
: () => _confirmDelete(context, model),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmDelete(BuildContext context, AiModelInfo model) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('删除模型'),
|
||||
content: Text('确定删除 ${model.name}?此操作不可恢复。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true && context.mounted) {
|
||||
await context.read<ModelProvider>().delete(model.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/video_item.dart';
|
||||
@@ -80,6 +81,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
children: [
|
||||
Card(
|
||||
child: Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.memory_rounded),
|
||||
title: const Text('模型管理'),
|
||||
subtitle: const Text('下载 / 切换 / 删除 AI 模型'),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => context.push('/settings/models'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.bolt_rounded),
|
||||
title: const Text('状态机触发'),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => context.push('/settings/trigger'),
|
||||
),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.bug_report_rounded),
|
||||
title: const Text('调试日志'),
|
||||
trailing: const Icon(Icons.chevron_right_rounded),
|
||||
onTap: () => context.push('/settings/debug'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
@@ -464,9 +493,17 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
Map<String, dynamic>.from(results[0] as Map<String, dynamic>);
|
||||
final available =
|
||||
Map<String, dynamic>.from(results[1] as Map<String, dynamic>);
|
||||
// M2.1: configs 为 {filename, character} 对象列表;兼容旧版纯字符串列表
|
||||
_availableConfigs =
|
||||
(available['configs'] as List<dynamic>? ?? const <dynamic>[])
|
||||
.map((item) => item.toString())
|
||||
.map((item) {
|
||||
if (item is String) return item;
|
||||
if (item is Map) {
|
||||
return item['filename']?.toString() ?? item.toString();
|
||||
}
|
||||
return item.toString();
|
||||
})
|
||||
.where((name) => name.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
_activeConfig = available['active']?.toString();
|
||||
_applyDisplayConfig(Map<String, dynamic>.from(
|
||||
|
||||
@@ -5,8 +5,11 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../models/ai_model.dart';
|
||||
import '../models/api_response.dart';
|
||||
import '../models/ble_status.dart';
|
||||
import '../models/character_pack.dart';
|
||||
import '../models/chat_models.dart';
|
||||
import '../models/player_status.dart';
|
||||
import '../models/video_item.dart';
|
||||
import '../models/wifi_network.dart';
|
||||
@@ -300,6 +303,118 @@ class HttpApiService {
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<AvailableConfigs> getAvailableCharacterConfigs() async {
|
||||
final map = await getAvailableConfigs();
|
||||
return AvailableConfigs.fromJson(map);
|
||||
}
|
||||
|
||||
// ── M2.1 AI 对话 / 模型管理 ──
|
||||
|
||||
Future<ChatTurnResponse> chatText({
|
||||
required String text,
|
||||
String? sessionId,
|
||||
}) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/chat/text'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(<String, dynamic>{
|
||||
'text': text,
|
||||
if (sessionId != null && sessionId.isNotEmpty) 'session_id': sessionId,
|
||||
}),
|
||||
);
|
||||
// AI 错误也以 JSON ChatResponse 返回(可能是 5xx)
|
||||
final map = _decodeMap(response.body);
|
||||
final result = ChatTurnResponse.fromJson(map);
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return result;
|
||||
}
|
||||
if (result.error != null && result.error!.isNotEmpty) {
|
||||
return result;
|
||||
}
|
||||
throw ApiException(
|
||||
map['message']?.toString() ?? '对话失败 (${response.statusCode})',
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ChatTurnResponse> chatAudio({
|
||||
required List<int> bytes,
|
||||
String format = 'wav',
|
||||
String? sessionId,
|
||||
}) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/chat/audio'),
|
||||
headers: <String, String>{
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Accept': 'application/json',
|
||||
'X-Audio-Format': format,
|
||||
if (sessionId != null && sessionId.isNotEmpty) 'X-Session-Id': sessionId,
|
||||
},
|
||||
body: bytes,
|
||||
);
|
||||
final map = _decodeMap(response.body);
|
||||
final result = ChatTurnResponse.fromJson(map);
|
||||
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||
return result;
|
||||
}
|
||||
if (result.error != null && result.error!.isNotEmpty) {
|
||||
return result;
|
||||
}
|
||||
throw ApiException(
|
||||
map['message']?.toString() ?? '语音对话失败 (${response.statusCode})',
|
||||
statusCode: response.statusCode,
|
||||
);
|
||||
}
|
||||
|
||||
String chatAudioFileUrl(String absolutePath) {
|
||||
return _uri(
|
||||
'/api/chat/audio/file',
|
||||
<String, String>{'path': absolutePath},
|
||||
).toString();
|
||||
}
|
||||
|
||||
Future<ModelsSnapshot> getModels() async {
|
||||
final response = await _client.get(_uri('/api/models'));
|
||||
_ensureSuccess(response);
|
||||
return ModelsSnapshot.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> downloadModel(String modelId) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/models/download'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(<String, dynamic>{'model_id': modelId}),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> switchModel({
|
||||
required String modelId,
|
||||
required String kind,
|
||||
}) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/models/switch'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(<String, dynamic>{
|
||||
'model_id': modelId,
|
||||
'kind': kind,
|
||||
}),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<ApiResponse> deleteModel(String modelId) async {
|
||||
final response = await _client.post(
|
||||
_uri('/api/models/delete'),
|
||||
headers: _jsonHeaders,
|
||||
body: jsonEncode(<String, dynamic>{'model_id': modelId}),
|
||||
);
|
||||
_ensureSuccess(response);
|
||||
return ApiResponse.fromJson(_decodeMap(response.body));
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> listFiles(String dirKey, [String? path]) async {
|
||||
final response = await _client.get(
|
||||
_uri('/api/files/$dirKey', _pathQuery(path)),
|
||||
|
||||
Reference in New Issue
Block a user