AI助手优化-人设与会话绑定
This commit is contained in:
@@ -27,7 +27,6 @@ class AiPersonaTab extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final personas = ref.watch(aiPersonasProvider);
|
final personas = ref.watch(aiPersonasProvider);
|
||||||
final selectedId = ref.watch(selectedAiPersonaProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xFFFAF8F5),
|
backgroundColor: const Color(0xFFFAF8F5),
|
||||||
@@ -52,7 +51,7 @@ class AiPersonaTab extends ConsumerWidget {
|
|||||||
_introCard(),
|
_introCard(),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
for (final item in list) ...[
|
for (final item in list) ...[
|
||||||
_personaTile(context, ref, item, selectedId, custom),
|
_personaTile(context, ref, item, custom),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
],
|
],
|
||||||
if (custom == 0) _emptyHint(),
|
if (custom == 0) _emptyHint(),
|
||||||
@@ -74,7 +73,7 @@ class AiPersonaTab extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'换个说话的人',
|
'管理人设',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@@ -83,8 +82,8 @@ class AiPersonaTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
SizedBox(height: 5),
|
SizedBox(height: 5),
|
||||||
Text(
|
Text(
|
||||||
'人设决定助手的说话风格和做事规则,和用哪个模型是两件独立的事 ——\n'
|
'这里只负责「有哪些人设」。用哪个是在新建对话时选的 ——\n'
|
||||||
'换模型不会换人设。\n'
|
'一段对话选定之后不会中途换人,想换就开个新对话。\n'
|
||||||
'提示词里不用写日期,服务端每次会自动补上今天几号、「这个月」指哪个月。',
|
'提示词里不用写日期,服务端每次会自动补上今天几号、「这个月」指哪个月。',
|
||||||
style: TextStyle(fontSize: 12, height: 1.6, color: Color(0xFF8D6E63)),
|
style: TextStyle(fontSize: 12, height: 1.6, color: Color(0xFF8D6E63)),
|
||||||
),
|
),
|
||||||
@@ -105,7 +104,8 @@ class AiPersonaTab extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: const Text(
|
child: const Text(
|
||||||
'还没有自己的人设,用的就是「默认」那套。\n'
|
'还没有自己的人设,用的就是「默认」那套。\n'
|
||||||
'想换个风格就点右下角「+」—— 会预填一份能用的,改一改就行。',
|
'想加一个就点右下角「+」—— 会预填一份能用的,改一改就行。\n'
|
||||||
|
'加完之后,新建对话时就能选它。',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(fontSize: 12.5, height: 1.7, color: Color(0xFFBCAAA4)),
|
style: TextStyle(fontSize: 12.5, height: 1.7, color: Color(0xFFBCAAA4)),
|
||||||
),
|
),
|
||||||
@@ -113,119 +113,97 @@ class AiPersonaTab extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 一条人设卡片。
|
||||||
|
///
|
||||||
|
/// **点整行不再是「选用」** —— 用哪个人设是新建对话时决定的,
|
||||||
|
/// 这里只负责管理(改名、改提示词、换头像、删除)。
|
||||||
|
/// 之前这里有个单选圈 + 点击切换,会让人误以为「在这儿选了就生效」,
|
||||||
|
/// 而实际说话的人跟着会话走,两者不是一回事。
|
||||||
Widget _personaTile(
|
Widget _personaTile(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
AiPersonaItem item,
|
AiPersonaItem item,
|
||||||
int? selectedId,
|
|
||||||
int customCount,
|
int customCount,
|
||||||
) {
|
) {
|
||||||
// 默认项的 selectionKey 是 -1,不能用 id 比较
|
|
||||||
final active = selectedId == null
|
|
||||||
? item.isDefault
|
|
||||||
: item.selectionKey == selectedId;
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(color: const Color(0xFFF0E6D8)),
|
||||||
color: active ? const Color(0xFFC8956E) : const Color(0xFFF0E6D8),
|
|
||||||
width: active ? 1.5 : 1,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: Padding(
|
||||||
borderRadius: BorderRadius.circular(14),
|
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
|
||||||
onTap: () {
|
child: Row(
|
||||||
ref.read(selectedAiPersonaProvider.notifier).select(item.id);
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
showToast(context, '已切换到「${item.name}」', icon: 'check');
|
children: [
|
||||||
},
|
// 人设头像。没设过就是默认小人图标
|
||||||
child: Padding(
|
Avatar(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
|
assetPath: item.avatar,
|
||||||
child: Row(
|
size: 38,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
fallbackIcon: 'assistant',
|
||||||
children: [
|
),
|
||||||
Padding(
|
const SizedBox(width: 10),
|
||||||
padding: const EdgeInsets.only(top: 1),
|
Expanded(
|
||||||
child: Icon(
|
child: Column(
|
||||||
active
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
? Icons.radio_button_checked
|
children: [
|
||||||
: Icons.radio_button_unchecked,
|
Row(
|
||||||
size: 19,
|
children: [
|
||||||
color:
|
Flexible(
|
||||||
active ? const Color(0xFFC8956E) : const Color(0xFFD7CCC8),
|
child: Text(
|
||||||
),
|
item.name,
|
||||||
),
|
maxLines: 1,
|
||||||
const SizedBox(width: 8),
|
overflow: TextOverflow.ellipsis,
|
||||||
// 人设头像。没设过就是默认小人图标
|
style: const TextStyle(
|
||||||
Avatar(
|
fontSize: 14.5,
|
||||||
assetPath: item.avatar,
|
fontWeight: FontWeight.w600,
|
||||||
size: 38,
|
color: Color(0xFF5D4037),
|
||||||
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<String>(
|
if (item.editable)
|
||||||
icon: const Icon(Icons.more_vert,
|
PopupMenuButton<String>(
|
||||||
size: 19, color: Color(0xFFBCAAA4)),
|
icon: const Icon(Icons.more_vert,
|
||||||
onSelected: (v) {
|
size: 19, color: Color(0xFFBCAAA4)),
|
||||||
if (v == 'edit') {
|
onSelected: (v) {
|
||||||
_openEditor(context, ref, item);
|
if (v == 'edit') {
|
||||||
} else {
|
_openEditor(context, ref, item);
|
||||||
_confirmDelete(context, ref, item, customCount);
|
} else {
|
||||||
}
|
_confirmDelete(context, ref, item, customCount);
|
||||||
},
|
}
|
||||||
itemBuilder: (_) => const [
|
},
|
||||||
PopupMenuItem(value: 'edit', child: Text('编辑')),
|
itemBuilder: (_) => const [
|
||||||
PopupMenuItem(value: 'delete', child: Text('删除')),
|
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!);
|
await ref.read(aiApiProvider).deletePersona(item.id!);
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
// 删掉的正好是当前选中的 → 退回默认
|
// 删掉的正好是「新对话预选的那条」→ 忘掉它,别让下次新对话
|
||||||
if (ref.read(selectedAiPersonaProvider) == item.id) {
|
// 预选一个已经不存在的 id(服务端会兜底退回默认,但界面上会显得莫名其妙)
|
||||||
ref.read(selectedAiPersonaProvider.notifier).select(null);
|
if (ref.read(preferredPersonaProvider) == item.id) {
|
||||||
|
ref.read(preferredPersonaProvider.notifier).forget();
|
||||||
}
|
}
|
||||||
ref.invalidate(aiPersonasProvider);
|
ref.invalidate(aiPersonasProvider);
|
||||||
showToast(context, '已删除', icon: 'check');
|
showToast(context, '已删除', icon: 'check');
|
||||||
|
|||||||
@@ -287,9 +287,16 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
);
|
);
|
||||||
if (!mounted) return;
|
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<AssistantPage> {
|
|||||||
// 头像只给 36px —— 太大反而抢戏,微信也就这个量级。
|
// 头像只给 36px —— 太大反而抢戏,微信也就这个量级。
|
||||||
|
|
||||||
Widget _bubble(BuildContext context, ChatBubble bubble) {
|
Widget _bubble(BuildContext context, ChatBubble bubble) {
|
||||||
final persona = ref.watch(activeAiPersonaProvider);
|
|
||||||
final user = ref.watch(authProvider).user;
|
final user = ref.watch(authProvider).user;
|
||||||
|
|
||||||
if (bubble.isUser) {
|
if (bubble.isUser) {
|
||||||
@@ -340,7 +346,10 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
|
|
||||||
return _row(
|
return _row(
|
||||||
avatar: Avatar(
|
avatar: Avatar(
|
||||||
assetPath: persona?.avatar,
|
// 会话锁定了哪个人设,气泡上就显示谁 —— 和这段回答实际用的 prompt 同一个人。
|
||||||
|
// 不能用「新对话预选的那条」:用户之后新对话选了别人,
|
||||||
|
// 这条旧会话的历史消息不该跟着换头像
|
||||||
|
assetPath: _sessionPersonaAvatar(),
|
||||||
size: 36,
|
size: 36,
|
||||||
fallbackIcon: 'assistant',
|
fallbackIcon: 'assistant',
|
||||||
),
|
),
|
||||||
@@ -397,6 +406,15 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 当前会话绑定的人设头像。null = 没设过(或人设被删了),显示默认图标。
|
||||||
|
///
|
||||||
|
/// 关键在「按会话存的那个 id 去找」而不是读全局偏好 —— 见
|
||||||
|
/// [personaByIdProvider] 的说明。
|
||||||
|
String? _sessionPersonaAvatar() {
|
||||||
|
final personaId = ref.watch(assistantProvider.select((s) => s.personaId));
|
||||||
|
return ref.watch(personaByIdProvider(personaId))?.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
/// 一行 = 头像 + 气泡。用户侧左右镜像。
|
/// 一行 = 头像 + 气泡。用户侧左右镜像。
|
||||||
///
|
///
|
||||||
/// 头像用 [CrossAxisAlignment.start](贴顶)而不是居中 —— 回答很长时
|
/// 头像用 [CrossAxisAlignment.start](贴顶)而不是居中 —— 回答很长时
|
||||||
@@ -643,8 +661,9 @@ class _PersonaPickerSheet extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final personasAsync = ref.watch(aiPersonasProvider);
|
final personasAsync = ref.watch(aiPersonasProvider);
|
||||||
// 默认选中「用户当前偏好的那套」,省得每次都点
|
// 预选上次用的那套,省得每次重选。注意这只是「预选」——
|
||||||
final preferred = ref.watch(selectedAiPersonaProvider);
|
// 真正生效的人设是新建会话时定下的那个
|
||||||
|
final preferred = ref.watch(preferredPersonaProvider);
|
||||||
|
|
||||||
return SafeArea(
|
return SafeArea(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
|
|||||||
@@ -11,53 +11,56 @@ final aiPersonasProvider = FutureProvider.autoDispose<List<AiPersonaItem>>((ref)
|
|||||||
return ref.watch(aiApiProvider).personas();
|
return ref.watch(aiApiProvider).personas();
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 当前选中的人设。
|
/// **新对话时预选哪个人设**的偏好。null = 内置默认人设。
|
||||||
///
|
///
|
||||||
/// **不设 autoDispose**:理由和 [SelectedAiConfigNotifier] 一样 ——
|
/// ⚠️ 注意它**不是**「当前正在用的人设」—— 那是 `AssistantState.personaId`,
|
||||||
/// 选完回对话页要立刻生效,切页面不该丢。
|
/// 在新建会话时定下来、之后不再变(见 `ai_provider.dart` 里的说明)。
|
||||||
class SelectedAiPersonaNotifier extends Notifier<int?> {
|
///
|
||||||
|
/// 两者的分工:
|
||||||
|
/// · 这个 = 「上次选了谁」,只用来在新对话弹层里打个勾,省得每次重选
|
||||||
|
/// · 会话的 personaId = 「这次对话实际在用谁」,气泡、prompt 都看它
|
||||||
|
///
|
||||||
|
/// 会不一样:用户拿人设 A 聊过一轮,之后新对话选了 B —— 那条旧会话的
|
||||||
|
/// 历史消息仍然该显示 A 的头像和 A 的记录。
|
||||||
|
///
|
||||||
|
/// 名字里刻意没有「selected」这个词:AI 设置页**不提供**「选说话的人」这个动作,
|
||||||
|
/// 选人只发生在新建会话那一步。用「selected」会让人以为设置页能改。
|
||||||
|
class PreferredPersonaNotifier extends Notifier<int?> {
|
||||||
@override
|
@override
|
||||||
int? build() {
|
int? build() {
|
||||||
// 存的是「真实 id」,null = 用内置默认。
|
// 存的是「真实 id」,null = 内置默认。
|
||||||
// 默认项在列表里的 id 也是 null,两边语义天然一致。
|
// 默认项在列表里的 id 也是 null,两边语义天然一致。
|
||||||
return LocalStorage.instance.getAiPersonaId();
|
return LocalStorage.instance.getAiPersonaId();
|
||||||
}
|
}
|
||||||
|
|
||||||
void select(int? id) {
|
void remember(int? id) {
|
||||||
state = id;
|
state = id;
|
||||||
LocalStorage.instance.saveAiPersonaId(id);
|
LocalStorage.instance.saveAiPersonaId(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 被记住的那条人设删了 —— 忘掉它,别留着指向不存在的 id
|
||||||
|
void forget() => remember(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
final selectedAiPersonaProvider =
|
final preferredPersonaProvider =
|
||||||
NotifierProvider<SelectedAiPersonaNotifier, int?>(SelectedAiPersonaNotifier.new);
|
NotifierProvider<PreferredPersonaNotifier, int?>(PreferredPersonaNotifier.new);
|
||||||
|
|
||||||
/// 从人设列表里挑出当前选中的那一条。
|
/// 按 id 找人设。聊天气泡用它取「当前会话绑的那个人」的头像。
|
||||||
///
|
///
|
||||||
/// 三种情况都要兜住:
|
/// 直接读会话的 personaId 去找,而不是用全局偏好 —— 会话锁定了谁,
|
||||||
/// · 还没选过 → 用列表第一项(也就是「默认」)
|
/// 气泡上就该显示谁,和那段回答实际用的 prompt 是同一套。
|
||||||
/// · 选过但那条被删了 → 退回第一项,并顺手把本地选择改掉
|
|
||||||
/// · 列表还没加载出来 → 返回 null,界面显示「--」
|
|
||||||
///
|
///
|
||||||
/// 注意这里和服务端的降级是**两套独立的安全网**:服务端拿到过期 id 也会
|
/// id 为 null(内置默认人设)或找不到(人设被删)时返回 null,
|
||||||
/// 退回内置默认,所以就算这里没来得及纠正,对话也不会挂。
|
/// 调用方据此显示默认图标。
|
||||||
final activeAiPersonaProvider = Provider.autoDispose<AiPersonaItem?>((ref) {
|
final personaByIdProvider =
|
||||||
final personas = ref.watch(aiPersonasProvider).value;
|
Provider.autoDispose.family<AiPersonaItem?, int?>((ref, id) {
|
||||||
if (personas == null || personas.isEmpty) return null;
|
if (id == null) return null;
|
||||||
|
final list = ref.watch(aiPersonasProvider).value;
|
||||||
final selectedId = ref.watch(selectedAiPersonaProvider);
|
if (list == null) return null;
|
||||||
if (selectedId == null) return personas.first;
|
for (final p in list) {
|
||||||
|
if (p.id == id) return p;
|
||||||
for (final item in personas) {
|
|
||||||
if (item.selectionKey == selectedId) return item;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
// 选中的那条已经不存在了(被删了)。退回默认。
|
|
||||||
// 放在微任务里改 state —— build 过程中不能同步改另一个 provider。
|
|
||||||
Future.microtask(() {
|
|
||||||
ref.read(selectedAiPersonaProvider.notifier).select(null);
|
|
||||||
});
|
|
||||||
return personas.first;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/// 内置默认人设文本,供编辑页「恢复默认」按钮使用。
|
/// 内置默认人设文本,供编辑页「恢复默认」按钮使用。
|
||||||
|
|||||||
@@ -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<List<AiConfigItem>> configs() async => const [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<AiPersonaItem>> 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<void> settle(WidgetTester tester) async {
|
||||||
|
await tester.pump();
|
||||||
|
await tester.pump(const Duration(milliseconds: 300));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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: '可编辑的人设才能有菜单,默认项不给');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.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/api/ai_api.dart';
|
||||||
import 'package:qiangbao_accounting/models/ai.dart';
|
import 'package:qiangbao_accounting/models/ai.dart';
|
||||||
import 'package:qiangbao_accounting/models/ai_persona.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_provider.dart';
|
||||||
import 'package:qiangbao_accounting/providers/ai_persona_provider.dart';
|
import 'package:qiangbao_accounting/providers/ai_persona_provider.dart';
|
||||||
|
|
||||||
@@ -39,7 +41,35 @@ class _CapturingAiApi extends AiApi {
|
|||||||
Future<List<ChatSessionItem>> sessions() async => const [];
|
Future<List<ChatSessionItem>> sessions() async => const [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<AiPersonaItem>> personas() async => const [];
|
Future<List<ChatMessageItem>> messages(int sessionId) async => const [
|
||||||
|
ChatMessageItem(id: 1, role: 'user', content: '你好'),
|
||||||
|
ChatMessageItem(id: 2, role: 'assistant', content: '你好呀'),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// 两个人设,头像区分明显 —— 用于验证气泡上显示的是哪一个
|
||||||
|
@override
|
||||||
|
Future<List<AiPersonaItem>> 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() {
|
void main() {
|
||||||
@@ -57,7 +87,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group('★ 会话内人设固定', () {
|
group('★ 会话内人设固定', () {
|
||||||
test('新对话选的人设会一直用下去(中途全局偏好变了也不受影响)', () async {
|
test('新对话选的人设会一直用下去(中途预选偏好变了也不受影响)', () async {
|
||||||
final api = _CapturingAiApi();
|
final api = _CapturingAiApi();
|
||||||
final c = makeContainer(api);
|
final c = makeContainer(api);
|
||||||
final notifier = c.read(assistantProvider.notifier);
|
final notifier = c.read(assistantProvider.notifier);
|
||||||
@@ -66,14 +96,17 @@ void main() {
|
|||||||
notifier.newChat(personaId: 7);
|
notifier.newChat(personaId: 7);
|
||||||
expect(c.read(assistantProvider).personaId, 7);
|
expect(c.read(assistantProvider).personaId, 7);
|
||||||
|
|
||||||
// 中途用户在设置页把全局偏好换成了 9
|
// 之后他又开了一次新对话、选了人设 9(这是偏好,不是「当前会话的人设」)
|
||||||
c.read(selectedAiPersonaProvider.notifier).select(9);
|
c.read(preferredPersonaProvider.notifier).remember(9);
|
||||||
|
|
||||||
|
// 回到**第一个人设 7 的那条会话**继续说话。
|
||||||
|
// 用 openSession 模拟「切回历史会话」——它会用该会话自己的 personaId
|
||||||
|
await notifier.openSession(1, personaId: 7);
|
||||||
|
|
||||||
// 继续在这个会话里说话
|
|
||||||
await notifier.send('第一句');
|
await notifier.send('第一句');
|
||||||
await notifier.send('第二句');
|
await notifier.send('第二句');
|
||||||
|
|
||||||
// 两次都该用 7,不该跟着全局偏好变成 9
|
// 两次都该用 7,不该跟着偏好变成 9
|
||||||
expect(api.personaIdsSent, [7, 7],
|
expect(api.personaIdsSent, [7, 7],
|
||||||
reason: '会话锁定的人设不该被全局偏好覆盖');
|
reason: '会话锁定的人设不该被全局偏好覆盖');
|
||||||
});
|
});
|
||||||
@@ -152,4 +185,143 @@ void main() {
|
|||||||
expect(s.personaName, '默认人设');
|
expect(s.personaName, '默认人设');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('★ 气泡头像跟着会话的人设走', () {
|
||||||
|
/// 从渲染出来的 Image 里收集所有网络图片地址。
|
||||||
|
///
|
||||||
|
/// 要拆一层 ResizeImage:Avatar 传了 cacheWidth,Flutter 会把
|
||||||
|
/// NetworkImage 包成 ResizeImage,直接 whereType<NetworkImage> 会取不到。
|
||||||
|
List<String> imageUrls(WidgetTester tester) {
|
||||||
|
final urls = <String>[];
|
||||||
|
for (final img in tester.widgetList<Image>(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<void> 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user