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:
2026-07-09 12:41:34 +08:00
parent 7cb8cee70d
commit 6f7e24db63
51 changed files with 2674 additions and 351 deletions

View File

@@ -7,6 +7,7 @@ plugins {
android {
namespace 'com.showen.flutter'
compileSdk flutter.compileSdkVersion
ndkVersion = "28.2.13676358"
defaultConfig {
applicationId 'com.showen.flutter'

View File

@@ -8,6 +8,8 @@
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:label="ShowenV2"

View File

@@ -15,11 +15,31 @@ import io.flutter.embedding.engine.FlutterEngine;
public final class GeneratedPluginRegistrant {
private static final String TAG = "GeneratedPluginRegistrant";
public static void registerWith(@NonNull FlutterEngine flutterEngine) {
try {
flutterEngine.getPlugins().add(new xyz.luan.audioplayers.AudioplayersPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin audioplayers_android, xyz.luan.audioplayers.AudioplayersPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.lib.flutter_blue_plus.FlutterBluePlusPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_blue_plus_android, com.lib.flutter_blue_plus.FlutterBluePlusPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.github.dart_lang.jni.JniPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin jni, com.github.dart_lang.jni.JniPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.github.dart_lang.jni_flutter.JniFlutterPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin jni_flutter, com.github.dart_lang.jni_flutter.JniFlutterPlugin", e);
}
try {
flutterEngine.getPlugins().add(new com.llfbandit.record.RecordPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin record_android, com.llfbandit.record.RecordPlugin", e);
}
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin());
} catch (Exception e) {

View File

@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx1536M -XX:MaxMetaspaceSize=512M -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@@ -21,8 +21,8 @@ pluginManagement {
plugins {
id 'dev.flutter.flutter-plugin-loader' version '1.0.0'
id 'com.android.application' version '8.3.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.22' apply false
id 'com.android.application' version '8.7.3' apply false
id 'org.jetbrains.kotlin.android' version '2.1.0' apply false
}
dependencyResolutionManagement {

View File

@@ -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(),
),
],
),
],
),

View 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']),
);
}
}

View 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() ?? '',
);
}
}

View 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;
}

View 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();
}
}
}

View 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();
}
}

View 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();
}
}

View File

@@ -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: '调试',
),
],
),
);

View 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,
),
],
),
),
),
);
}),
],
),
),
);
}
}

View 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,
),
),
),
],
),
),
),
],
),
);
}
}

View File

@@ -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(

View 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);
}
}
}

View File

@@ -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(

View File

@@ -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)),

View File

