AI助手优化-头像和对话功能

This commit is contained in:
2026-09-29 09:45:02 +08:00
parent 80222099ff
commit f4ad2f8612
15 changed files with 990 additions and 37 deletions
+218 -15
View File
@@ -8,8 +8,11 @@ import '../../core/network/api_env.dart';
import '../../models/ai.dart';
import '../../providers/ai_provider.dart';
import '../../providers/ai_config_provider.dart';
import '../../providers/ai_persona_provider.dart';
import '../../providers/auth_provider.dart';
import 'ai_config_page.dart';
import '../../utils/toast.dart';
import '../../widgets/avatar.dart';
import '../../widgets/state_views.dart';
import '../../widgets/cute_icon.dart';
@@ -121,7 +124,8 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
_iconButton(
icon: Icons.add_comment_outlined,
tooltip: '新对话',
onTap: () => ref.read(assistantProvider.notifier).newChat(),
// 新对话要先选人设 —— 选了之后这一轮就锁定它,中途不会换
onTap: () => _startNewChat(context),
),
],
),
@@ -261,17 +265,59 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
);
}
// ---------------------------------------------------------------- 新对话
/// 「新对话」:先让用户挑个人设,再开始。
///
/// 挑完就锁定在这一轮对话上(存进 AssistantState.personaId),
/// 之后不管用户在设置页怎么换人设,这轮都不会变 —— 这是刻意的,
/// 见 `AssistantState.personaId` 的注释。
///
/// 列表里第一项是「默认人设」,也就是什么都不选的状态。用户直接关掉弹层
/// (返回 null)时也按默认处理,不强迫他做选择。
Future<void> _startNewChat(BuildContext context) async {
final picked = await showModalBottomSheet<_PersonaChoice>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => const _PersonaPickerSheet(),
);
if (!mounted) return;
// 关掉弹层 = 用默认人设。不返回任何东西也照样开新对话,
// 免得用户以为「没点就是取消了」
ref.read(assistantProvider.notifier).newChat(personaId: picked?.personaId);
}
// ---------------------------------------------------------------- 气泡
//
// 微信风格:左右两侧都带头像,气泡紧贴头像那一侧。
// · 对方(AI)头像在左,用**当前会话绑的人设头像**;没设过就是默认小人
// · 自己(用户)头像在右,用账号头像;没设过是默认小人
//
// 头像只给 36px —— 太大反而抢戏,微信也就这个量级。
Widget _bubble(BuildContext context, ChatBubble bubble) {
final persona = ref.watch(activeAiPersonaProvider);
final user = ref.watch(authProvider).user;
if (bubble.isUser) {
return Align(
alignment: Alignment.centerRight,
child: Container(
margin: const EdgeInsets.only(top: 10, left: 48),
return _row(
avatar: Avatar(
assetPath: user?.avatar,
size: 36,
fallbackIcon: 'user',
),
bubble: Container(
margin: const EdgeInsets.only(left: 40),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFD9A87C)]),
gradient: const LinearGradient(
colors: [Color(0xFFC8956E), Color(0xFFD9A87C)],
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(16),
topRight: Radius.circular(16),
@@ -281,16 +327,25 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
),
child: SelectableText(
bubble.content,
style: const TextStyle(fontSize: 14.5, height: 1.5, color: Colors.white),
style: const TextStyle(
fontSize: 14.5,
height: 1.5,
color: Colors.white,
),
),
),
isUser: true,
);
}
return Align(
alignment: Alignment.centerLeft,
child: Container(
margin: const EdgeInsets.only(top: 10, right: 24),
return _row(
avatar: Avatar(
assetPath: persona?.avatar,
size: 36,
fallbackIcon: 'assistant',
),
bubble: Container(
margin: const EdgeInsets.only(right: 40),
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
decoration: BoxDecoration(
color: Colors.white,
@@ -338,6 +393,35 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
],
),
),
isUser: false,
);
}
/// 一行 = 头像 + 气泡。用户侧左右镜像。
///
/// 头像用 [CrossAxisAlignment.start](贴顶)而不是居中 —— 回答很长时
/// 头像跟着垂直居中会飘到中间,微信也是贴顶的。
Widget _row({
required Widget avatar,
required Widget bubble,
required bool isUser,
}) {
return Padding(
padding: const EdgeInsets.only(top: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: isUser
? [
Expanded(child: Align(alignment: Alignment.centerRight, child: bubble)),
const SizedBox(width: 8),
avatar,
]
: [
avatar,
const SizedBox(width: 8),
Expanded(child: Align(alignment: Alignment.centerLeft, child: bubble)),
],
),
);
}
@@ -498,7 +582,7 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
// ---------------------------------------------------------------- 历史会话
Future<void> _showSessions(BuildContext context) async {
final selected = await showModalBottomSheet<int>(
final selected = await showModalBottomSheet<ChatSessionItem>(
context: context,
backgroundColor: Colors.white,
isScrollControlled: true,
@@ -508,7 +592,11 @@ class _AssistantPageState extends ConsumerState<AssistantPage> {
builder: (_) => const _SessionSheet(),
);
if (selected == null || !mounted) return;
ref.read(assistantProvider.notifier).openSession(selected);
// 把人设一并带进去 —— 历史对话要用它当时绑的那个,
// 不能因为你现在换了默认人设就跟着变
ref
.read(assistantProvider.notifier)
.openSession(selected.id, personaId: selected.personaId);
}
// ---------------------------------------------------------------- 滚动
@@ -537,6 +625,112 @@ class _TypingDots extends StatelessWidget {
}
}
/// 新对话时选人设的结果
class _PersonaChoice {
const _PersonaChoice(this.personaId);
/// null = 内置默认人设
final int? personaId;
}
/// 新对话的人设选择面板。
///
/// 为什么新对话要先选人设:人设一旦定了,这轮对话就不再变(换了会前后不一致)。
/// 所以在「开始一段对话」这个时刻选是最自然的 —— 而不是聊着聊着去设置页改。
class _PersonaPickerSheet extends ConsumerWidget {
const _PersonaPickerSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final personasAsync = ref.watch(aiPersonasProvider);
// 默认选中「用户当前偏好的那套」,省得每次都点
final preferred = ref.watch(selectedAiPersonaProvider);
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'这次想找谁聊?',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: Color(0xFF5D4037),
),
),
const SizedBox(height: 4),
const Text(
'选定后整段对话都是这个人,中途不会换',
style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
),
const SizedBox(height: 12),
Flexible(
child: personasAsync.when(
loading: () => const LoadingView(tip: '加载中…'),
error: (e, _) => ErrorView(
message: '人设加载失败',
onRetry: () => ref.invalidate(aiPersonasProvider),
),
data: (list) {
if (list.isEmpty) {
// 极端情况:后端一个人设都没有。不该卡住用户,
// 直接给一个「用默认」的出口
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
leading: const Avatar(size: 38, fallbackIcon: 'assistant'),
title: const Text('默认人设', style: TextStyle(fontSize: 14.5)),
onTap: () => Navigator.pop(context, const _PersonaChoice(null)),
);
}
return ListView.builder(
shrinkWrap: true,
itemCount: list.length,
itemBuilder: (context, index) {
final p = list[index];
final active = p.selectionKey == (preferred ?? -1);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
leading: Avatar(
assetPath: p.avatar,
size: 42,
fallbackIcon: 'assistant',
),
title: Text(
p.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14.5,
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
color: const Color(0xFF5D4037),
),
),
subtitle: Text(
p.isDefault ? '代码内置的那套' : p.preview,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
),
trailing: active
? const Icon(Icons.check_circle,
size: 19, color: Color(0xFFC8956E))
: null,
onTap: () => Navigator.pop(context, _PersonaChoice(p.id)),
);
},
);
},
),
),
],
),
),
);
}
}
/// 历史会话面板
class _SessionSheet extends ConsumerWidget {
const _SessionSheet();
@@ -578,6 +772,13 @@ class _SessionSheet extends ConsumerWidget {
final s = sessions[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
// 会话头像 = 那条会话绑定的人设头像。
// 一眼看出「这段对话是谁在陪你」,不用点进去
leading: Avatar(
assetPath: s.personaAvatar,
size: 38,
fallbackIcon: 'assistant',
),
title: Text(
s.title,
maxLines: 1,
@@ -585,7 +786,9 @@ class _SessionSheet extends ConsumerWidget {
style: const TextStyle(fontSize: 14.5, color: Color(0xFF5D4037)),
),
subtitle: Text(
s.updateTime,
'${s.personaName} · ${s.updateTime}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)),
),
trailing: IconButton(
@@ -594,7 +797,7 @@ class _SessionSheet extends ConsumerWidget {
color: const Color(0xFFBCAAA4),
onPressed: () => _confirmDelete(context, ref, s),
),
onTap: () => Navigator.pop(context, s.id),
onTap: () => Navigator.pop(context, s),
);
},
);