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

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