@@ -6,23 +6,79 @@ packages:
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
url: "https://pub.dev"
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.13.0"
version: "2.13.1"
audioplayers:
dependency: "direct main"
description:
name: audioplayers
sha256: "2ba4bb2944baacbdd5372ff8254a8e7feb8c10d7739545e392f5605a8f618745"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.8.1"
audioplayers_android:
dependency: transitive
description:
name: audioplayers_android
sha256: f5ff5b15620fbab8cb0849e9636c48e2b96c3f0f71723bbbe2ad3c761b205f05
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.3.0"
audioplayers_darwin:
dependency: transitive
description:
name: audioplayers_darwin
sha256: "1ca553add991384ecf421b9569da850f3ab2472ffb83f6970b0416365abc51be"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.5.0"
audioplayers_linux:
dependency: transitive
description:
name: audioplayers_linux
sha256: "15178b726b7cdee5364d0463c8d445630c4e0fb7d26612b73c767e7d25de9417"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.3.0"
audioplayers_platform_interface:
dependency: transitive
description:
name: audioplayers_platform_interface
sha256: "765f6f0e6dca55cb471c9483fc77700564b3484d19198aca4ebb5147c6c85acb"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.2.0"
audioplayers_web:
dependency: transitive
description:
name: audioplayers_web
sha256: ae1e0103c865a03e273f6d13d97b93f5595eac09915729cd5e37ef96e2857319
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.3.0"
audioplayers_windows:
dependency: transitive
description:
name: audioplayers_windows
sha256: a70ae82bba2dfcb6eb03dd4815d737a2d46d33ea5a96a03f535cfcaac490e413
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.4.1"
bluez:
dependency: transitive
description:
name: bluez
sha256: "61a7204381925896a374301498f2f5399e59827c6498ae1e924aaa598751b545"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.8.3"
boolean_selector:
@@ -30,7 +86,7 @@ packages:
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
characters:
@@ -38,7 +94,7 @@ packages:
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
clock:
@@ -46,15 +102,23 @@ packages:
description:
name: clock
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.2"
code_assets:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.19.1"
crypto:
@@ -62,31 +126,31 @@ packages:
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6
url: "https://pub.dev"
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.8"
version: "1.0.9"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.12"
version: "0.7.14"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.3"
ffi:
@@ -94,7 +158,7 @@ packages:
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
file:
@@ -102,9 +166,17 @@ packages:
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
@@ -115,7 +187,7 @@ packages:
description:
name: flutter_blue_plus
sha256: "69a8c87c11fc792e8cf0f997d275484fbdb5143ac9f0ac4d424429700cb4e0ed"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.36.8"
flutter_blue_plus_android:
@@ -123,7 +195,7 @@ packages:
description:
name: flutter_blue_plus_android
sha256: "6f7fe7e69659c30af164a53730707edc16aa4d959e4c208f547b893d940f853d"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.4"
flutter_blue_plus_darwin:
@@ -131,7 +203,7 @@ packages:
description:
name: flutter_blue_plus_darwin
sha256: "682982862c1d964f4d54a3fb5fccc9e59a066422b93b7e22079aeecd9c0d38f8"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.3"
flutter_blue_plus_linux:
@@ -139,7 +211,7 @@ packages:
description:
name: flutter_blue_plus_linux
sha256: "56b0c45edd0a2eec8f85bd97a26ac3cd09447e10d0094fed55587bf0592e3347"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.3"
flutter_blue_plus_platform_interface:
@@ -147,7 +219,7 @@ packages:
description:
name: flutter_blue_plus_platform_interface
sha256: "84fbd180c50a40c92482f273a92069960805ce324e3673ad29c41d2faaa7c5c2"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.0"
flutter_blue_plus_web:
@@ -155,7 +227,7 @@ packages:
description:
name: flutter_blue_plus_web
sha256: a1aceee753d171d24c0e0cdadb37895b5e9124862721f25f60bb758e20b72c99
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.2"
flutter_lints:
@@ -163,7 +235,7 @@ packages:
description:
name: flutter_lints
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
flutter_test:
@@ -181,15 +253,23 @@ packages:
description:
name: go_router
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "14.8.1"
hooks:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.0.2"
http:
dependency: "direct main"
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
http_parser:
@@ -197,15 +277,31 @@ packages:
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
jni:
dependency: transitive
description:
name: jni
sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
jni_flutter:
dependency: transitive
description:
name: jni_flutter
sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
@@ -213,7 +309,7 @@ packages:
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.10"
leak_tracker_testing:
@@ -221,7 +317,7 @@ packages:
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.2"
lints:
@@ -229,7 +325,7 @@ packages:
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
logging:
@@ -237,7 +333,7 @@ packages:
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
matcher:
@@ -245,7 +341,7 @@ packages:
description:
name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.12.19"
material_color_utilities:
@@ -253,55 +349,95 @@ packages:
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.17.0"
version: "1.18.0"
nested:
dependency: transitive
description:
name: nested
sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.0"
objective_c:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "9.4.1"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.9.1"
path_provider:
dependency: "direct main"
description:
name: path_provider
sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.6"
path_provider_android:
dependency: transitive
description:
name: path_provider_android
sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.1"
path_provider_foundation:
dependency: transitive
description:
name: path_provider_foundation
sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.6.0"
path_provider_linux:
dependency: transitive
description:
name: path_provider_linux
sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279
url: "https://pub.dev"
sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.1"
version: "2.2.2"
path_provider_platform_interface:
dependency: transitive
description:
name: path_provider_platform_interface
sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334"
url: "https://pub.dev"
sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.2"
version: "2.1.3"
path_provider_windows:
dependency: transitive
description:
name: path_provider_windows
sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.3.0"
petitparser:
@@ -309,7 +445,7 @@ packages:
description:
name: petitparser
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "7.0.2"
platform:
@@ -317,7 +453,7 @@ packages:
description:
name: platform
sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.6"
plugin_platform_interface:
@@ -325,7 +461,7 @@ packages:
description:
name: plugin_platform_interface
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.8"
provider:
@@ -333,39 +469,119 @@ packages:
description:
name: provider
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.1.5+1"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
record:
dependency: "direct main"
description:
name: record
sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.2.1"
record_android:
dependency: transitive
description:
name: record_android
sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.2"
record_ios:
dependency: transitive
description:
name: record_ios
sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.1"
record_linux:
dependency: transitive
description:
name: record_linux
sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.1"
record_macos:
dependency: transitive
description:
name: record_macos
sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.2"
record_platform_interface:
dependency: transitive
description:
name: record_platform_interface
sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.6.0"
record_use:
dependency: transitive
description:
name: record_use
sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.6.0"
record_web:
dependency: transitive
description:
name: record_web
sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.3.0"
record_windows:
dependency: transitive
description:
name: record_windows
sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.7"
rxdart:
dependency: transitive
description:
name: rxdart
sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.28.0"
shared_preferences:
dependency: "direct main"
description:
name: shared_preferences
sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64"
url: "https://pub.dev"
sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.4"
version: "2.5.5"
shared_preferences_android:
dependency: transitive
description:
name: shared_preferences_android
sha256: "8374d6200ab33ac99031a852eba4c8eb2170c4bf20778b3e2c9eccb45384fb41"
url: "https://pub.dev"
sha256: "93ae5884a9df5d3bb696825bceb3a17590754548b5d740eba51500afc8d088f5"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.21"
version: "2.4.26"
shared_preferences_foundation:
dependency: transitive
description:
name: shared_preferences_foundation
sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.5.6"
shared_preferences_linux:
@@ -373,23 +589,23 @@ packages:
description:
name: shared_preferences_linux
sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
shared_preferences_platform_interface:
dependency: transitive
description:
name: shared_preferences_platform_interface
sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80"
url: "https://pub.dev"
sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
version: "2.4.2"
shared_preferences_web:
dependency: transitive
description:
name: shared_preferences_web
sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.3"
shared_preferences_windows:
@@ -397,7 +613,7 @@ packages:
description:
name: shared_preferences_windows
sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.4.1"
sky_engine:
@@ -410,7 +626,7 @@ packages:
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.10.2"
stack_trace:
@@ -418,7 +634,7 @@ packages:
description:
name: stack_trace
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.12.1"
stream_channel:
@@ -426,7 +642,7 @@ packages:
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.1.4"
string_scanner:
@@ -434,55 +650,71 @@ packages:
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
synchronized:
dependency: transitive
description:
name: synchronized
sha256: "93b153dcb6a26dcddee6ca087dd634b53e38c10b5aa163e8e49501a776456153"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.7.10"
version: "0.7.11"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.0"
uuid:
dependency: transitive
description:
name: uuid
sha256: "1fef9e8e11e2991bb773070d4656b7bd5d850967a2456cfc83cf47925ba79489"
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.5.3"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "2.2.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
url: "https://pub.dev"
sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360"
url: "https://pub.flutter-io.cn"
source: hosted
version: "15.0.2"
version: "15.2.0"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.1"
web_socket:
@@ -490,7 +722,7 @@ packages:
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.0.1"
web_socket_channel:
@@ -498,7 +730,7 @@ packages:
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.3"
xdg_directories:
@@ -506,7 +738,7 @@ packages:
description:
name: xdg_directories
sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.1.0"
xml:
@@ -514,9 +746,17 @@ packages:
description:
name: xml
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
url: "https://pub.flutter-io.cn"
source: hosted
version: "6.6.1"
yaml:
dependency: transitive
description:
name: yaml
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0 <4.0.0"
flutter: ">=3.35.0"
dart: ">=3.12.0 <4.0.0"
flutter: ">=3.44.0"

