From 34bc2e04b077c309ee94da3710a3381558dbe87d Mon Sep 17 00:00:00 2001 From: handsomeAq Date: Wed, 23 Sep 2026 17:23:59 +0800 Subject: [PATCH] =?UTF-8?q?AI=20=E6=8E=A5=E5=85=A5=20V0.1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .codebuddy/memory/MEMORY.md | 8 + lib/api/ai_api.dart | 147 ++++++ lib/models/ai.dart | 111 +++++ lib/models/json_util.dart | 7 + lib/models/note.dart | 7 - lib/pages/assistant/assistant_page.dart | 621 ++++++++++++++++++++---- lib/pages/home/dashboard_page.dart | 12 +- lib/providers/ai_provider.dart | 189 ++++++++ test/page_layout_test.dart | 32 ++ 9 files changed, 1018 insertions(+), 116 deletions(-) create mode 100644 lib/api/ai_api.dart create mode 100644 lib/models/ai.dart create mode 100644 lib/providers/ai_provider.dart diff --git a/.codebuddy/memory/MEMORY.md b/.codebuddy/memory/MEMORY.md index 9226b98..b0a0c5f 100644 --- a/.codebuddy/memory/MEMORY.md +++ b/.codebuddy/memory/MEMORY.md @@ -29,6 +29,14 @@ - 统计**不再是顶层 Tab**,从记账首页右上角图标进(`BillStatisticsPage`)。 - `pages/bill/bill_home_page.dart` 和 `bill_statistics_page.dart` 都是被 push 进来的,所以顶部自绘了返回箭头,底部留白也按 `Navigator.canPop()` 判断(有导航栏时留 90,没有时留 24)。 +### AI 助手(2026-09-23 阶段 B) +- 链路:`lib/api/ai_api.dart`(SSE 客户端)→ `lib/providers/ai_provider.dart`(`assistantProvider` + `chatSessionsProvider` + `aiStatusProvider`)→ `lib/pages/assistant/assistant_page.dart`(真对话页)。 +- **SSE 是手动解析的**:dio 没有内置 SSE 支持,用 `ResponseType.stream` 拿字节流,再 `cast>().transform(utf8.decoder).transform(LineSplitter())` 按 `event:` / `data:` / 空行切。`cast` 不能省 —— dio 给的是 `Stream`,Dart 泛型不允许它直接喂给 `StreamTransformer, String>`。 +- **`sessionId` 传 null 开新会话**,后端在 `done` 事件里才把新 ID 返回,`AssistantNotifier.send` 必须把它记下来,否则下一条消息又会开一个新会话。 +- 历史会话存后端(`chat_session` / `chat_message` 两张表),App 只发 `sessionId + content`。**工具调用的中间过程不入库**,所以 App 不用理解 tool 消息格式。 +- 助手气泡用 `MarkdownBody` 渲染(回答里常有列表/代码块),用户气泡用 `SelectableText`。 +- 后端未配 key 时不报错:`/api/ai/status` 返回 `enabled:false`,`/chat` 推一个 error 事件,页面顶部显示红色提示条。 + ## 设计规范(配色) - 品牌主色:`#C8956E`(驼色/焦糖色),在 `app.dart` 中作为 `ColorScheme.fromSeed` 的 seedColor。 - 深棕文字色:`#5D4037`;辅助文字:`#8D6E63`;页面底色:`#F8F8F8`。 diff --git a/lib/api/ai_api.dart b/lib/api/ai_api.dart new file mode 100644 index 0000000..a574ada --- /dev/null +++ b/lib/api/ai_api.dart @@ -0,0 +1,147 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/network/api_exception.dart'; +import '../core/network/dio_client.dart'; +import '../models/ai.dart'; +import '../models/json_util.dart'; + +final aiApiProvider = Provider((ref) => AiApi(ref.watch(dioProvider))); + +/// AI 助手接口 +class AiApi { + final Dio _dio; + + AiApi(this._dio); + + /// 后端是否配好了 key(没配的话 App 显示提示而不是让用户白问一句) + Future status() async { + try { + final res = await _dio.get('/ai/status'); + final data = res.data; + return data is Map && data['enabled'] == true; + } on DioException { + return false; + } + } + + Future> sessions() async { + try { + final res = await _dio.get('/ai/sessions'); + final data = res.data; + if (data is! List) return const []; + return asMapList(data).map(ChatSessionItem.fromJson).toList(); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '会话列表加载失败'); + } + } + + Future> messages(int sessionId) async { + try { + final res = await _dio.get('/ai/sessions/$sessionId/messages'); + final data = res.data; + if (data is! List) return const []; + return asMapList(data).map(ChatMessageItem.fromJson).toList(); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '消息加载失败'); + } + } + + Future deleteSession(int sessionId) async { + try { + await _dio.delete('/ai/sessions/$sessionId'); + } on DioException catch (e) { + final err = e.error; + throw err is ApiException ? err : ApiException(-1, '删除会话失败'); + } + } + + /// 发起对话,返回事件流。 + /// + /// 用 dio 的 [ResponseType.stream] 拿原始字节流、自己按 SSE 协议切分 —— + /// dio 没有内置 SSE 支持,但 OpenAI 兼容接口的流格式很简单 + /// (`event: xxx` / `data: {...}` / 空行分隔),手动解析比多引一个依赖可控。 + /// + /// 注意:[sessionId] 传 null 表示开新会话,会话 ID 会由后端在 done 事件里返回。 + Stream chat({int? sessionId, required String content}) async* { + final Response res; + try { + res = await _dio.post( + '/ai/chat', + data: {'sessionId': sessionId, 'content': content}, + options: Options( + responseType: ResponseType.stream, + // LLM 生成慢 + 工具循环可能跑好几轮,给足;后端 SseEmitter 是 180s + receiveTimeout: const Duration(minutes: 3), + headers: {'Accept': 'text/event-stream'}, + ), + ); + } on DioException catch (e) { + final err = e.error; + yield AiEvent.error(err is ApiException ? err.message : 'AI 请求失败'); + return; + } + + final body = res.data; + if (body == null) { + yield const AiEvent.error('AI 没有返回内容'); + return; + } + + try { + // utf8.decoder 作为流转换器能正确处理「多字节字符被 chunk 切断」的情况, + // 直接对每块调 utf8.decode 会在这里出乱码。 + // cast 是必须的:dio 给的是 Stream,而 utf8.decoder 是 + // StreamTransformer, String>,Dart 的泛型不支持这个方向的隐式转换。 + final lines = body.stream + .cast>() + .transform(utf8.decoder) + .transform(const LineSplitter()); + + var event = ''; + final data = StringBuffer(); + + await for (final line in lines) { + if (line.isEmpty) { + // 空行 = 一个事件结束 + final payload = data.toString(); + data.clear(); + if (payload.isNotEmpty) { + final parsed = _parseEvent(event, payload); + if (parsed != null) yield parsed; + } + event = ''; + continue; + } + if (line.startsWith('event:')) { + event = line.substring(6).trim(); + } else if (line.startsWith('data:')) { + data.write(line.substring(5).trim()); + } + } + } on DioException catch (e) { + final err = e.error; + yield AiEvent.error(err is ApiException ? err.message : 'AI 连接中断'); + } catch (_) { + yield const AiEvent.error('AI 连接中断'); + } + } + + AiEvent? _parseEvent(String event, String payload) { + final decoded = jsonDecode(payload); + if (decoded is! Map) return null; + + return switch (event) { + 'delta' => AiEvent.delta(asString(decoded['content'])), + 'tool' => AiEvent.tool(asString(decoded['name'], fallback: '查询')), + 'done' => AiEvent.done(asNullableInt(decoded['sessionId'])), + 'error' => AiEvent.error(asString(decoded['message'], fallback: 'AI 服务出错')), + // 未知事件直接忽略,后端以后加新事件类型时老版本 App 不会崩 + _ => null, + }; + } +} diff --git a/lib/models/ai.dart b/lib/models/ai.dart new file mode 100644 index 0000000..8357d39 --- /dev/null +++ b/lib/models/ai.dart @@ -0,0 +1,111 @@ +import 'json_util.dart'; + +/// 会话列表项(不含消息) +class ChatSessionItem { + final int id; + final String title; + final String updateTime; + + const ChatSessionItem({ + required this.id, + required this.title, + required this.updateTime, + }); + + factory ChatSessionItem.fromJson(Map json) { + return ChatSessionItem( + id: asInt(json['id']), + title: asNonEmpty(json['title'], '新对话'), + updateTime: formatDateTime(json['updateTime']), + ); + } +} + +/// 会话里的一条消息。工具调用的中间过程后端不入库,所以只有 user / assistant +class ChatMessageItem { + final int id; + final String role; + final String content; + + const ChatMessageItem({ + required this.id, + required this.role, + required this.content, + }); + + bool get isUser => role == 'user'; + + factory ChatMessageItem.fromJson(Map json) { + return ChatMessageItem( + id: asInt(json['id']), + role: asString(json['role'], fallback: 'assistant'), + content: asString(json['content']), + ); + } +} + +/// 页面上的一个气泡。 +/// +/// 和 [ChatMessageItem] 的区别:这个没有 id、带 streaming 标记, +/// 因为正在生成的那条回答还没落库。 +class ChatBubble { + final String role; + final String content; + + /// 正在生成中(末尾会显示光标 / 工具提示) + final bool streaming; + + const ChatBubble({ + required this.role, + required this.content, + this.streaming = false, + }); + + const ChatBubble.user(String content) : this(role: 'user', content: content); + + const ChatBubble.assistant(String content, {bool streaming = false}) + : this(role: 'assistant', content: content, streaming: streaming); + + bool get isUser => role == 'user'; + + ChatBubble copyWith({String? content, bool? streaming}) { + return ChatBubble( + role: role, + content: content ?? this.content, + streaming: streaming ?? this.streaming, + ); + } + + factory ChatBubble.fromItem(ChatMessageItem item) => + ChatBubble(role: item.role, content: item.content); +} + +/// SSE 事件类型 +enum AiEventType { delta, tool, done, error } + +/// 一次 SSE 事件。后端只会发这四种,见 AiChatService。 +class AiEvent { + final AiEventType type; + + /// delta:本次新增的文本 + final String? content; + + /// tool:正在调用的工具名 + final String? toolName; + + /// done:会话 ID(新会话时后端才刚创建,App 要靠它接着聊) + final int? sessionId; + + /// error:错误提示 + final String? message; + + const AiEvent._(this.type, {this.content, this.toolName, this.sessionId, this.message}); + + const AiEvent.delta(String content) : this._(AiEventType.delta, content: content); + + const AiEvent.tool(String name) : this._(AiEventType.tool, toolName: name); + + const AiEvent.done(int? sessionId) : this._(AiEventType.done, sessionId: sessionId); + + const AiEvent.error(String message) : this._(AiEventType.error, message: message); +} diff --git a/lib/models/json_util.dart b/lib/models/json_util.dart index dedd714..3fb7f81 100644 --- a/lib/models/json_util.dart +++ b/lib/models/json_util.dart @@ -55,3 +55,10 @@ List asStringList(Object? value) { .where((e) => e.isNotEmpty) .toList(); } + +/// 后端给的是 `yyyy-MM-dd HH:mm:ss`,这里裁成 `yyyy-MM-dd HH:mm`。 +/// 列表里显示到秒没有意义,还会把卡片撑宽。 +String formatDateTime(Object? value) { + final text = asString(value); + return text.length >= 16 ? text.substring(0, 16) : text; +} diff --git a/lib/models/note.dart b/lib/models/note.dart index 80aa460..089b624 100644 --- a/lib/models/note.dart +++ b/lib/models/note.dart @@ -191,10 +191,3 @@ class NoteLocation { } } } - -/// 后端给的是 `yyyy-MM-dd HH:mm:ss`,这里裁成 `yyyy-MM-dd HH:mm` -/// 列表里显示到秒没有意义,还会把卡片撑宽。 -String formatDateTime(Object? value) { - final text = asString(value); - return text.length >= 16 ? text.substring(0, 16) : text; -} diff --git a/lib/pages/assistant/assistant_page.dart b/lib/pages/assistant/assistant_page.dart index 2b3d616..4f7c48a 100644 --- a/lib/pages/assistant/assistant_page.dart +++ b/lib/pages/assistant/assistant_page.dart @@ -1,14 +1,49 @@ 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 '../../utils/toast.dart'; +import '../../widgets/state_views.dart'; + +/// 助手:和 AI 对话,问账单、问笔记。 /// -/// 现在是骨架里预留的位置 —— 先让布局一次到位,阶段 B 接 AI 时只填内容、 -/// 不再动导航结构。同时它也是首页那个输入框的落地页。 -class AssistantPage extends StatelessWidget { +/// 既是底部 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 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, @@ -24,36 +59,9 @@ class AssistantPage extends StatelessWidget { child: Column( children: [ _header(context), - Expanded( - child: ListView( - physics: const AlwaysScrollableScrollPhysics(), - padding: const EdgeInsets.fromLTRB(24, 40, 24, 0), - children: [ - const Center(child: Text('✨', style: TextStyle(fontSize: 46))), - const SizedBox(height: 16), - const Center( - child: Text( - 'AI 助手正在建设中', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: Color(0xFF5D4037), - ), - ), - ), - const SizedBox(height: 8), - const Center( - child: Text( - '位置已经预留好,接入后不用再改布局', - style: TextStyle(fontSize: 12.5, color: Color(0xFF8D6E63)), - ), - ), - const SizedBox(height: 28), - _planCard(), - const SizedBox(height: 90), - ], - ), - ), + Expanded(child: _body(context, state)), + if (state.error != null) _errorBar(state.error!), + _inputBar(state), ], ), ), @@ -61,20 +69,18 @@ class AssistantPage extends StatelessWidget { ); } - /// 用 Tab 打开时没有上一页,就不显示返回箭头 + // ---------------------------------------------------------------- 顶部 + Widget _header(BuildContext context) { return Padding( - padding: const EdgeInsets.fromLTRB(10, 6, 16, 6), + padding: const EdgeInsets.fromLTRB(10, 6, 8, 6), child: Row( children: [ if (Navigator.of(context).canPop()) - InkWell( + _iconButton( + icon: Icons.arrow_back, + tooltip: '返回', onTap: () => Navigator.of(context).pop(), - borderRadius: BorderRadius.circular(20), - child: const Padding( - padding: EdgeInsets.all(6), - child: Icon(Icons.arrow_back, size: 22, color: Color(0xFF5D4037)), - ), ), const Padding( padding: EdgeInsets.only(left: 6), @@ -87,79 +93,498 @@ class AssistantPage extends StatelessWidget { ), ), ), + const Spacer(), + _iconButton( + icon: Icons.history, + tooltip: '历史对话', + onTap: () => _showSessions(context), + ), + _iconButton( + icon: Icons.add_comment_outlined, + tooltip: '新对话', + onTap: () => ref.read(assistantProvider.notifier).newChat(), + ), ], ), ); } - Widget _planCard() { - const items = [ - ('一句话记账', '「昨天买咖啡 32」→ 自动填好分类和金额'), - ('笔记问答', '从你的知识库里找答案,而不是重读一遍'), - ('服务器状态', '查状态、看日志 —— 只走预定义动作,不给任意 shell'), - ('跨模块查询', '「上周服务器相关的花费」这类要跨模块的问题'), - ]; - - return Container( - padding: const EdgeInsets.fromLTRB(18, 16, 18, 8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), + 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)), + ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '规划中的能力', + ); + } + + // ---------------------------------------------------------------- 主体 + + 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: Text('✨', style: TextStyle(fontSize: 44))), + const SizedBox(height: 14), + const Center( + child: Text( + '问我点什么', style: TextStyle( - fontSize: 13, + fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF5D4037), ), ), - const SizedBox(height: 12), - for (final (title, desc) in items) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.only(top: 5), - child: Icon(Icons.circle, size: 5, color: Color(0xFFC8956E)), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 13.5, - fontWeight: FontWeight.w600, - color: Color(0xFF5D4037), - ), - ), - const SizedBox(height: 2), - Text( - desc, - style: const TextStyle(fontSize: 12, color: Color(0xFF8D6E63)), - ), - ], + ), + 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)), + ], + ), + ), + ); + } + + // ---------------------------------------------------------------- 气泡 + + Widget _bubble(BuildContext context, ChatBubble bubble) { + if (bubble.isUser) { + return Align( + alignment: Alignment.centerRight, + child: Container( + margin: const EdgeInsets.only(top: 10, left: 48), + 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), + ), + ), + ); + } + + return Align( + alignment: Alignment.centerLeft, + child: Container( + margin: const EdgeInsets.only(top: 10, right: 24), + 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())), ), ), - ], - ), - const SizedBox(height: 14), + ), + if (bubble.streaming && bubble.content.isNotEmpty) ...[ + const SizedBox(height: 6), + const _TypingDots(), + ], ], - const Divider(height: 1, color: Color(0xFFF0E6D8)), - const SizedBox(height: 10), - const Text( - '隐私:涉及你笔记里的服务器信息时,会在请求发出前先脱敏。', - style: TextStyle(fontSize: 11.5, color: Color(0xFFBCAAA4), height: 1.5), + ), + ), + ); + } + + 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)), ), - const SizedBox(height: 8), ], ), ); } + + 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); + } + + // ---------------------------------------------------------------- 滚动 + + /// 只在用户本来就贴着底部时才自动滚动 —— 生成过程中用户往上翻看历史时别抢 + 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 _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), + title: Text( + s.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 14.5, color: Color(0xFF5D4037)), + ), + subtitle: Text( + s.updateTime, + 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.id), + ); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } + + 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); + } + } } diff --git a/lib/pages/home/dashboard_page.dart b/lib/pages/home/dashboard_page.dart index 2d1f9c0..f96638d 100644 --- a/lib/pages/home/dashboard_page.dart +++ b/lib/pages/home/dashboard_page.dart @@ -114,17 +114,7 @@ class DashboardPage extends StatelessWidget { style: TextStyle(fontSize: 14, color: Color(0xFFBCAAA4)), ), ), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0xFFF5EFE6), - borderRadius: BorderRadius.circular(8), - ), - child: const Text( - '建设中', - style: TextStyle(fontSize: 11, color: Color(0xFF8D6E63)), - ), - ), + const Icon(Icons.chevron_right, size: 20, color: Color(0xFFBCAAA4)), ], ), ), diff --git a/lib/providers/ai_provider.dart b/lib/providers/ai_provider.dart new file mode 100644 index 0000000..0f2dcaf --- /dev/null +++ b/lib/providers/ai_provider.dart @@ -0,0 +1,189 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../api/ai_api.dart'; +import '../core/network/api_exception.dart'; +import '../models/ai.dart'; + +/// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句) +final aiStatusProvider = FutureProvider.autoDispose((ref) { + return ref.watch(aiApiProvider).status(); +}); + +/// 会话列表 +final chatSessionsProvider = FutureProvider.autoDispose>((ref) { + return ref.watch(aiApiProvider).sessions(); +}); + +/// 助手页状态 +class AssistantState { + /// null = 新对话(还没落库,第一条消息发出去时后端才建) + final int? sessionId; + + final List bubbles; + + /// 正在生成回答 + final bool streaming; + + /// 正在调用哪个工具(null = 没在查东西) + final String? activeTool; + + final String? error; + + /// 正在拉历史消息 + final bool loadingHistory; + + const AssistantState({ + this.sessionId, + this.bubbles = const [], + this.streaming = false, + this.activeTool, + this.error, + this.loadingHistory = false, + }); + + /// 真的一句都没有(不是加载中、也不是出错) + bool get isEmpty => bubbles.isEmpty && !loadingHistory && error == null; + + AssistantState copyWith({ + Object? sessionId = _sentinel, + List? bubbles, + bool? streaming, + Object? activeTool = _sentinel, + Object? error = _sentinel, + bool? loadingHistory, + }) { + return AssistantState( + sessionId: sessionId == _sentinel ? this.sessionId : sessionId as int?, + bubbles: bubbles ?? this.bubbles, + streaming: streaming ?? this.streaming, + activeTool: activeTool == _sentinel ? this.activeTool : activeTool as String?, + error: error == _sentinel ? this.error : error as String?, + loadingHistory: loadingHistory ?? this.loadingHistory, + ); + } + + static const _sentinel = Object(); +} + +class AssistantNotifier extends Notifier { + @override + AssistantState build() => const AssistantState(); + + /// 新对话:清空界面,但**不清 sessionId 为 null 之前的会话**(后端那边还在,能从历史里翻出来) + void newChat() { + state = const AssistantState(); + } + + void clearError() { + state = state.copyWith(error: null); + } + + /// 打开历史会话 + Future openSession(int sessionId) async { + state = AssistantState(sessionId: sessionId, loadingHistory: true); + try { + final items = await ref.read(aiApiProvider).messages(sessionId); + if (!ref.mounted) return; + state = AssistantState( + sessionId: sessionId, + bubbles: items.map(ChatBubble.fromItem).toList(), + ); + } catch (e) { + if (!ref.mounted) return; + state = AssistantState( + sessionId: sessionId, + error: e is ApiException ? e.message : '消息加载失败', + ); + } + } + + /// 发一条消息并接收流式回答 + Future send(String text) async { + final trimmed = text.trim(); + if (trimmed.isEmpty || state.streaming) return; + + state = state.copyWith( + // 先乐观地把用户气泡和「空的、正在生成」的回答气泡放上去 + bubbles: [ + ...state.bubbles, + ChatBubble.user(trimmed), + const ChatBubble.assistant('', streaming: true), + ], + streaming: true, + error: null, + activeTool: null, + ); + + var failed = false; + // 收到 done 说明回答已经完整了,之后连接怎么断都不该算失败 —— + // SSE 的收尾阶段出错(服务端异步派发、代理截断)很常见, + // 弹个红条出来只会让人以为回答没拿到。 + var sawDone = false; + try { + await for (final event in ref + .read(aiApiProvider) + .chat(sessionId: state.sessionId, content: trimmed)) { + if (!ref.mounted) return; + + switch (event.type) { + case AiEventType.delta: + _appendDelta(event.content ?? ''); + case AiEventType.tool: + state = state.copyWith(activeTool: event.toolName); + case AiEventType.done: + // 新会话时后端在这一刻才把 ID 告诉我们,必须记下来, + // 否则下一条消息又会开一个新会话 + sawDone = true; + state = state.copyWith(sessionId: event.sessionId); + case AiEventType.error: + if (!sawDone) { + failed = true; + state = state.copyWith(error: event.message); + } + } + } + } catch (e) { + if (!sawDone) { + failed = true; + state = state.copyWith(error: e is ApiException ? e.message : 'AI 连接中断'); + } + } + + _finish(failed: failed); + } + + void _appendDelta(String delta) { + if (delta.isEmpty) return; + final bubbles = [...state.bubbles]; + if (bubbles.isEmpty) return; + + final last = bubbles.last; + bubbles[bubbles.length - 1] = last.copyWith(content: last.content + delta); + // 有文字出来了,说明工具已经查完 + state = state.copyWith(bubbles: bubbles, activeTool: null); + } + + void _finish({required bool failed}) { + var bubbles = [...state.bubbles]; + + if (failed) { + // 一条字都没生成出来时,把那个空气泡去掉,只留错误提示 + if (bubbles.isNotEmpty && !bubbles.last.isUser && bubbles.last.content.isEmpty) { + bubbles.removeLast(); + } + } else if (bubbles.isNotEmpty && bubbles.last.streaming) { + bubbles[bubbles.length - 1] = bubbles.last.copyWith(streaming: false); + } + + state = state.copyWith(bubbles: bubbles, streaming: false, activeTool: null); + + if (!failed) { + // 新会话要出现在列表里、老会话的标题/时间要更新 + ref.invalidate(chatSessionsProvider); + } + } +} + +/// 不加 autoDispose:切到别的 Tab 再回来,对话还在 +final assistantProvider = + NotifierProvider(AssistantNotifier.new); diff --git a/test/page_layout_test.dart b/test/page_layout_test.dart index d891222..edd4e8e 100644 --- a/test/page_layout_test.dart +++ b/test/page_layout_test.dart @@ -5,6 +5,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:qiangbao_accounting/models/bill.dart'; import 'package:qiangbao_accounting/models/note.dart'; import 'package:qiangbao_accounting/models/statistics_response.dart'; +import 'package:qiangbao_accounting/pages/assistant/assistant_page.dart'; import 'package:qiangbao_accounting/pages/bill/bill_home_page.dart'; import 'package:qiangbao_accounting/pages/home/dashboard_page.dart'; import 'package:qiangbao_accounting/providers/bill_provider.dart'; @@ -102,5 +103,36 @@ void main() { expect(find.text('我的账单'), findsOneWidget); expect(tester.takeException(), isNull); }); + + testWidgets('助手页(push 进来)$label 不溢出', (tester) async { + useSize(tester, size); + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + home: Builder( + builder: (ctx) => Scaffold( + body: Center( + child: TextButton( + onPressed: () => Navigator.of(ctx).push( + MaterialPageRoute(builder: (_) => const AssistantPage()), + ), + child: const Text('进入'), + ), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('进入')); + await tester.pumpAndSettle(); + + // 空状态:标题 + 引导问题 + 底部输入框 + expect(find.text('问我点什么'), findsOneWidget); + expect(find.text('这个月花了多少钱?'), findsOneWidget); + + expect(tester.takeException(), isNull); + }); } }