AI助手优化-头像和对话功能
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.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/avatar.dart';
|
||||
import '../../../widgets/state_views.dart';
|
||||
|
||||
/// 人设列表页(不含 Scaffold/AppBar —— 它作为「AI 设置」页的一个 Tab 嵌进去)
|
||||
@@ -154,6 +156,13 @@ class AiPersonaTab extends ConsumerWidget {
|
||||
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(
|
||||
@@ -319,6 +328,20 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
||||
|
||||
bool _saving = false;
|
||||
|
||||
/// 当前头像相对路径。null = 没有头像。
|
||||
///
|
||||
/// 三种状态要分清(和后端语义对齐):
|
||||
/// · 进来时的原值 → 不传,保持不动
|
||||
/// · 用户选了新图 → 传新路径
|
||||
/// · 用户点了「移除头像」→ 传 clearAvatar=true
|
||||
String? _avatar;
|
||||
|
||||
/// 是否显式要求清空头像
|
||||
bool _clearAvatar = false;
|
||||
|
||||
/// 上传中(转圈 + 禁止重复点)
|
||||
bool _uploading = false;
|
||||
|
||||
bool get _isEdit => widget.existing != null;
|
||||
|
||||
@override
|
||||
@@ -331,6 +354,7 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
||||
_prompt = TextEditingController(
|
||||
text: e?.systemPrompt ?? ref.read(defaultPersonaPromptProvider) ?? '',
|
||||
);
|
||||
_avatar = e?.avatar;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -340,6 +364,82 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 从相册或相机选一张头像。
|
||||
///
|
||||
/// 这里**传了压缩参数**(和水印那个「要原图」的场景不同)——
|
||||
/// 头像显示尺寸才 40px,传几 MB 的原图纯属浪费流量和服务器空间。
|
||||
Future<void> _pickAvatar(ImageSource source) async {
|
||||
if (_uploading) return;
|
||||
try {
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
imageQuality: 80,
|
||||
maxWidth: 800,
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
setState(() => _uploading = true);
|
||||
final path = await ref.read(aiApiProvider).uploadPersonaAvatar(picked.path);
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_avatar = path;
|
||||
_clearAvatar = false;
|
||||
});
|
||||
showToast(context, '头像已上传,记得保存', icon: 'check');
|
||||
} on ApiException catch (e) {
|
||||
if (mounted) showToast(context, e.message, error: true);
|
||||
} catch (e) {
|
||||
if (mounted) showToast(context, '上传失败:$e', error: true);
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploading = false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 底部选「拍照 / 相册」
|
||||
Future<void> _showAvatarPicker() async {
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_camera_outlined, color: Color(0xFFC8956E)),
|
||||
title: const Text('拍一张', style: TextStyle(fontSize: 14.5)),
|
||||
onTap: () => Navigator.of(sheetContext).pop(ImageSource.camera),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined, color: Color(0xFFC8956E)),
|
||||
title: const Text('从相册选', style: TextStyle(fontSize: 14.5)),
|
||||
onTap: () => Navigator.of(sheetContext).pop(ImageSource.gallery),
|
||||
),
|
||||
if ((_avatar?.isNotEmpty ?? false))
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline, color: Color(0xFFE53935)),
|
||||
title: const Text('移除头像',
|
||||
style: TextStyle(fontSize: 14.5, color: Color(0xFFE53935))),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
setState(() {
|
||||
_avatar = null;
|
||||
_clearAvatar = true;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (source == null || !mounted) return;
|
||||
await _pickAvatar(source);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final prompt = _prompt.text.trim();
|
||||
if (prompt.isEmpty) {
|
||||
@@ -350,16 +450,21 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
final api = ref.read(aiApiProvider);
|
||||
// 只有「选了新图」才传 avatar;没动就不传(后端保持原值)
|
||||
final newAvatar = _clearAvatar ? null : _avatar;
|
||||
if (_isEdit) {
|
||||
await api.updatePersona(
|
||||
id: widget.existing!.id!,
|
||||
name: _name.text.trim(),
|
||||
systemPrompt: prompt,
|
||||
avatar: newAvatar,
|
||||
clearAvatar: _clearAvatar,
|
||||
);
|
||||
} else {
|
||||
await api.createPersona(
|
||||
name: _name.text.trim(),
|
||||
systemPrompt: prompt,
|
||||
avatar: newAvatar,
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
@@ -375,6 +480,69 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 头像选择区:一个圆形头像 + 下面一行小字。
|
||||
///
|
||||
/// 点它弹「拍照 / 相册 / 移除」的选择。已上传时头像立即预览
|
||||
/// (用的是刚传回来的相对路径,本地也能看到)。
|
||||
Widget _avatarPicker() {
|
||||
return GestureDetector(
|
||||
onTap: _uploading ? null : _showAvatarPicker,
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
children: [
|
||||
Avatar(
|
||||
assetPath: _avatar,
|
||||
size: 76,
|
||||
fallbackIcon: 'assistant',
|
||||
),
|
||||
// 悬一个小相机角标,暗示「可以点」
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
child: Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: const Color(0xFFC8956E),
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
child: const Icon(Icons.camera_alt,
|
||||
size: 12, color: Colors.white),
|
||||
),
|
||||
),
|
||||
if (_uploading)
|
||||
Positioned.fill(
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black26,
|
||||
),
|
||||
child: const Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_uploading ? '上传中…' : '点头像换个形象',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFFBCAAA4)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _restoreDefault() {
|
||||
final def = ref.read(defaultPersonaPromptProvider);
|
||||
if (def == null || def.isEmpty) {
|
||||
@@ -412,6 +580,10 @@ class _PersonaEditorState extends ConsumerState<_PersonaEditor> {
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// 头像放最上面 —— 它是「这个人长什么样」的第一印象
|
||||
Center(child: _avatarPicker()),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
_field(
|
||||
label: '名称',
|
||||
controller: _name,
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user