View File

@@ -2,7 +2,7 @@ name: showen_v2_flutter
description: ShowenV2 cross-platform mobile controller.
publish_to: 'none'
version: 0.1.0+1
version: 0.4.0+4
environment:
sdk: '>=3.3.0 <4.0.0'
@@ -17,6 +17,9 @@ dependencies:
provider: ^6.1.2
web_socket_channel: ^3.0.1
shared_preferences: ^2.3.0
record: ^6.0.0
audioplayers: ^6.1.0
path_provider: ^2.1.5
dev_dependencies:
flutter_test:

View File

@@ -0,0 +1,90 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:showen_v2_flutter/models/ai_model.dart';
import 'package:showen_v2_flutter/models/character_pack.dart';
import 'package:showen_v2_flutter/models/chat_models.dart';
void main() {
group('CharacterPack / AvailableConfigs', () {
test('parses M2.1 config available payload', () {
final snapshot = AvailableConfigs.fromJson({
'active': 'dog_state_machine.json',
'configs': [
{
'filename': 'dog_state_machine.json',
'character': {
'name': '小狗',
'character_type': 'pet',
'render_type': 'video',
},
},
{
'filename': 'live2d_demo.json',
'character': {
'name': 'Live2D',
'character_type': 'live2d',
'render_type': 'live2d',
'live2d_model': 'Hiyori',
},
},
],
});
expect(snapshot.active, 'dog_state_machine.json');
expect(snapshot.configs, hasLength(2));
expect(snapshot.configs.first.name, '小狗');
expect(snapshot.configs.first.typeLabel, '宠物');
expect(snapshot.configs.last.isLive2d, isTrue);
});
});
group('ChatTurnResponse', () {
test('parses success and error responses', () {
final ok = ChatTurnResponse.fromJson({
'session_id': 'app_1',
'transcription': '你好',
'reply_text': '汪!',
'reply_audio_path': '/tmp/tts.wav',
'error': null,
});
expect(ok.isOk, isTrue);
expect(ok.replyText, '汪!');
expect(ok.replyAudioPath, '/tmp/tts.wav');
final err = ChatTurnResponse.fromJson({
'session_id': '',
'reply_text': '',
'error': 'AI 插件未就绪',
});
expect(err.isOk, isFalse);
expect(err.error, 'AI 插件未就绪');
});
});
group('ModelsSnapshot', () {
test('parses model list and active map', () {
final snapshot = ModelsSnapshot.fromJson({
'models': [
{
'id': 'qwen2.5-0.5b-q4_k_m',
'kind': 'llm',
'name': 'Qwen2.5 0.5B',
'version': '1',
'size': 400000000,
'memory_required': 1000000000,
'recommended_tier': '4G',
'downloaded': true,
'download_progress': 100,
},
],
'active': {'llm': 'qwen2.5-0.5b-q4_k_m', 'asr': 'whisper-tiny'},
'used_space': 500000000,
'quota': 4000000000,
});
expect(snapshot.models, hasLength(1));
expect(snapshot.isActive(snapshot.models.first), isTrue);
expect(snapshot.usageRatio, closeTo(0.125, 0.001));
expect(snapshot.models.first.kindLabel, 'LLM');
});
});
}