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:
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user