AI助手优化-人设与会话绑定

This commit is contained in:
2026-09-29 10:44:39 +08:00
parent f4ad2f8612
commit 900d48697e
5 changed files with 464 additions and 150 deletions
+85 -106
View File
@@ -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<String>(
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<String>(
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');
+26 -7
View File
@@ -287,9 +287,16 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
);
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 —— 太大反而抢戏,微信也就这个量级。
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<AssistantPage> {
return _row(
avatar: Avatar(
assetPath: persona?.avatar,
// 会话锁定了哪个人设,气泡上就显示谁 —— 和这段回答实际用的 prompt 同一个人。
// 不能用「新对话预选的那条」:用户之后新对话选了别人,
// 这条旧会话的历史消息不该跟着换头像
assetPath: _sessionPersonaAvatar(),
size: 36,
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](贴顶)而不是居中 —— 回答很长时
@@ -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(