diff --git a/lib/pages/assistant/ai_persona_page.dart b/lib/pages/assistant/ai_persona_page.dart index f806435..24dd871 100644 --- a/lib/pages/assistant/ai_persona_page.dart +++ b/lib/pages/assistant/ai_persona_page.dart @@ -27,7 +27,6 @@ class AiPersonaTab extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final personas = ref.watch(aiPersonasProvider); - final selectedId = ref.watch(selectedAiPersonaProvider); return Scaffold( backgroundColor: const Color(0xFFFAF8F5), @@ -52,7 +51,7 @@ class AiPersonaTab extends ConsumerWidget { _introCard(), const SizedBox(height: 14), for (final item in list) ...[ - _personaTile(context, ref, item, selectedId, custom), + _personaTile(context, ref, item, custom), const SizedBox(height: 10), ], if (custom == 0) _emptyHint(), @@ -74,7 +73,7 @@ class AiPersonaTab extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '换个说话的人', + '管理人设', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -83,8 +82,8 @@ class AiPersonaTab extends ConsumerWidget { ), SizedBox(height: 5), Text( - '人设决定助手的说话风格和做事规则,和用哪个模型是两件独立的事 ——\n' - '换模型不会换人设。\n' + '这里只负责「有哪些人设」。用哪个是在新建对话时选的 ——\n' + '一段对话选定之后不会中途换人,想换就开个新对话。\n' '提示词里不用写日期,服务端每次会自动补上今天几号、「这个月」指哪个月。', style: TextStyle(fontSize: 12, height: 1.6, color: Color(0xFF8D6E63)), ), @@ -105,7 +104,8 @@ class AiPersonaTab extends ConsumerWidget { ), child: const Text( '还没有自己的人设,用的就是「默认」那套。\n' - '想换个风格就点右下角「+」—— 会预填一份能用的,改一改就行。', + '想加一个就点右下角「+」—— 会预填一份能用的,改一改就行。\n' + '加完之后,新建对话时就能选它。', textAlign: TextAlign.center, style: TextStyle(fontSize: 12.5, height: 1.7, color: Color(0xFFBCAAA4)), ), @@ -113,119 +113,97 @@ class AiPersonaTab extends ConsumerWidget { ); } + /// 一条人设卡片。 + /// + /// **点整行不再是「选用」** —— 用哪个人设是新建对话时决定的, + /// 这里只负责管理(改名、改提示词、换头像、删除)。 + /// 之前这里有个单选圈 + 点击切换,会让人误以为「在这儿选了就生效」, + /// 而实际说话的人跟着会话走,两者不是一回事。 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, - ), + border: Border.all(color: const Color(0xFFF0E6D8)), ), - 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: 8), - // 人设头像。没设过就是默认小人图标 - Avatar( - assetPath: item.avatar, - size: 38, - fallbackIcon: 'assistant', - ), - 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), - ), + child: Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 8, 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 人设头像。没设过就是默认小人图标 + Avatar( + assetPath: item.avatar, + size: 38, + fallbackIcon: 'assistant', + ), + 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.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('删除')), - ], - ), - ], - ), + ), + // 默认项不给编辑/删除的入口 —— 它的内容是服务端代码里内置的 + 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('删除')), + ], + ), + ], ), ), ); @@ -297,9 +275,10 @@ class AiPersonaTab extends ConsumerWidget { await ref.read(aiApiProvider).deletePersona(item.id!); if (!context.mounted) return; - // 删掉的正好是当前选中的 → 退回默认 - if (ref.read(selectedAiPersonaProvider) == item.id) { - ref.read(selectedAiPersonaProvider.notifier).select(null); + // 删掉的正好是「新对话预选的那条」→ 忘掉它,别让下次新对话 + // 预选一个已经不存在的 id(服务端会兜底退回默认,但界面上会显得莫名其妙) + if (ref.read(preferredPersonaProvider) == item.id) { + ref.read(preferredPersonaProvider.notifier).forget(); } ref.invalidate(aiPersonasProvider); showToast(context, '已删除', icon: 'check'); diff --git a/lib/pages/assistant/assistant_page.dart b/lib/pages/assistant/assistant_page.dart index db0eaf6..67bfaaa 100644 --- a/lib/pages/assistant/assistant_page.dart +++ b/lib/pages/assistant/assistant_page.dart @@ -287,9 +287,16 @@ class _AssistantPageState extends ConsumerState { ); if (!mounted) return; - // 关掉弹层 = 用默认人设。不返回任何东西也照样开新对话, - // 免得用户以为「没点就是取消了」 - ref.read(assistantProvider.notifier).newChat(personaId: picked?.personaId); + // 关掉弹层 = 取消,不开新对话。 + // 不自动用默认人设开始:误触一下就把用户正在看的对话顶掉太亏了。 + // 想用默认人设就点列表里第一项「默认人设」,一样是一下 + if (picked == null) return; + + // 记住这次选的,下次新对话默认预选它。 + // 这是**唯一**写这个偏好的地方 —— 设置页不提供「选说话的人」这个动作 + ref.read(preferredPersonaProvider.notifier).remember(picked.personaId); + + ref.read(assistantProvider.notifier).newChat(personaId: picked.personaId); } // ---------------------------------------------------------------- 气泡 @@ -301,7 +308,6 @@ class _AssistantPageState extends ConsumerState { // 头像只给 36px —— 太大反而抢戏,微信也就这个量级。 Widget _bubble(BuildContext context, ChatBubble bubble) { - final persona = ref.watch(activeAiPersonaProvider); final user = ref.watch(authProvider).user; if (bubble.isUser) { @@ -340,7 +346,10 @@ class _AssistantPageState extends ConsumerState { return _row( avatar: Avatar( - assetPath: persona?.avatar, + // 会话锁定了哪个人设,气泡上就显示谁 —— 和这段回答实际用的 prompt 同一个人。 + // 不能用「新对话预选的那条」:用户之后新对话选了别人, + // 这条旧会话的历史消息不该跟着换头像 + assetPath: _sessionPersonaAvatar(), size: 36, fallbackIcon: 'assistant', ), @@ -397,6 +406,15 @@ class _AssistantPageState extends ConsumerState { ); } + /// 当前会话绑定的人设头像。null = 没设过(或人设被删了),显示默认图标。 + /// + /// 关键在「按会话存的那个 id 去找」而不是读全局偏好 —— 见 + /// [personaByIdProvider] 的说明。 + String? _sessionPersonaAvatar() { + final personaId = ref.watch(assistantProvider.select((s) => s.personaId)); + return ref.watch(personaByIdProvider(personaId))?.avatar; + } + /// 一行 = 头像 + 气泡。用户侧左右镜像。 /// /// 头像用 [CrossAxisAlignment.start](贴顶)而不是居中 —— 回答很长时 @@ -643,8 +661,9 @@ class _PersonaPickerSheet extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final personasAsync = ref.watch(aiPersonasProvider); - // 默认选中「用户当前偏好的那套」,省得每次都点 - final preferred = ref.watch(selectedAiPersonaProvider); + // 预选上次用的那套,省得每次重选。注意这只是「预选」—— + // 真正生效的人设是新建会话时定下的那个 + final preferred = ref.watch(preferredPersonaProvider); return SafeArea( child: Padding( diff --git a/lib/providers/ai_persona_provider.dart b/lib/providers/ai_persona_provider.dart index e23b882..b5876c1 100644 --- a/lib/providers/ai_persona_provider.dart +++ b/lib/providers/ai_persona_provider.dart @@ -11,53 +11,56 @@ final aiPersonasProvider = FutureProvider.autoDispose>((ref) return ref.watch(aiApiProvider).personas(); }); -/// 当前选中的人设。 +/// **新对话时预选哪个人设**的偏好。null = 内置默认人设。 /// -/// **不设 autoDispose**:理由和 [SelectedAiConfigNotifier] 一样 —— -/// 选完回对话页要立刻生效,切页面不该丢。 -class SelectedAiPersonaNotifier extends Notifier { +/// ⚠️ 注意它**不是**「当前正在用的人设」—— 那是 `AssistantState.personaId`, +/// 在新建会话时定下来、之后不再变(见 `ai_provider.dart` 里的说明)。 +/// +/// 两者的分工: +/// · 这个 = 「上次选了谁」,只用来在新对话弹层里打个勾,省得每次重选 +/// · 会话的 personaId = 「这次对话实际在用谁」,气泡、prompt 都看它 +/// +/// 会不一样:用户拿人设 A 聊过一轮,之后新对话选了 B —— 那条旧会话的 +/// 历史消息仍然该显示 A 的头像和 A 的记录。 +/// +/// 名字里刻意没有「selected」这个词:AI 设置页**不提供**「选说话的人」这个动作, +/// 选人只发生在新建会话那一步。用「selected」会让人以为设置页能改。 +class PreferredPersonaNotifier extends Notifier { @override int? build() { - // 存的是「真实 id」,null = 用内置默认。 + // 存的是「真实 id」,null = 内置默认。 // 默认项在列表里的 id 也是 null,两边语义天然一致。 return LocalStorage.instance.getAiPersonaId(); } - void select(int? id) { + void remember(int? id) { state = id; LocalStorage.instance.saveAiPersonaId(id); } + + /// 被记住的那条人设删了 —— 忘掉它,别留着指向不存在的 id + void forget() => remember(null); } -final selectedAiPersonaProvider = - NotifierProvider(SelectedAiPersonaNotifier.new); +final preferredPersonaProvider = + NotifierProvider(PreferredPersonaNotifier.new); -/// 从人设列表里挑出当前选中的那一条。 +/// 按 id 找人设。聊天气泡用它取「当前会话绑的那个人」的头像。 /// -/// 三种情况都要兜住: -/// · 还没选过 → 用列表第一项(也就是「默认」) -/// · 选过但那条被删了 → 退回第一项,并顺手把本地选择改掉 -/// · 列表还没加载出来 → 返回 null,界面显示「--」 +/// 直接读会话的 personaId 去找,而不是用全局偏好 —— 会话锁定了谁, +/// 气泡上就该显示谁,和那段回答实际用的 prompt 是同一套。 /// -/// 注意这里和服务端的降级是**两套独立的安全网**:服务端拿到过期 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; +/// id 为 null(内置默认人设)或找不到(人设被删)时返回 null, +/// 调用方据此显示默认图标。 +final personaByIdProvider = + Provider.autoDispose.family((ref, id) { + if (id == null) return null; + final list = ref.watch(aiPersonasProvider).value; + if (list == null) return null; + for (final p in list) { + if (p.id == id) return p; } - - // 选中的那条已经不存在了(被删了)。退回默认。 - // 放在微任务里改 state —— build 过程中不能同步改另一个 provider。 - Future.microtask(() { - ref.read(selectedAiPersonaProvider.notifier).select(null); - }); - return personas.first; + return null; }); /// 内置默认人设文本,供编辑页「恢复默认」按钮使用。 diff --git a/test/ai_persona_manage_test.dart b/test/ai_persona_manage_test.dart new file mode 100644 index 0000000..169cd17 --- /dev/null +++ b/test/ai_persona_manage_test.dart @@ -0,0 +1,141 @@ +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:shared_preferences/shared_preferences.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'; +import 'package:qiangbao_accounting/providers/ai_persona_provider.dart'; + +/// 「AI 设置页只管理人设、不负责选人」的回归测试。 +/// +/// 背景:一开始设置页做成「点一条就切换当前人设」,但这和数据模型是冲突的 —— +/// 人设是**跟着会话走**的(新建对话时定,之后不改),设置页里再放一个「选人」 +/// 就会让用户以为「在这儿选了就生效」,实际聊起来还是会话里那个。 +/// +/// 所以这里守两件事: +/// 1. 设置页上没有任何表示「当前选中」的控件(单选圈),也不能通过点击切换 +/// 2. 会话的人设不受「预选偏好」影响(这个是核心不变量,另有专门测试) +class _FakeAiApi extends AiApi { + _FakeAiApi() : super(Dio()); + + @override + Future> configs() async => const []; + + @override + Future> personas() async => const [ + AiPersonaItem( + id: null, + name: '默认人设', + systemPrompt: '内置的', + promptLength: 3, + isDefault: true, + editable: false, + defaultPrompt: '内置的', + ), + AiPersonaItem( + id: 7, + name: '花花', + systemPrompt: '你是花花', + promptLength: 4, + isDefault: false, + editable: true, + defaultPrompt: null, + ), + AiPersonaItem( + id: 8, + name: '简洁模式', + systemPrompt: '只回一句', + promptLength: 4, + isDefault: false, + editable: true, + defaultPrompt: null, + ), + ]; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + SharedPreferences.setMockInitialValues({}); + + late _FakeAiApi api; + + setUp(() => api = _FakeAiApi()); + + Widget wrap() => ProviderScope( + overrides: [aiApiProvider.overrideWithValue(api)], + child: const MaterialApp(home: AiConfigPage()), + ); + + Future settle(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future goToPersonaTab(WidgetTester tester) async { + await tester.tap(find.text('人设')); + await tester.pumpAndSettle(const Duration(milliseconds: 100)); + } + + group('★ AI 设置页不负责选人', () { + testWidgets('人设列表里没有任何单选圈(不给「当前选中」的错觉)', (tester) async { + await tester.pumpWidget(wrap()); + await settle(tester); + await goToPersonaTab(tester); + + // 三条人设都该在 + expect(find.text('默认人设'), findsOneWidget); + expect(find.text('花花'), findsOneWidget); + expect(find.text('简洁模式'), findsOneWidget); + + // 选中的两种圈都不该出现 + expect(find.byIcon(Icons.radio_button_checked), findsNothing, + reason: '设置页不该有「已选中」的视觉标记'); + expect(find.byIcon(Icons.radio_button_unchecked), findsNothing, + reason: '设置页不该有可选的单选圈'); + }); + + testWidgets('★ 点人设卡片不会改变「预选偏好」', (tester) async { + await tester.pumpWidget(wrap()); + await settle(tester); + await goToPersonaTab(tester); + + final container = ProviderScope.containerOf( + tester.element(find.text('花花')), + ); + expect(container.read(preferredPersonaProvider), isNull); + + // 点「花花」整行 + await tester.tap(find.text('花花')); + await settle(tester); + + // 偏好不该被改 —— 设置页没有「选用」这个动作 + expect(container.read(preferredPersonaProvider), isNull, + reason: '点人设卡片不该把它变成「当前使用的人设」'); + }); + + testWidgets('标题和说明写明「这里只管管理,选人是在新建对话时」', (tester) async { + await tester.pumpWidget(wrap()); + await settle(tester); + await goToPersonaTab(tester); + + expect(find.text('管理人设'), findsOneWidget); + expect(find.textContaining('用哪个是在新建对话时选的', findRichText: true), + findsOneWidget, + reason: '要明确告诉用户「在哪儿选人」,否则会以为这里能选'); + }); + + testWidgets('仍然能编辑和删除(管理功能不受影响)', (tester) async { + await tester.pumpWidget(wrap()); + await settle(tester); + await goToPersonaTab(tester); + + // 两条用户人设各有一个「更多」菜单;默认人设没有 + expect(find.byIcon(Icons.more_vert), findsNWidgets(2), + reason: '可编辑的人设才能有菜单,默认项不给'); + }); + }); +} diff --git a/test/ai_session_persona_test.dart b/test/ai_session_persona_test.dart index 094be23..6a30557 100644 --- a/test/ai_session_persona_test.dart +++ b/test/ai_session_persona_test.dart @@ -1,4 +1,5 @@ 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:shared_preferences/shared_preferences.dart'; @@ -6,6 +7,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:qiangbao_accounting/api/ai_api.dart'; import 'package:qiangbao_accounting/models/ai.dart'; import 'package:qiangbao_accounting/models/ai_persona.dart'; +import 'package:qiangbao_accounting/pages/assistant/assistant_page.dart'; import 'package:qiangbao_accounting/providers/ai_provider.dart'; import 'package:qiangbao_accounting/providers/ai_persona_provider.dart'; @@ -39,7 +41,35 @@ class _CapturingAiApi extends AiApi { Future> sessions() async => const []; @override - Future> personas() async => const []; + Future> messages(int sessionId) async => const [ + ChatMessageItem(id: 1, role: 'user', content: '你好'), + ChatMessageItem(id: 2, role: 'assistant', content: '你好呀'), + ]; + + /// 两个人设,头像区分明显 —— 用于验证气泡上显示的是哪一个 + @override + Future> personas() async => const [ + AiPersonaItem( + id: 7, + name: '花花', + systemPrompt: '你是花花', + promptLength: 4, + isDefault: false, + editable: true, + defaultPrompt: null, + avatar: 'persona/huahua.jpg', + ), + AiPersonaItem( + id: 9, + name: '简洁', + systemPrompt: '只回一句', + promptLength: 4, + isDefault: false, + editable: true, + defaultPrompt: null, + avatar: 'persona/jianjie.jpg', + ), + ]; } void main() { @@ -57,7 +87,7 @@ void main() { } group('★ 会话内人设固定', () { - test('新对话选的人设会一直用下去(中途全局偏好变了也不受影响)', () async { + test('新对话选的人设会一直用下去(中途预选偏好变了也不受影响)', () async { final api = _CapturingAiApi(); final c = makeContainer(api); final notifier = c.read(assistantProvider.notifier); @@ -66,14 +96,17 @@ void main() { notifier.newChat(personaId: 7); expect(c.read(assistantProvider).personaId, 7); - // 中途用户在设置页把全局偏好换成了 9 - c.read(selectedAiPersonaProvider.notifier).select(9); + // 之后他又开了一次新对话、选了人设 9(这是偏好,不是「当前会话的人设」) + c.read(preferredPersonaProvider.notifier).remember(9); + + // 回到**第一个人设 7 的那条会话**继续说话。 + // 用 openSession 模拟「切回历史会话」——它会用该会话自己的 personaId + await notifier.openSession(1, personaId: 7); - // 继续在这个会话里说话 await notifier.send('第一句'); await notifier.send('第二句'); - // 两次都该用 7,不该跟着全局偏好变成 9 + // 两次都该用 7,不该跟着偏好变成 9 expect(api.personaIdsSent, [7, 7], reason: '会话锁定的人设不该被全局偏好覆盖'); }); @@ -152,4 +185,143 @@ void main() { expect(s.personaName, '默认人设'); }); }); + + group('★ 气泡头像跟着会话的人设走', () { + /// 从渲染出来的 Image 里收集所有网络图片地址。 + /// + /// 要拆一层 ResizeImage:Avatar 传了 cacheWidth,Flutter 会把 + /// NetworkImage 包成 ResizeImage,直接 whereType 会取不到。 + List imageUrls(WidgetTester tester) { + final urls = []; + for (final img in tester.widgetList(find.byType(Image))) { + final provider = img.image; + if (provider is NetworkImage) { + urls.add(provider.url); + } else if (provider is ResizeImage && + provider.imageProvider is NetworkImage) { + urls.add((provider.imageProvider as NetworkImage).url); + } + } + return urls; + } + + testWidgets('会话绑 7 → 气泡显示 7 的头像,不是预选偏好的 9', (tester) async { + final api = _CapturingAiApi(); + await tester.pumpWidget(ProviderScope( + overrides: [aiApiProvider.overrideWithValue(api)], + child: const MaterialApp(home: AssistantPage()), + )); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(AssistantPage)), + ); + + // 预选偏好是 9(比如用户上次新对话选了「简洁」) + container.read(preferredPersonaProvider.notifier).remember(9); + + // 但打开的是绑定人设 7 的那条会话 + await container.read(assistantProvider.notifier).openSession(1, personaId: 7); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + final urls = imageUrls(tester); + expect(urls.any((u) => u.contains('huahua')), isTrue, + reason: '气泡该显示会话绑定的人设(7=花花)的头像'); + expect(urls.any((u) => u.contains('jianjie')), isFalse, + reason: '不该显示预选偏好那个人(9=简洁)的头像 —— 那是另一个会话的人'); + + expect(tester.takeException(), isNull); + }); + + testWidgets('会话没绑人设(内置默认)时不加载任何头像图,退回默认图标', (tester) async { + final api = _CapturingAiApi(); + await tester.pumpWidget(ProviderScope( + overrides: [aiApiProvider.overrideWithValue(api)], + child: const MaterialApp(home: AssistantPage()), + )); + await tester.pump(); + + final container = ProviderScope.containerOf( + tester.element(find.byType(AssistantPage)), + ); + // 预选偏好 9,但会话用的是内置默认(personaId=null) + container.read(preferredPersonaProvider.notifier).remember(9); + await container.read(assistantProvider.notifier).openSession(1); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + final urls = imageUrls(tester); + expect(urls.any((u) => u.contains('jianjie')), isFalse, + reason: '会话用内置默认人设时,不该借预选偏好那个人来用'); + + expect(tester.takeException(), isNull); + }); + }); + + group('★ 新建对话:选了才开,关掉就是取消', () { + Future openAssistant(WidgetTester tester, _CapturingAiApi api) async { + await tester.pumpWidget(ProviderScope( + overrides: [aiApiProvider.overrideWithValue(api)], + child: const MaterialApp(home: AssistantPage()), + )); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + } + + testWidgets('点「新对话」会弹出选人设;选一个 → 该会话锁定它', (tester) async { + final api = _CapturingAiApi(); + await openAssistant(tester, api); + final container = ProviderScope.containerOf( + tester.element(find.byType(AssistantPage)), + ); + + await tester.tap(find.byTooltip('新对话')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + // 弹层里应该有两个人设 + expect(find.text('这次想找谁聊?'), findsOneWidget); + expect(find.text('花花'), findsOneWidget); + expect(find.text('简洁'), findsOneWidget); + + await tester.tap(find.text('简洁')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + expect(container.read(assistantProvider).personaId, 9, + reason: '选了「简洁」就该锁定 9'); + expect(tester.takeException(), isNull); + }); + + testWidgets('★ 关掉弹层 = 取消,不会偷偷开一个新对话', (tester) async { + final api = _CapturingAiApi(); + await openAssistant(tester, api); + final container = ProviderScope.containerOf( + tester.element(find.byType(AssistantPage)), + ); + + // 先打开一条历史会话,制造「当前有对话」的状态 + await container.read(assistantProvider.notifier).openSession(1, personaId: 7); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(container.read(assistantProvider).sessionId, 1); + + await tester.tap(find.byTooltip('新对话')); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + expect(find.text('这次想找谁聊?'), findsOneWidget); + + // 点弹层外面(左上角)关掉它 + await tester.tapAt(const Offset(5, 5)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 400)); + + // 当前会话不该被清掉 —— 误触一下不该丢掉正在看的对话 + expect(container.read(assistantProvider).sessionId, 1, + reason: '取消选人设时不该开新对话'); + expect(container.read(assistantProvider).personaId, 7); + expect(tester.takeException(), isNull); + }); + }); }