import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_markdown_plus/flutter_markdown_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../api/ai_api.dart'; 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'; /// 助手:和 AI 对话,问账单、问笔记。 /// /// 既是底部 Tab,也能从首页那个输入框 push 进来 —— 所以顶部自己画了 /// 返回箭头(canPop 时才画)。 class AssistantPage extends ConsumerStatefulWidget { const AssistantPage({super.key}); @override ConsumerState createState() => _AssistantPageState(); } class _AssistantPageState extends ConsumerState { final _input = TextEditingController(); final _scroll = ScrollController(); /// 空状态下的引导问题 —— 让用户知道能问什么,比一句「你好」有用 static const _suggestions = [ '这个月花了多少钱?', '上个月的支出分类排行', '我的笔记里有哪些关于 MySQL 的?', ]; @override void initState() { super.initState(); // 打开就把上一次的对话展开,而不是每次都从空白开始。 // 放进 postFrameCallback:initState 里改 provider 状态会撞上「构建期间改状态」。 WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; ref.read(assistantProvider.notifier).restoreLastSessionIfNeeded(); }); } @override void dispose() { _input.dispose(); _scroll.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final state = ref.watch(assistantProvider); _scheduleScroll(); return Scaffold( body: Container( width: double.infinity, height: double.infinity, decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFFFE5CC), Color(0xFFFFD4A8), Color(0xFFFFC08A)], ), ), child: SafeArea( child: Column( children: [ _header(context), Expanded(child: _body(context, state)), if (state.error != null) _errorBar(state.error!), _inputBar(state), ], ), ), ), ); } // ---------------------------------------------------------------- 顶部 Widget _header(BuildContext context) { return Padding( padding: const EdgeInsets.fromLTRB(10, 6, 8, 6), child: Row( children: [ if (Navigator.of(context).canPop()) _iconButton( icon: Icons.arrow_back, tooltip: '返回', onTap: () => Navigator.of(context).pop(), ), const Padding( padding: EdgeInsets.only(left: 6), child: Text( '助手', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF5D4037), ), ), ), const SizedBox(width: 8), // 当前用的哪个模型 —— 点一下就能换。 // 放在标题旁边而不是藏进菜单里,是因为「刚才那句话怎么答得怪怪的」 // 十有八九就是切错了模型,得让它随时可见。 Flexible(child: _configChip(context)), const Spacer(), _iconButton( icon: Icons.history, tooltip: '历史对话', onTap: () => _showSessions(context), ), _iconButton( icon: Icons.add_comment_outlined, tooltip: '新对话', // 新对话要先选人设 —— 选了之后这一轮就锁定它,中途不会换 onTap: () => _startNewChat(context), ), ], ), ); } /// 顶栏上的「当前模型」小胶囊 Widget _configChip(BuildContext context) { final active = ref.watch(activeAiConfigProvider); final label = active?.name ?? '--'; return GestureDetector( onTap: () => openAiConfigPage(context), child: Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), decoration: BoxDecoration( color: const Color(0xFFFFF3E6), borderRadius: BorderRadius.circular(10), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.memory, size: 12, color: Color(0xFFC8956E)), const SizedBox(width: 4), Flexible( child: Text( label, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11.5, color: Color(0xFF8D6E63)), ), ), const Icon(Icons.expand_more, size: 13, color: Color(0xFFBCAAA4)), ], ), ), ); } Widget _iconButton({ required IconData icon, required String tooltip, required VoidCallback onTap, }) { return Tooltip( message: tooltip, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(20), child: Padding( padding: const EdgeInsets.all(7), child: Icon(icon, size: 21, color: const Color(0xFF5D4037)), ), ), ); } // ---------------------------------------------------------------- 主体 Widget _body(BuildContext context, AssistantState state) { if (state.loadingHistory) { return const LoadingView(tip: '正在加载对话…'); } if (state.isEmpty) { return _welcome(context); } final showTool = state.streaming && state.bubbles.isNotEmpty; return ListView.builder( controller: _scroll, physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(14, 8, 14, 12), itemCount: state.bubbles.length + (showTool ? 1 : 0), itemBuilder: (context, index) { if (index >= state.bubbles.length) { return _toolIndicator(state.activeTool); } return _bubble(context, state.bubbles[index]); }, ); } Widget _welcome(BuildContext context) { return ListView( padding: const EdgeInsets.fromLTRB(24, 40, 24, 0), children: [ const Center(child: CuteIcon('assistant', size: 48)), const SizedBox(height: 14), const Center( child: Text( '问我点什么', style: TextStyle( fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), ), const SizedBox(height: 6), const Center( child: Text( '我能查你的账单统计和笔记', style: TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63)), ), ), const SizedBox(height: 26), for (final s in _suggestions) ...[ _suggestionChip(s), const SizedBox(height: 10), ], ], ); } Widget _suggestionChip(String text) { return GestureDetector( onTap: () => _send(text), child: Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.9), borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFE8D9C5)), ), child: Row( children: [ Expanded( child: Text( text, style: const TextStyle(fontSize: 13.5, color: Color(0xFF5D4037)), ), ), const Icon(Icons.north_east, size: 15, color: Color(0xFFBCAAA4)), ], ), ), ); } // ---------------------------------------------------------------- 新对话 /// 「新对话」:先让用户挑个人设,再开始。 /// /// 挑完就锁定在这一轮对话上(存进 AssistantState.personaId), /// 之后不管用户在设置页怎么换人设,这轮都不会变 —— 这是刻意的, /// 见 `AssistantState.personaId` 的注释。 /// /// 列表里第一项是「默认人设」,也就是什么都不选的状态。用户直接关掉弹层 /// (返回 null)时也按默认处理,不强迫他做选择。 Future _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; // 关掉弹层 = 取消,不开新对话。 // 不自动用默认人设开始:误触一下就把用户正在看的对话顶掉太亏了。 // 想用默认人设就点列表里第一项「默认人设」,一样是一下 if (picked == null) return; // 记住这次选的,下次新对话默认预选它。 // 这是**唯一**写这个偏好的地方 —— 设置页不提供「选说话的人」这个动作 ref.read(preferredPersonaProvider.notifier).remember(picked.personaId); ref.read(assistantProvider.notifier).newChat(personaId: picked.personaId); } // ---------------------------------------------------------------- 气泡 // // 微信风格:左右两侧都带头像,气泡紧贴头像那一侧。 // · 对方(AI)头像在左,用**当前会话绑的人设头像**;没设过就是默认小人 // · 自己(用户)头像在右,用账号头像;没设过是默认小人 // // 头像只给 36px —— 太大反而抢戏,微信也就这个量级。 Widget _bubble(BuildContext context, ChatBubble bubble) { final user = ref.watch(authProvider).user; if (bubble.isUser) { 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)], ), borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), bottomLeft: Radius.circular(16), bottomRight: Radius.circular(4), ), ), child: SelectableText( bubble.content, style: const TextStyle( fontSize: 14.5, height: 1.5, color: Colors.white, ), ), ), isUser: true, ); } return _row( avatar: Avatar( // 会话锁定了哪个人设,气泡上就显示谁 —— 和这段回答实际用的 prompt 同一个人。 // 不能用「新对话预选的那条」:用户之后新对话选了别人, // 这条旧会话的历史消息不该跟着换头像 assetPath: _sessionPersonaAvatar(), 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, borderRadius: const BorderRadius.only( topLeft: Radius.circular(16), topRight: Radius.circular(16), bottomLeft: Radius.circular(4), bottomRight: Radius.circular(16), ), boxShadow: [ BoxShadow( color: const Color(0xFF5D4037).withValues(alpha: 0.06), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (bubble.content.isEmpty) const _TypingDots() else MarkdownBody( data: bubble.content, selectable: true, styleSheet: _markdownStyle(context), onTapLink: (text, href, title) { if (href == null || href.isEmpty) return; Clipboard.setData(ClipboardData(text: href)); showToast(context, '链接已复制'); }, imageBuilder: (uri, title, alt) => Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network(resolveAssetUrl(uri.toString())), ), ), ), if (bubble.streaming && bubble.content.isNotEmpty) ...[ const SizedBox(height: 6), const _TypingDots(), ], ], ), ), isUser: false, ); } /// 当前会话绑定的人设头像。null = 没设过(或人设被删了),显示默认图标。 /// /// 关键在「按会话存的那个 id 去找」而不是读全局偏好 —— 见 /// [personaByIdProvider] 的说明。 String? _sessionPersonaAvatar() { final personaId = ref.watch(assistantProvider.select((s) => s.personaId)); return ref.watch(personaByIdProvider(personaId))?.avatar; } /// 一行 = 头像 + 气泡。用户侧左右镜像。 /// /// 头像用 [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)), ], ), ); } MarkdownStyleSheet _markdownStyle(BuildContext context) { final base = MarkdownStyleSheet.fromTheme(Theme.of(context)); return base.copyWith( p: const TextStyle(fontSize: 14.5, height: 1.6, color: Color(0xFF3E2723)), h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, height: 1.4, color: Color(0xFF3E2723)), h2: const TextStyle(fontSize: 16.5, fontWeight: FontWeight.bold, height: 1.4, color: Color(0xFF3E2723)), h3: const TextStyle(fontSize: 15.5, fontWeight: FontWeight.w600, height: 1.4, color: Color(0xFF3E2723)), listBullet: const TextStyle(fontSize: 14.5, height: 1.6, color: Color(0xFF3E2723)), code: const TextStyle( fontSize: 13, backgroundColor: Color(0xFFF5EFE6), color: Color(0xFF8D4B2A), ), codeblockDecoration: BoxDecoration( color: const Color(0xFFF5EFE6), borderRadius: BorderRadius.circular(8), ), blockquoteDecoration: BoxDecoration( color: const Color(0xFFFBF6EF), borderRadius: BorderRadius.circular(6), border: const Border(left: BorderSide(color: Color(0xFFC8956E), width: 3)), ), tableBorder: TableBorder.all(color: const Color(0xFFE8D9C5)), tableCellsPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), ); } /// 工具调用提示:「正在查账单…」 Widget _toolIndicator(String? tool) { return Padding( padding: const EdgeInsets.only(top: 10, left: 4), child: Row( children: [ const SizedBox( width: 12, height: 12, child: CircularProgressIndicator(strokeWidth: 1.6, color: Color(0xFFC8956E)), ), const SizedBox(width: 8), Text( _toolLabel(tool), style: const TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63)), ), ], ), ); } String _toolLabel(String? name) => switch (name) { 'query_bills' => '正在查账单…', 'query_notes' => '正在搜笔记…', 'get_note' => '正在读笔记…', _ => '正在查询…', }; // ---------------------------------------------------------------- 错误与输入 Widget _errorBar(String message) { return Container( width: double.infinity, margin: const EdgeInsets.fromLTRB(14, 0, 14, 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), decoration: BoxDecoration( color: const Color(0xFFFFEBEE), borderRadius: BorderRadius.circular(10), ), child: Row( children: [ const Icon(Icons.error_outline, size: 16, color: Color(0xFFD32F2F)), const SizedBox(width: 8), Expanded( child: Text( message, style: const TextStyle(fontSize: 12.5, color: Color(0xFFB71C1C)), ), ), GestureDetector( onTap: () => ref.read(assistantProvider.notifier).clearError(), child: const Icon(Icons.close, size: 15, color: Color(0xFFB71C1C)), ), ], ), ); } Widget _inputBar(AssistantState state) { return Container( padding: const EdgeInsets.fromLTRB(14, 8, 14, 10), child: Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: Container( decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(22), border: Border.all(color: const Color(0xFFE8D9C5)), ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2), child: TextField( controller: _input, enabled: !state.streaming, minLines: 1, maxLines: 4, textInputAction: TextInputAction.send, onSubmitted: _send, decoration: const InputDecoration( border: InputBorder.none, isDense: true, contentPadding: EdgeInsets.symmetric(vertical: 12), hintText: '问点什么…', hintStyle: TextStyle(fontSize: 14, color: Color(0xFFBCAAA4)), ), style: const TextStyle(fontSize: 14.5, color: Color(0xFF5D4037)), ), ), ), const SizedBox(width: 8), _sendButton(state), ], ), ); } Widget _sendButton(AssistantState state) { final disabled = state.streaming; return GestureDetector( onTap: disabled ? null : () => _send(_input.text), child: Container( width: 42, height: 42, decoration: BoxDecoration( shape: BoxShape.circle, gradient: disabled ? null : const LinearGradient(colors: [Color(0xFFC8956E), Color(0xFFF5D59E)]), color: disabled ? const Color(0xFFE8D9C5) : null, ), child: Icon( disabled ? Icons.hourglass_empty : Icons.arrow_upward, size: 20, color: disabled ? const Color(0xFFBCAAA4) : const Color(0xFF5D4037), ), ), ); } void _send(String text) { final trimmed = text.trim(); if (trimmed.isEmpty) return; _input.clear(); ref.read(assistantProvider.notifier).send(trimmed); } // ---------------------------------------------------------------- 历史会话 Future _showSessions(BuildContext context) async { final selected = await showModalBottomSheet( context: context, backgroundColor: Colors.white, isScrollControlled: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (_) => const _SessionSheet(), ); if (selected == null || !mounted) return; // 把人设一并带进去 —— 历史对话要用它当时绑的那个, // 不能因为你现在换了默认人设就跟着变 ref .read(assistantProvider.notifier) .openSession(selected.id, personaId: selected.personaId); } // ---------------------------------------------------------------- 滚动 /// 只在用户本来就贴着底部时才自动滚动 —— 生成过程中用户往上翻看历史时别抢 void _scheduleScroll() { WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted || !_scroll.hasClients) return; final pos = _scroll.position; if (pos.maxScrollExtent - pos.pixels > 140) return; _scroll.jumpTo(pos.maxScrollExtent); }); } } /// 生成中的三个点 class _TypingDots extends StatelessWidget { const _TypingDots(); @override Widget build(BuildContext context) { return const Text( '●●●', style: TextStyle(fontSize: 7, letterSpacing: 2, color: Color(0xFFC8956E)), ); } } /// 新对话时选人设的结果 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(preferredPersonaProvider); 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(); @override Widget build(BuildContext context, WidgetRef ref) { final sessionsAsync = ref.watch(chatSessionsProvider); 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: 12), Flexible( child: sessionsAsync.when( loading: () => const LoadingView(tip: '加载中…'), error: (e, _) => ErrorView( message: '会话列表加载失败', onRetry: () => ref.invalidate(chatSessionsProvider), ), data: (sessions) { if (sessions.isEmpty) { return const EmptyView(title: '还没有对话', tip: '问一句就有了'); } return ListView.builder( shrinkWrap: true, itemCount: sessions.length, itemBuilder: (context, index) { 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, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14.5, color: Color(0xFF5D4037)), ), subtitle: Text( '${s.personaName} · ${s.updateTime}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4)), ), trailing: IconButton( tooltip: '删除', icon: const Icon(Icons.delete_outline, size: 19), color: const Color(0xFFBCAAA4), onPressed: () => _confirmDelete(context, ref, s), ), onTap: () => Navigator.pop(context, s), ); }, ); }, ), ), ], ), ), ); } Future _confirmDelete(BuildContext context, WidgetRef ref, ChatSessionItem session) async { final ok = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: const Text('删除对话'), content: Text('「${session.title}」将被删除,不能再恢复。'), actions: [ TextButton( onPressed: () => Navigator.pop(dialogContext, false), child: const Text('取消'), ), TextButton( onPressed: () => Navigator.pop(dialogContext, true), child: const Text('删除', style: TextStyle(color: Color(0xFFD32F2F))), ), ], ), ); if (ok != true) return; try { await ref.read(aiApiProvider).deleteSession(session.id); ref.invalidate(chatSessionsProvider); // 删掉的正好是当前打开的会话,就退回新对话 final current = ref.read(assistantProvider).sessionId; if (current == session.id) { ref.read(assistantProvider.notifier).newChat(); } } catch (e) { if (context.mounted) showToast(context, '删除失败', error: true); } } }