diff --git a/lib/api/ai_api.dart b/lib/api/ai_api.dart index dc323be..493019a 100644 --- a/lib/api/ai_api.dart +++ b/lib/api/ai_api.dart @@ -7,6 +7,7 @@ import '../core/network/api_exception.dart'; import '../core/network/dio_client.dart'; import '../models/ai.dart'; import '../models/ai_config.dart'; +import '../models/ai_persona.dart'; import '../models/json_util.dart'; final aiApiProvider = Provider((ref) => AiApi(ref.watch(dioProvider))); @@ -68,13 +69,24 @@ class AiApi { /// (`event: xxx` / `data: {...}` / 空行分隔),手动解析比多引一个依赖可控。 /// /// 注意:[sessionId] 传 null 表示开新会话,会话 ID 会由后端在 done 事件里返回。 - Stream chat({int? sessionId, int? configId, required String content}) async* { + Stream chat({ + int? sessionId, + int? configId, + int? personaId, + required String content, + }) async* { final Response res; try { res = await _dio.post( '/ai/chat', // configId 传 null = 用服务端 .env 里那套(列表里虚拟的「默认」项) - data: {'sessionId': sessionId, 'configId': configId, 'content': content}, + // personaId 传 null = 用服务端内置的默认人设。两者互相独立 + data: { + 'sessionId': sessionId, + 'configId': configId, + 'personaId': personaId, + 'content': content, + }, options: Options( responseType: ResponseType.stream, // LLM 生成慢 + 工具循环可能跑好几轮,给足;后端 SseEmitter 是 180s @@ -200,6 +212,66 @@ class AiApi { } } + // ---------------------------------------------------------------- AI 人设 + + /// 人设列表。后端把虚拟的「默认」项放在第一位(id 为 null,带 defaultPrompt)。 + Future> personas() async { + try { + final res = await _dio.get('/ai/personas'); + final data = res.data; + if (data is! List) return const []; + return asMapList(data).map(AiPersonaItem.fromJson).toList(); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '人设列表加载失败'); + } + } + + /// 新增一套人设。 + Future createPersona({ + required String name, + required String systemPrompt, + }) async { + try { + final res = await _dio.post('/ai/personas', data: { + 'name': name, + 'systemPrompt': systemPrompt, + }); + return AiPersonaItem.fromJson(Map.from(res.data as Map)); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '保存失败'); + } + } + + /// 修改人设。提示词是**整体替换**的 —— 每次都把完整文本传上来, + /// 没有模型配置那种「key 留空 = 不改动」的语义。 + Future updatePersona({ + required int id, + required String name, + required String systemPrompt, + }) async { + try { + final res = await _dio.put('/ai/personas/$id', data: { + 'name': name, + 'systemPrompt': systemPrompt, + }); + return AiPersonaItem.fromJson(Map.from(res.data as Map)); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '保存失败'); + } + } + + Future deletePersona(int id) async { + try { + await _dio.delete('/ai/personas/$id'); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '删除失败'); + } + } + AiEvent? _parseEvent(String event, String payload) { final decoded = jsonDecode(payload); if (decoded is! Map) return null; diff --git a/lib/core/storage/local_storage.dart b/lib/core/storage/local_storage.dart index 47c4be9..cbef1f2 100644 --- a/lib/core/storage/local_storage.dart +++ b/lib/core/storage/local_storage.dart @@ -127,4 +127,22 @@ class LocalStorage { /// 读选中的配置。null = 用默认。 int? getAiConfigId() => _prefs?.getInt(_keyAiConfigId); + + // ---- AI 人设配置 ---- + // 与模型配置各存各的 —— 两者正交,切一个不该动另一个。 + // 同样只存「用户选中了哪一条」,人设文本本身在服务端。 + static const _keyAiPersonaId = 'ai_persona_id'; + + /// 存选中的人设。null 表示「默认」(服务端内置的那套)。 + Future saveAiPersonaId(int? id) async { + final prefs = await _getPrefs(); + if (id == null) { + await prefs.remove(_keyAiPersonaId); + } else { + await prefs.setInt(_keyAiPersonaId, id); + } + } + + /// 读选中的人设。null = 用内置默认。 + int? getAiPersonaId() => _prefs?.getInt(_keyAiPersonaId); } diff --git a/lib/models/ai_persona.dart b/lib/models/ai_persona.dart new file mode 100644 index 0000000..3d671a8 --- /dev/null +++ b/lib/models/ai_persona.dart @@ -0,0 +1,80 @@ +import 'json_util.dart'; + +/// 一套 AI 人设(system prompt) +/// +/// 与 [AiConfigItem](模型配置)是**平行的两套配置**:一个管「用哪个模型」, +/// 一个管「用什么人设」。两者正交 —— App 里切模型不会换人设,切人设也不会换模型。 +/// +/// ⚠️ [systemPrompt] 里**不含日期等环境信息**。那些是运行时事实, +/// 由服务端在每次对话时追加(今天是几号、「这个月」指哪个月)。 +/// 所以「新建时预填」拿到的是可编辑的那部分,用户不用管日期。 +class AiPersonaItem { + const AiPersonaItem({ + required this.id, + required this.name, + required this.systemPrompt, + required this.promptLength, + required this.isDefault, + required this.editable, + required this.defaultPrompt, + }); + + /// null = 虚拟的「默认」项(内容是服务端代码内置的) + final int? id; + + final String name; + + /// 人设 + 运行规则。默认项上是内置默认文本,用户项上是自己配的 + final String systemPrompt; + + /// 服务端数好的字数,前端直接显示 + final int promptLength; + + /// 是否是「默认」那一项 + final bool isDefault; + + /// 是否可编辑/删除。默认项为 false + final bool editable; + + /// 内置默认文本。**只有默认项上才有值**,供「恢复默认」按钮使用。 + /// + /// 放服务端下发而不是 App 里硬编码,是为了两边永远一致 —— + /// 以后改了默认人设,App 不用跟着改版本。 + final String? defaultPrompt; + + /// 用于「显示已选中的是哪一项」的稳定标识。 + /// + /// 默认项没有 id,用 -1 代替 —— 不能直接用 null 当 map key 去比较。 + int get selectionKey => id ?? -1; + + factory AiPersonaItem.fromJson(Map json) { + return AiPersonaItem( + id: asNullableInt(json['id']), + name: asString(json['name'], fallback: '未命名'), + systemPrompt: asString(json['systemPrompt']), + promptLength: asInt(json['promptLength']), + isDefault: json['isDefault'] == true, + editable: json['editable'] != false, + // 用户项上这个字段是 null —— 不能直接用 asNonEmpty(它要 fallback 参数, + // 而我们恰恰需要保留「null = 没有」这个语义,供「恢复默认」按钮判断) + defaultPrompt: _nullableNonEmpty(json['defaultPrompt']), + ); + } + + /// 列表里显示的两行预览:把换行压平,太长的截断 + String get preview { + final flat = systemPrompt.replaceAll(RegExp(r'\s+'), ' ').trim(); + if (flat.length <= 50) return flat; + return '${flat.substring(0, 50)}…'; + } +} + +/// 空串/null 都返回 null,否则原样返回。 +/// +/// 不用 `asNonEmpty` 是因为它需要一个 fallback 字符串,而这里 +/// 「有没有值」本身是有意义的(defaultPrompt 只有默认项才有)。 +String? _nullableNonEmpty(Object? value) { + if (value == null) return null; + final s = value.toString(); + return s.isEmpty ? null : s; +} diff --git a/lib/pages/assistant/ai_config_page.dart b/lib/pages/assistant/ai_config_page.dart index 665e380..63a1af0 100644 --- a/lib/pages/assistant/ai_config_page.dart +++ b/lib/pages/assistant/ai_config_page.dart @@ -7,15 +7,15 @@ import '../../../models/ai_config.dart'; import '../../../providers/ai_config_provider.dart'; import '../../../utils/toast.dart'; import '../../../widgets/state_views.dart'; +import 'ai_persona_page.dart'; -/// AI 模型配置管理:列出现有配置、切换、增删改。 +/// 「AI 设置」页:两个 Tab —— 模型 / 人设。 /// -/// 设计上的三个要点: -/// 1. **「默认」那一项是虚拟的** —— 值来自服务器 `.env`,不能编辑/删除。 -/// 它就是「什么都不选」的状态,选中后请求里 configId 传 null。 -/// 2. **列表不打勾谁在用**,打勾的是 [selectedAiConfigProvider] 里存的那个。 -/// 选中状态存在手机本地,不改服务端 —— 否则网页端和手机会互相打架。 -/// 3. **api key 只显示掩码**。编辑时留空 = 不改动。 +/// 为什么是 Tab 而不是顶栏再加一个胶囊:顶栏已经有返回、标题、当前模型胶囊、 +/// 历史、新对话五块,320 宽的小屏塞不下第二个(`page_layout_test.dart` 会抓出来)。 +/// 而且模型和人设本来就是**一对平行的配置**,放一个页面里切换很自然。 +/// +/// 入口函数名保留 `openAiConfigPage` —— 助手页的胶囊用它,不用改。 Future openAiConfigPage(BuildContext context) { return Navigator.of(context).push( MaterialPageRoute(builder: (_) => const AiConfigPage()), @@ -25,21 +25,52 @@ Future openAiConfigPage(BuildContext context) { class AiConfigPage extends ConsumerWidget { const AiConfigPage({super.key}); + @override + Widget build(BuildContext context, WidgetRef ref) { + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('AI 设置'), + bottom: const TabBar( + labelColor: Color(0xFFC8956E), + unselectedLabelColor: Color(0xFFBCAAA4), + indicatorColor: Color(0xFFC8956E), + indicatorSize: TabBarIndicatorSize.label, + tabs: [ + Tab(text: '模型'), + Tab(text: '人设'), + ], + ), + ), + body: const TabBarView( + children: [ + AiModelTab(), + AiPersonaTab(), + ], + ), + ), + ); + } +} + +/// 模型配置 Tab(原来的整页内容,去掉了自己的 Scaffold/AppBar) +class AiModelTab extends ConsumerWidget { + const AiModelTab({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { final configs = ref.watch(aiConfigsProvider); final selectedId = ref.watch(selectedAiConfigProvider); return Scaffold( - appBar: AppBar( - title: const Text('AI 模型'), - actions: [ - IconButton( - tooltip: '新增配置', - icon: const Icon(Icons.add), - onPressed: () => _openEditor(context, ref, null), - ), - ], + backgroundColor: const Color(0xFFFAF8F5), + floatingActionButton: FloatingActionButton( + onPressed: () => _openEditor(context, ref, null), + backgroundColor: const Color(0xFFC8956E), + foregroundColor: Colors.white, + tooltip: '新增配置', + child: const Icon(Icons.add), ), body: configs.when( loading: () => const LoadingView(tip: '正在加载配置...'), @@ -50,7 +81,7 @@ class AiConfigPage extends ConsumerWidget { data: (list) { final custom = list.where((c) => c.editable).length; return ListView( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 88), children: [ _introCard(), const SizedBox(height: 14), diff --git a/lib/pages/assistant/ai_persona_page.dart b/lib/pages/assistant/ai_persona_page.dart new file mode 100644 index 0000000..03601a7 --- /dev/null +++ b/lib/pages/assistant/ai_persona_page.dart @@ -0,0 +1,590 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../api/ai_api.dart'; +import '../../../core/network/api_exception.dart'; +import '../../../models/ai_persona.dart'; +import '../../../providers/ai_persona_provider.dart'; +import '../../../utils/toast.dart'; +import '../../../widgets/state_views.dart'; + +/// 人设列表页(不含 Scaffold/AppBar —— 它作为「AI 设置」页的一个 Tab 嵌进去) +/// +/// 与模型配置页 [AiConfigPage] 是**平行的两套**:一个管模型,一个管人设。 +/// 分成两个 Tab 而不是在顶栏加两个胶囊,是因为顶栏已经挤了五块东西, +/// 320 宽的小屏塞不下第二个胶囊。 +/// +/// 三个要点: +/// 1. **「默认」是虚拟项** —— 内容是服务端代码里的常量,不能编辑/删除。 +/// 2. **预填而不是空框** —— 新增时把内置默认文本填进去,用户改一改就用, +/// 而不是面对一片空白不知道写什么。 +/// 3. **提示词里不含日期** —— 那是运行时事实,服务端每次对话自动追加。 +class AiPersonaTab extends ConsumerWidget { + const AiPersonaTab({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final personas = ref.watch(aiPersonasProvider); + final selectedId = ref.watch(selectedAiPersonaProvider); + + return Scaffold( + backgroundColor: const Color(0xFFFAF8F5), + floatingActionButton: FloatingActionButton( + onPressed: () => _openEditor(context, ref, null), + backgroundColor: const Color(0xFFC8956E), + foregroundColor: Colors.white, + tooltip: '新增人设', + child: const Icon(Icons.add), + ), + body: personas.when( + loading: () => const LoadingView(tip: '正在加载人设...'), + error: (e, _) => ErrorView( + message: e is ApiException ? e.message : '人设加载失败', + onRetry: () => ref.invalidate(aiPersonasProvider), + ), + data: (list) { + final custom = list.where((p) => p.editable).length; + return ListView( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 88), + children: [ + _introCard(), + const SizedBox(height: 14), + for (final item in list) ...[ + _personaTile(context, ref, item, selectedId, custom), + const SizedBox(height: 10), + ], + if (custom == 0) _emptyHint(), + ], + ); + }, + ), + ); + } + + Widget _introCard() { + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFFFF3E6), + borderRadius: BorderRadius.circular(12), + ), + child: const Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '换个说话的人', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + SizedBox(height: 5), + Text( + '人设决定助手的说话风格和做事规则,和用哪个模型是两件独立的事 ——\n' + '换模型不会换人设。\n' + '提示词里不用写日期,服务端每次会自动补上今天几号、「这个月」指哪个月。', + style: TextStyle(fontSize: 12, height: 1.6, color: Color(0xFF8D6E63)), + ), + ], + ), + ); + } + + Widget _emptyHint() { + return Padding( + padding: const EdgeInsets.only(top: 4), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + ), + child: const Text( + '还没有自己的人设,用的就是「默认」那套。\n' + '想换个风格就点右下角「+」—— 会预填一份能用的,改一改就行。', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 12.5, height: 1.7, color: Color(0xFFBCAAA4)), + ), + ), + ); + } + + Widget _personaTile( + BuildContext context, + WidgetRef ref, + AiPersonaItem item, + int? selectedId, + int customCount, + ) { + // 默认项的 selectionKey 是 -1,不能用 id 比较 + final active = selectedId == null + ? item.isDefault + : item.selectionKey == selectedId; + + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: active ? const Color(0xFFC8956E) : const Color(0xFFF0E6D8), + width: active ? 1.5 : 1, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: () { + ref.read(selectedAiPersonaProvider.notifier).select(item.id); + showToast(context, '已切换到「${item.name}」', icon: 'check'); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 1), + child: Icon( + active + ? Icons.radio_button_checked + : Icons.radio_button_unchecked, + size: 19, + color: + active ? const Color(0xFFC8956E) : const Color(0xFFD7CCC8), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + item.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14.5, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ), + if (item.isDefault) ...[ + const SizedBox(width: 6), + _tag('默认', const Color(0xFF8D6E63)), + ], + if (item.promptLength > 0) ...[ + const SizedBox(width: 6), + _tag('${item.promptLength} 字', const Color(0xFFBCAAA4)), + ], + ], + ), + const SizedBox(height: 4), + // 提示词预览:两行封顶,别把列表撑散 + Text( + item.preview.isEmpty ? '(空提示词)' : item.preview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11.5, + height: 1.6, + color: Color(0xFFBCAAA4), + ), + ), + ], + ), + ), + // 默认项不给编辑/删除的入口 —— 它的内容是服务端代码里内置的 + if (item.editable) + PopupMenuButton( + icon: const Icon(Icons.more_vert, + size: 19, color: Color(0xFFBCAAA4)), + onSelected: (v) { + if (v == 'edit') { + _openEditor(context, ref, item); + } else { + _confirmDelete(context, ref, item, customCount); + } + }, + itemBuilder: (_) => const [ + PopupMenuItem(value: 'edit', child: Text('编辑')), + PopupMenuItem(value: 'delete', child: Text('删除')), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _tag(String text, Color color) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.11), + borderRadius: BorderRadius.circular(6), + ), + child: Text(text, style: TextStyle(fontSize: 10.5, color: color)), + ); + } + + // ---------------- 增删改 ---------------- + + Future _openEditor( + BuildContext context, + WidgetRef ref, + AiPersonaItem? existing, + ) async { + final saved = await showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _PersonaEditor(existing: existing), + ); + if (saved == true && context.mounted) { + ref.invalidate(aiPersonasProvider); + showToast(context, existing == null ? '已添加' : '已保存', icon: 'check'); + } + } + + Future _confirmDelete( + BuildContext context, + WidgetRef ref, + AiPersonaItem item, + int customCount, + ) async { + final ok = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: const Text('删除人设?', style: TextStyle(fontSize: 16)), + content: Text( + '「${item.name}」会被删掉。\n' + '${customCount == 1 ? '删掉之后会自动回到「默认」那套。' : ''}', + style: const TextStyle(fontSize: 13, height: 1.6), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(false), + child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(true), + child: const Text('删除', style: TextStyle(color: Color(0xFFE53935))), + ), + ], + ), + ); + if (ok != true || !context.mounted) return; + + try { + await ref.read(aiApiProvider).deletePersona(item.id!); + if (!context.mounted) return; + + // 删掉的正好是当前选中的 → 退回默认 + if (ref.read(selectedAiPersonaProvider) == item.id) { + ref.read(selectedAiPersonaProvider.notifier).select(null); + } + ref.invalidate(aiPersonasProvider); + showToast(context, '已删除', icon: 'check'); + } on ApiException catch (e) { + if (context.mounted) showToast(context, e.message, error: true); + } catch (_) { + if (context.mounted) showToast(context, '删除失败', error: true); + } + } +} + +/// 新增 / 编辑人设的表单 +class _PersonaEditor extends ConsumerStatefulWidget { + const _PersonaEditor({this.existing}); + + /// null = 新增 + final AiPersonaItem? existing; + + @override + ConsumerState<_PersonaEditor> createState() => _PersonaEditorState(); +} + +class _PersonaEditorState extends ConsumerState<_PersonaEditor> { + late final TextEditingController _name; + late final TextEditingController _prompt; + + bool _saving = false; + + bool get _isEdit => widget.existing != null; + + @override + void initState() { + super.initState(); + final e = widget.existing; + _name = TextEditingController(text: e?.name ?? ''); + // 新增时预填内置默认文本 —— 面对的不是空白框,是一份能跑的。 + // 拿不到(列表还没加载出来)就先空着,用户手动点「恢复默认」也能填 + _prompt = TextEditingController( + text: e?.systemPrompt ?? ref.read(defaultPersonaPromptProvider) ?? '', + ); + } + + @override + void dispose() { + _name.dispose(); + _prompt.dispose(); + super.dispose(); + } + + Future _save() async { + final prompt = _prompt.text.trim(); + if (prompt.isEmpty) { + showToast(context, '提示词不能为空', error: true); + return; + } + + setState(() => _saving = true); + try { + final api = ref.read(aiApiProvider); + if (_isEdit) { + await api.updatePersona( + id: widget.existing!.id!, + name: _name.text.trim(), + systemPrompt: prompt, + ); + } else { + await api.createPersona( + name: _name.text.trim(), + systemPrompt: prompt, + ); + } + if (!mounted) return; + Navigator.of(context).pop(true); + } on ApiException catch (e) { + if (!mounted) return; + showToast(context, e.message, error: true); + } catch (_) { + if (!mounted) return; + showToast(context, '保存失败', error: true); + } finally { + if (mounted) setState(() => _saving = false); + } + } + + void _restoreDefault() { + final def = ref.read(defaultPersonaPromptProvider); + if (def == null || def.isEmpty) { + showToast(context, '默认人设还没加载出来,稍后再试', error: true); + return; + } + setState(() => _prompt.text = def); + showToast(context, '已填入默认人设,记得保存'); + } + + @override + Widget build(BuildContext context) { + // 是否提示「没提到工具」——只提示,不阻止保存 + final lacksToolHint = _prompt.text.trim().isNotEmpty && + !_prompt.text.contains('工具'); + + return Padding( + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), + child: SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Text( + _isEdit ? '编辑人设' : '新增人设', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF5D4037), + ), + ), + ), + const SizedBox(height: 18), + + _field( + label: '名称', + controller: _name, + hint: '留空则取提示词开头几个字,比如:花花', + maxLines: 1, + ), + _field( + label: '提示词', + controller: _prompt, + hint: '比如:你是一个说话简洁的记账助手…', + helper: '人设 + 运行规则都写这里。日期由服务端自动补,不用写。', + maxLines: null, + minLines: 12, + ), + + if (lacksToolHint) ...[ + const SizedBox(height: 2), + _warnBox(), + ], + + const SizedBox(height: 6), + // 「恢复默认」放左边,和右边的取消/保存隔开 —— 避免误点 + Row( + children: [ + TextButton.icon( + onPressed: _saving ? null : _restoreDefault, + icon: const Icon(Icons.settings_backup_restore, size: 16), + label: const Text('恢复默认', style: TextStyle(fontSize: 12.5)), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF8D6E63), + ), + ), + ], + ), + + const SizedBox(height: 10), + Row( + children: [ + Expanded( + child: SizedBox( + height: 46, + child: OutlinedButton( + onPressed: + _saving ? null : () => Navigator.of(context).pop(), + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFC8956E), + side: const BorderSide(color: Color(0xFFC8956E)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(23), + ), + ), + child: const Text('取消'), + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: SizedBox( + height: 46, + child: FilledButton( + onPressed: _saving ? null : _save, + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFC8956E), + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(23), + ), + ), + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ) + : const Text('保存'), + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + /// 软提示:提示词里没提到「工具」就提醒一句。 + /// + /// 只提示、不阻止保存 —— 用户明确要求规则可编辑,不该替他做决定。 + /// 但这条提醒很重要:删掉「必须用工具查询」之后,助手会开始凭印象编造金额, + /// 而且不会有任何报错。 + Widget _warnBox() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + color: const Color(0xFFFFF8E1), + borderRadius: BorderRadius.circular(10), + border: Border.all(color: const Color(0xFFFFE082)), + ), + child: const Text( + '提示:没提到「工具」。助手可能会凭印象编造金额和数字 ——\n' + '建议保留「必须用工具查询,不要编造数字」这类要求。', + style: TextStyle(fontSize: 11.5, height: 1.65, color: Color(0xFF8A6D1F)), + ), + ); + } + + Widget _field({ + required String label, + required TextEditingController controller, + required String hint, + String? helper, + int? maxLines = 1, + int? minLines, + }) { + // 单行 vs 多行:maxLines 为 null 或 >1 都算多行 + final multiline = maxLines == null || maxLines != 1; + return Padding( + padding: const EdgeInsets.only(bottom: 14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63))), + const SizedBox(height: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: const Color(0xFFF7F4F1), + borderRadius: BorderRadius.circular(10), + ), + child: TextField( + controller: controller, + maxLines: maxLines, + minLines: minLines, + // 多行时让文字从顶部开始,否则会垂直居中显得很怪 + textAlignVertical: + multiline ? TextAlignVertical.top : TextAlignVertical.center, + keyboardType: multiline ? TextInputType.multiline : TextInputType.text, + autocorrect: false, + enableSuggestions: false, + // 边打字边刷新 —— 上面那个软提示要根据内容变 + onChanged: multiline ? (_) => setState(() {}) : null, + style: TextStyle( + fontSize: multiline ? 13 : 14, + height: multiline ? 1.75 : null, + color: const Color(0xFF5D4037), + ), + decoration: InputDecoration( + border: InputBorder.none, + isDense: true, + contentPadding: EdgeInsets.symmetric( + vertical: multiline ? 12 : 13, + ), + hintText: hint, + hintStyle: const TextStyle(fontSize: 13.5, color: Color(0xFFD7CCC8)), + ), + ), + ), + if (helper != null) + Padding( + padding: const EdgeInsets.only(top: 4, left: 2), + child: Text( + helper, + style: const TextStyle(fontSize: 11, color: Color(0xFFBCAAA4)), + ), + ), + ], + ), + ); + } +} diff --git a/lib/providers/ai_persona_provider.dart b/lib/providers/ai_persona_provider.dart new file mode 100644 index 0000000..e23b882 --- /dev/null +++ b/lib/providers/ai_persona_provider.dart @@ -0,0 +1,77 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/ai_api.dart'; +import '../core/storage/local_storage.dart'; +import '../models/ai_persona.dart'; + +/// 可选的人设列表(含虚拟的「默认」项) +/// +/// 与 [aiConfigsProvider] 完全平行:一个拉模型配置,一个拉人设。 +final aiPersonasProvider = FutureProvider.autoDispose>((ref) { + return ref.watch(aiApiProvider).personas(); +}); + +/// 当前选中的人设。 +/// +/// **不设 autoDispose**:理由和 [SelectedAiConfigNotifier] 一样 —— +/// 选完回对话页要立刻生效,切页面不该丢。 +class SelectedAiPersonaNotifier extends Notifier { + @override + int? build() { + // 存的是「真实 id」,null = 用内置默认。 + // 默认项在列表里的 id 也是 null,两边语义天然一致。 + return LocalStorage.instance.getAiPersonaId(); + } + + void select(int? id) { + state = id; + LocalStorage.instance.saveAiPersonaId(id); + } +} + +final selectedAiPersonaProvider = + NotifierProvider(SelectedAiPersonaNotifier.new); + +/// 从人设列表里挑出当前选中的那一条。 +/// +/// 三种情况都要兜住: +/// · 还没选过 → 用列表第一项(也就是「默认」) +/// · 选过但那条被删了 → 退回第一项,并顺手把本地选择改掉 +/// · 列表还没加载出来 → 返回 null,界面显示「--」 +/// +/// 注意这里和服务端的降级是**两套独立的安全网**:服务端拿到过期 id 也会 +/// 退回内置默认,所以就算这里没来得及纠正,对话也不会挂。 +final activeAiPersonaProvider = Provider.autoDispose((ref) { + final personas = ref.watch(aiPersonasProvider).value; + if (personas == null || personas.isEmpty) return null; + + final selectedId = ref.watch(selectedAiPersonaProvider); + if (selectedId == null) return personas.first; + + for (final item in personas) { + if (item.selectionKey == selectedId) return item; + } + + // 选中的那条已经不存在了(被删了)。退回默认。 + // 放在微任务里改 state —— build 过程中不能同步改另一个 provider。 + Future.microtask(() { + ref.read(selectedAiPersonaProvider.notifier).select(null); + }); + return personas.first; +}); + +/// 内置默认人设文本,供编辑页「恢复默认」按钮使用。 +/// +/// 从列表的默认项里取(服务端下发),拿不到时返回 null —— +/// 这时按钮会提示「还没加载出来」,而不是塞一份 App 里硬编码的文本进去 +/// (那样两边迟早不一致)。 +final defaultPersonaPromptProvider = Provider.autoDispose((ref) { + final personas = ref.watch(aiPersonasProvider).value; + if (personas == null) return null; + for (final p in personas) { + if (p.isDefault && (p.defaultPrompt?.isNotEmpty ?? false)) { + return p.defaultPrompt; + } + } + return null; +}); diff --git a/lib/providers/ai_provider.dart b/lib/providers/ai_provider.dart index 014e9c1..0cf1527 100644 --- a/lib/providers/ai_provider.dart +++ b/lib/providers/ai_provider.dart @@ -4,6 +4,7 @@ import '../api/ai_api.dart'; import '../core/network/api_exception.dart'; import '../models/ai.dart'; import 'ai_config_provider.dart'; +import 'ai_persona_provider.dart'; import 'bill_refresh.dart'; import 'note_provider.dart'; @@ -160,9 +161,14 @@ class AssistantNotifier extends Notifier { try { // 带上用户选中的配置。null = 用服务端 .env 那套(虚拟的「默认」项) final configId = ref.read(selectedAiConfigProvider); - await for (final event in ref - .read(aiApiProvider) - .chat(sessionId: state.sessionId, configId: configId, content: trimmed)) { + // 人设是独立的另一套。null = 用服务端内置的默认人设 + final personaId = ref.read(selectedAiPersonaProvider); + await for (final event in ref.read(aiApiProvider).chat( + sessionId: state.sessionId, + configId: configId, + personaId: personaId, + content: trimmed, + )) { if (!ref.mounted) return; switch (event.type) { diff --git a/test/ai_persona_layout_test.dart b/test/ai_persona_layout_test.dart new file mode 100644 index 0000000..729eeff --- /dev/null +++ b/test/ai_persona_layout_test.dart @@ -0,0 +1,229 @@ +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:qiangbao_accounting/api/ai_api.dart'; +import 'package:qiangbao_accounting/models/ai_config.dart'; +import 'package:qiangbao_accounting/models/ai_persona.dart'; +import 'package:qiangbao_accounting/pages/assistant/ai_config_page.dart'; + +/// 「AI 设置」页(模型 / 人设 双 Tab)的布局测试。 +/// +/// 重点是小屏不溢出 —— 这类测试在健康模块抓过真 bug(320 宽溢出 9.8px)。 +/// 这里风险更高的地方是**人设编辑弹层**:多行 TextField + 软提示 + +/// 「恢复默认」按钮,元素比模型那个弹层多。 +/// +/// 另外守两件事: +/// · 默认人设项的提示词预览能正常显示(换行被压平,不会撑爆卡片) +/// · 长提示词在列表里被两行截断,不会把列表撑散 + +/// 假 API:不发网络请求,返回固定的模型与人设列表 +class _FakeAiApi extends AiApi { + _FakeAiApi() : super(Dio()); + + @override + Future> configs() async => const [ + AiConfigItem( + id: null, + name: '默认', + baseUrl: 'https://api.deepseek.com/v1', + apiKeyMasked: 'sk-****cdef', + model: 'deepseek-chat', + isDefault: true, + editable: false, + fromEnv: true, + ready: true, + ), + ]; + + @override + Future> personas() async => [ + const AiPersonaItem( + id: null, + name: '默认人设', + systemPrompt: '大名:王洋 小名:花花 关系:你的铁杆好朋友,俩人互相喊对方宝', + promptLength: 30, + isDefault: true, + editable: false, + defaultPrompt: '大名:王洋 小名:花花', + ), + const AiPersonaItem( + id: 7, + name: '简洁模式', + // 故意很长 + 带换行,验证预览会被压平和截断 + systemPrompt: '你是极简助手,只回一句话。\n必须用工具查询数据,不要编造数字。' + '这是一段很长的文本用来测试列表项会不会被撑爆,' + '再补一些字凑长度让预览一定要截断。', + promptLength: 60, + isDefault: false, + editable: true, + defaultPrompt: null, + ), + ]; + + @override + Future createPersona({ + required String name, + required String systemPrompt, + }) async => + AiPersonaItem( + id: 99, + name: name.isEmpty ? '新的' : name, + systemPrompt: systemPrompt, + promptLength: systemPrompt.length, + isDefault: false, + editable: true, + defaultPrompt: null, + ); +} + +void main() { + const sizes = [ + ('iPhone SE 窄屏', Size(320, 568)), + ('常见安卓机', Size(393, 873)), + ('大屏', Size(480, 1000)), + ]; + + void useSize(WidgetTester tester, Size size) { + tester.view.physicalSize = size * 3; + tester.view.devicePixelRatio = 3.0; + addTearDown(tester.view.reset); + } + + Widget wrap() => ProviderScope( + overrides: [aiApiProvider.overrideWithValue(_FakeAiApi())], + child: const MaterialApp(home: AiConfigPage()), + ); + + /// 进人设 Tab + /// + /// 必须 pumpAndSettle:TabBarView 的切换是**动画**,只 pump 几帧的话 + /// 人设内容还没被构建出来(实测要 settle 完才出现)。 + /// 带个超时上限,避免哪天真卡死时整个测试挂住。 + Future goToPersonaTab(WidgetTester tester) async { + await tester.tap(find.text('人设')); + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + } + + /// 等列表数据加载完(FutureProvider 走完一轮) + Future settleData(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + for (final (label, size) in sizes) { + testWidgets('AI 设置页(模型 Tab)$label 不溢出', (tester) async { + useSize(tester, size); + await tester.pumpWidget(wrap()); + await settleData(tester); + + expect(find.text('模型'), findsOneWidget); + expect(find.text('人设'), findsOneWidget); + expect(find.text('默认'), findsWidgets); + expect(tester.takeException(), isNull); + }); + + testWidgets('AI 设置页(人设 Tab)$label 不溢出', (tester) async { + useSize(tester, size); + await tester.pumpWidget(wrap()); + await settleData(tester); + + await goToPersonaTab(tester); + + expect(find.text('默认人设'), findsOneWidget); + expect(find.text('简洁模式'), findsOneWidget); + expect(tester.takeException(), isNull, + reason: '$label 下人设 Tab 溢出了'); + }); + } + + testWidgets('★ 长提示词在列表里被两行截断(否则卡片会被撑得很高)', (tester) async { + useSize(tester, const Size(393, 873)); + await tester.pumpWidget(wrap()); + await settleData(tester); + await goToPersonaTab(tester); + + // 直接断言那个预览 Text 的属性。 + // + // 为什么不靠「溢出」断言:这是纵向列表,卡片变高只会让列表更长、 + // 不会报 RenderFlex overflow —— 我实测过,把 maxLines 去掉后 + // 溢出断言照样通过。所以必须查组件本身。 + final previewTexts = tester + .widgetList(find.byType(Text)) + .where((t) => t.data != null && t.data!.contains('你是极简助手')) + .toList(); + + expect(previewTexts, isNotEmpty, reason: '没找到人设预览文本'); + for (final t in previewTexts) { + expect(t.maxLines, 2, reason: '预览必须限 2 行,否则长提示词会把卡片撑爆'); + expect(t.overflow, TextOverflow.ellipsis, reason: '超出部分要省略号截断'); + } + + // 预览里不该有原始换行(换行来自未压平的 systemPrompt) + final preview = previewTexts.first.data!; + expect(preview.contains('\n'), isFalse, reason: '预览应该已把换行压成空格'); + }); + + testWidgets('人设编辑弹层在窄屏打开不溢出(多行输入 + 软提示 + 恢复默认)', (tester) async { + useSize(tester, const Size(320, 568)); + await tester.pumpWidget(wrap()); + await settleData(tester); + await goToPersonaTab(tester); + + // 点右下角 FAB 新增 —— 新增时会预填内置默认文本 + await tester.tap(find.byIcon(Icons.add)); + // ⚠️ 不能用 pumpAndSettle:弹层里的 TextField 光标闪烁是**无限动画**, + // settle 会一直等下去(这个坑在 watermark_layout_test 里踩过) + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(find.text('新增人设'), findsOneWidget); + expect(find.text('提示词'), findsOneWidget); + expect(find.text('恢复默认'), findsOneWidget); + expect(tester.takeException(), isNull, reason: '窄屏下人设编辑弹层溢出了'); + }); + + testWidgets('★ 编辑人设时提示词预填内置默认(不是空白框)', (tester) async { + useSize(tester, const Size(393, 873)); + await tester.pumpWidget(wrap()); + await settleData(tester); + await goToPersonaTab(tester); + + await tester.tap(find.byIcon(Icons.add)); + // ⚠️ 不能用 pumpAndSettle:弹层里的 TextField 光标闪烁是**无限动画**, + // settle 会一直等下去(这个坑在 watermark_layout_test 里踩过) + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + // 找到提示词那个多行 TextField,它应该有内容 + final fields = tester.widgetList(find.byType(TextField)).toList(); + // 第一个是「名称」,第二个是「提示词」 + expect(fields.length, greaterThanOrEqualTo(2)); + expect(fields[1].controller?.text, isNotEmpty, + reason: '新增人设时提示词应该预填内置默认,而不是空框'); + }); + + testWidgets('人设列表项点一下能切换选中状态', (tester) async { + useSize(tester, const Size(393, 873)); + await tester.pumpWidget(wrap()); + await settleData(tester); + await goToPersonaTab(tester); + + // 点「简洁模式」,应该弹 toast(不崩) + await tester.tap(find.text('简洁模式')); + await settleData(tester); + + expect(tester.takeException(), isNull); + }); + + testWidgets('默认人设项没有编辑/删除入口(只有一排菜单图标 = 用户项数)', (tester) async { + useSize(tester, const Size(393, 873)); + await tester.pumpWidget(wrap()); + await settleData(tester); + await goToPersonaTab(tester); + + // 两条人设里只有一条(简洁模式)可编辑 → 只该有一个 more_vert 菜单 + expect(find.byIcon(Icons.more_vert), findsOneWidget); + }); +} diff --git a/test/ai_persona_test.dart b/test/ai_persona_test.dart new file mode 100644 index 0000000..a5f61b6 --- /dev/null +++ b/test/ai_persona_test.dart @@ -0,0 +1,138 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:qiangbao_accounting/models/ai_persona.dart'; + +/// AiPersonaItem 的解析与预览测试。 +/// +/// 重点是**不崩**:服务端字段缺失(老版本后端、网络截断)时 fromJson +/// 不能抛异常,否则整个配置页会白屏。 +void main() { + group('AiPersonaItem 解析', () { + test('完整字段正常解析', () { + final item = AiPersonaItem.fromJson({ + 'id': 5, + 'name': '花花', + 'systemPrompt': '你是助手。', + 'promptLength': 6, + 'isDefault': false, + 'editable': true, + 'defaultPrompt': null, + }); + + expect(item.id, 5); + expect(item.name, '花花'); + expect(item.systemPrompt, '你是助手。'); + expect(item.promptLength, 6); + expect(item.isDefault, isFalse); + expect(item.editable, isTrue); + expect(item.defaultPrompt, isNull); + }); + + test('默认项:id 为 null、不可编辑、带 defaultPrompt', () { + final item = AiPersonaItem.fromJson({ + 'id': null, + 'name': '默认人设', + 'systemPrompt': '内置文本', + 'isDefault': true, + 'editable': false, + 'defaultPrompt': '内置文本', + }); + + expect(item.id, isNull); + expect(item.isDefault, isTrue); + expect(item.editable, isFalse); + expect(item.defaultPrompt, '内置文本'); + }); + + test('★ 字段全缺也不抛异常(老后端 / 网络截断)', () { + final item = AiPersonaItem.fromJson(const {}); + + expect(item.id, isNull); + expect(item.name, '未命名'); + expect(item.systemPrompt, ''); + expect(item.promptLength, 0); + expect(item.isDefault, isFalse); + // editable 默认 true —— 缺字段时倾向「让用户能改」,而不是锁死 + expect(item.editable, isTrue); + expect(item.defaultPrompt, isNull); + }); + + test('selectionKey:默认项是 -1,具体项是它的 id', () { + final def = AiPersonaItem.fromJson({'id': null, 'isDefault': true}); + final mine = AiPersonaItem.fromJson({'id': 42}); + + expect(def.selectionKey, -1); + expect(mine.selectionKey, 42); + }); + + test('defaultPrompt 为空串时归一成 null(不能拿空串去判断「有没有」)', () { + final item = AiPersonaItem.fromJson({'id': 1, 'defaultPrompt': ''}); + expect(item.defaultPrompt, isNull); + }); + + test('promptLength 缺失时按 0,不会让界面显示 null', () { + final item = AiPersonaItem.fromJson({'id': 1}); + expect(item.promptLength, 0); + }); + }); + + group('preview(列表里的两行预览)', () { + test('短文本原样返回', () { + final item = AiPersonaItem.fromJson({ + 'id': 1, + 'systemPrompt': '你是助手。', + }); + expect(item.preview, '你是助手。'); + }); + + test('换行被压成空格 —— 否则列表项会被撑成好几行', () { + final item = AiPersonaItem.fromJson({ + 'id': 1, + 'systemPrompt': '第一行\n第二行\n\n第三行', + }); + expect(item.preview, '第一行 第二行 第三行'); + expect(item.preview.contains('\n'), isFalse); + }); + + test('连续空白(含缩进)被压成一个空格', () { + final item = AiPersonaItem.fromJson({ + 'id': 1, + 'systemPrompt': '甲 乙\t丙', + }); + expect(item.preview, '甲 乙 丙'); + }); + + test('超长文本截断并加省略号', () { + final long = '啊' * 100; + final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': long}); + + expect(item.preview.length, 51); // 50 + 省略号 + expect(item.preview.endsWith('…'), isTrue); + }); + + test('刚好 50 字不截断', () { + final exact = '啊' * 50; + final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': exact}); + expect(item.preview, exact); + expect(item.preview.endsWith('…'), isFalse); + }); + + test('空提示词不会崩', () { + final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': ''}); + expect(item.preview, ''); + }); + + test('只有空白字符时预览是空的', () { + final item = AiPersonaItem.fromJson({'id': 1, 'systemPrompt': ' \n\t '}); + expect(item.preview, ''); + }); + + test('首尾空白被去掉', () { + final item = AiPersonaItem.fromJson({ + 'id': 1, + 'systemPrompt': ' 你是助手。 ', + }); + expect(item.preview, '你是助手。'); + }); + }); +} diff --git a/test/ai_refresh_test.dart b/test/ai_refresh_test.dart index 0b7476e..3c6996c 100644 --- a/test/ai_refresh_test.dart +++ b/test/ai_refresh_test.dart @@ -15,7 +15,12 @@ class FakeAiApi extends AiApi { final List _events; @override - Stream chat({int? sessionId, int? configId, required String content}) async* { + Stream chat({ + int? sessionId, + int? configId, + int? personaId, + required String content, + }) async* { for (final e in _events) { yield e; }