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 嵌进去) /// /// 与模型配置页 [AiConfigPage] 是**平行的两套**:一个管模型,一个管人设。 /// 分成两个 Tab 而不是在顶栏加两个胶囊,是因为顶栏已经挤了五块东西, /// 320 宽的小屏塞不下第二个胶囊。 /// /// 三个要点: /// 1. **「默认」是虚拟项** —— 内容是服务端代码里的常量,不能编辑/删除。 /// 2. **预填而不是空框** —— 新增时把内置默认文本填进去,用户改一改就用, /// 而不是面对一片空白不知道写什么。 /// 3. **提示词里不含日期** —— 那是运行时事实,服务端每次对话自动追加。 class AiPersonaTab extends ConsumerWidget { const AiPersonaTab({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final personas = ref.watch(aiPersonasProvider); final selectedId = ref.watch(selectedAiPersonaProvider); return Scaffold( backgroundColor: const Color(0xFFFAF8F5), floatingActionButton: FloatingActionButton( onPressed: () => _openEditor(context, ref, null), backgroundColor: const Color(0xFFC8956E), foregroundColor: Colors.white, tooltip: '新增人设', child: const Icon(Icons.add), ), body: personas.when( loading: () => const LoadingView(tip: '正在加载人设...'), error: (e, _) => ErrorView( message: e is ApiException ? e.message : '人设加载失败', onRetry: () => ref.invalidate(aiPersonasProvider), ), data: (list) { final custom = list.where((p) => p.editable).length; return ListView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 88), children: [ _introCard(), const SizedBox(height: 14), for (final item in list) ...[ _personaTile(context, ref, item, selectedId, custom), const SizedBox(height: 10), ], if (custom == 0) _emptyHint(), ], ); }, ), ); } Widget _introCard() { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: const Color(0xFFFFF3E6), borderRadius: BorderRadius.circular(12), ), child: const Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '换个说话的人', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), SizedBox(height: 5), Text( '人设决定助手的说话风格和做事规则,和用哪个模型是两件独立的事 ——\n' '换模型不会换人设。\n' '提示词里不用写日期,服务端每次会自动补上今天几号、「这个月」指哪个月。', style: TextStyle(fontSize: 12, height: 1.6, color: Color(0xFF8D6E63)), ), ], ), ); } Widget _emptyHint() { return Padding( padding: const EdgeInsets.only(top: 4), child: Container( width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), ), child: const Text( '还没有自己的人设,用的就是「默认」那套。\n' '想换个风格就点右下角「+」—— 会预填一份能用的,改一改就行。', textAlign: TextAlign.center, style: TextStyle(fontSize: 12.5, height: 1.7, color: Color(0xFFBCAAA4)), ), ), ); } 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, ), ), 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), ), ), ), 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('删除')), ], ), ], ), ), ), ); } Widget _tag(String text, Color color) { return Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), decoration: BoxDecoration( color: color.withValues(alpha: 0.11), borderRadius: BorderRadius.circular(6), ), child: Text(text, style: TextStyle(fontSize: 10.5, color: color)), ); } // ---------------- 增删改 ---------------- Future _openEditor( BuildContext context, WidgetRef ref, AiPersonaItem? existing, ) async { final saved = await showModalBottomSheet( context: context, backgroundColor: Colors.white, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (_) => _PersonaEditor(existing: existing), ); if (saved == true && context.mounted) { ref.invalidate(aiPersonasProvider); showToast(context, existing == null ? '已添加' : '已保存', icon: 'check'); } } Future _confirmDelete( BuildContext context, WidgetRef ref, AiPersonaItem item, int customCount, ) async { final ok = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('删除人设?', style: TextStyle(fontSize: 16)), content: Text( '「${item.name}」会被删掉。\n' '${customCount == 1 ? '删掉之后会自动回到「默认」那套。' : ''}', style: const TextStyle(fontSize: 13, height: 1.6), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('取消', style: TextStyle(color: Color(0xFF8D6E63))), ), TextButton( onPressed: () => Navigator.of(dialogContext).pop(true), child: const Text('删除', style: TextStyle(color: Color(0xFFE53935))), ), ], ), ); if (ok != true || !context.mounted) return; try { await ref.read(aiApiProvider).deletePersona(item.id!); if (!context.mounted) return; // 删掉的正好是当前选中的 → 退回默认 if (ref.read(selectedAiPersonaProvider) == item.id) { ref.read(selectedAiPersonaProvider.notifier).select(null); } ref.invalidate(aiPersonasProvider); showToast(context, '已删除', icon: 'check'); } on ApiException catch (e) { if (context.mounted) showToast(context, e.message, error: true); } catch (_) { if (context.mounted) showToast(context, '删除失败', error: true); } } } /// 新增 / 编辑人设的表单 class _PersonaEditor extends ConsumerStatefulWidget { const _PersonaEditor({this.existing}); /// null = 新增 final AiPersonaItem? existing; @override ConsumerState<_PersonaEditor> createState() => _PersonaEditorState(); } class _PersonaEditorState extends ConsumerState<_PersonaEditor> { late final TextEditingController _name; late final TextEditingController _prompt; bool _saving = false; /// 当前头像相对路径。null = 没有头像。 /// /// 三种状态要分清(和后端语义对齐): /// · 进来时的原值 → 不传,保持不动 /// · 用户选了新图 → 传新路径 /// · 用户点了「移除头像」→ 传 clearAvatar=true String? _avatar; /// 是否显式要求清空头像 bool _clearAvatar = false; /// 上传中(转圈 + 禁止重复点) bool _uploading = false; bool get _isEdit => widget.existing != null; @override void initState() { super.initState(); final e = widget.existing; _name = TextEditingController(text: e?.name ?? ''); // 新增时预填内置默认文本 —— 面对的不是空白框,是一份能跑的。 // 拿不到(列表还没加载出来)就先空着,用户手动点「恢复默认」也能填 _prompt = TextEditingController( text: e?.systemPrompt ?? ref.read(defaultPersonaPromptProvider) ?? '', ); _avatar = e?.avatar; } @override void dispose() { _name.dispose(); _prompt.dispose(); super.dispose(); } /// 从相册或相机选一张头像。 /// /// 这里**传了压缩参数**(和水印那个「要原图」的场景不同)—— /// 头像显示尺寸才 40px,传几 MB 的原图纯属浪费流量和服务器空间。 Future _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 _showAvatarPicker() async { final source = await showModalBottomSheet( 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 _save() async { final prompt = _prompt.text.trim(); if (prompt.isEmpty) { showToast(context, '提示词不能为空', error: true); return; } 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; Navigator.of(context).pop(true); } on ApiException catch (e) { if (!mounted) return; showToast(context, e.message, error: true); } catch (_) { if (!mounted) return; showToast(context, '保存失败', error: true); } finally { if (mounted) setState(() => _saving = false); } } /// 头像选择区:一个圆形头像 + 下面一行小字。 /// /// 点它弹「拍照 / 相册 / 移除」的选择。已上传时头像立即预览 /// (用的是刚传回来的相对路径,本地也能看到)。 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) { showToast(context, '默认人设还没加载出来,稍后再试', error: true); return; } setState(() => _prompt.text = def); showToast(context, '已填入默认人设,记得保存'); } @override Widget build(BuildContext context) { // 是否提示「没提到工具」——只提示,不阻止保存 final lacksToolHint = _prompt.text.trim().isNotEmpty && !_prompt.text.contains('工具'); return Padding( padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Center( child: Text( _isEdit ? '编辑人设' : '新增人设', style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), ), const SizedBox(height: 18), // 头像放最上面 —— 它是「这个人长什么样」的第一印象 Center(child: _avatarPicker()), const SizedBox(height: 18), _field( label: '名称', controller: _name, hint: '留空则取提示词开头几个字,比如:花花', maxLines: 1, ), _field( label: '提示词', controller: _prompt, hint: '比如:你是一个说话简洁的记账助手…', helper: '人设 + 运行规则都写这里。日期由服务端自动补,不用写。', maxLines: null, minLines: 12, ), if (lacksToolHint) ...[ const SizedBox(height: 2), _warnBox(), ], const SizedBox(height: 6), // 「恢复默认」放左边,和右边的取消/保存隔开 —— 避免误点 Row( children: [ TextButton.icon( onPressed: _saving ? null : _restoreDefault, icon: const Icon(Icons.settings_backup_restore, size: 16), label: const Text('恢复默认', style: TextStyle(fontSize: 12.5)), style: TextButton.styleFrom( foregroundColor: const Color(0xFF8D6E63), ), ), ], ), const SizedBox(height: 10), Row( children: [ Expanded( child: SizedBox( height: 46, child: OutlinedButton( onPressed: _saving ? null : () => Navigator.of(context).pop(), style: OutlinedButton.styleFrom( foregroundColor: const Color(0xFFC8956E), side: const BorderSide(color: Color(0xFFC8956E)), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(23), ), ), child: const Text('取消'), ), ), ), const SizedBox(width: 12), Expanded( child: SizedBox( height: 46, child: FilledButton( onPressed: _saving ? null : _save, style: FilledButton.styleFrom( backgroundColor: const Color(0xFFC8956E), foregroundColor: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(23), ), ), child: _saving ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white, ), ) : const Text('保存'), ), ), ), ], ), ], ), ), ), ); } /// 软提示:提示词里没提到「工具」就提醒一句。 /// /// 只提示、不阻止保存 —— 用户明确要求规则可编辑,不该替他做决定。 /// 但这条提醒很重要:删掉「必须用工具查询」之后,助手会开始凭印象编造金额, /// 而且不会有任何报错。 Widget _warnBox() { return Container( width: double.infinity, padding: const EdgeInsets.all(11), decoration: BoxDecoration( color: const Color(0xFFFFF8E1), borderRadius: BorderRadius.circular(10), border: Border.all(color: const Color(0xFFFFE082)), ), child: const Text( '提示:没提到「工具」。助手可能会凭印象编造金额和数字 ——\n' '建议保留「必须用工具查询,不要编造数字」这类要求。', style: TextStyle(fontSize: 11.5, height: 1.65, color: Color(0xFF8A6D1F)), ), ); } Widget _field({ required String label, required TextEditingController controller, required String hint, String? helper, int? maxLines = 1, int? minLines, }) { // 单行 vs 多行:maxLines 为 null 或 >1 都算多行 final multiline = maxLines == null || maxLines != 1; return Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF8D6E63))), const SizedBox(height: 6), Container( padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: const Color(0xFFF7F4F1), borderRadius: BorderRadius.circular(10), ), child: TextField( controller: controller, maxLines: maxLines, minLines: minLines, // 多行时让文字从顶部开始,否则会垂直居中显得很怪 textAlignVertical: multiline ? TextAlignVertical.top : TextAlignVertical.center, keyboardType: multiline ? TextInputType.multiline : TextInputType.text, autocorrect: false, enableSuggestions: false, // 边打字边刷新 —— 上面那个软提示要根据内容变 onChanged: multiline ? (_) => setState(() {}) : null, style: TextStyle( fontSize: multiline ? 13 : 14, height: multiline ? 1.75 : null, color: const Color(0xFF5D4037), ), decoration: InputDecoration( border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.symmetric( vertical: multiline ? 12 : 13, ), hintText: hint, hintStyle: const TextStyle(fontSize: 13.5, color: Color(0xFFD7CCC8)), ), ), ), if (helper != null) Padding( padding: const EdgeInsets.only(top: 4, left: 2), child: Text( helper, style: const TextStyle(fontSize: 11, color: Color(0xFFBCAAA4)), ), ), ], ), ); } }