import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/ai_api.dart'; import '../core/network/api_exception.dart'; import '../models/ai.dart'; import 'ai_config_provider.dart'; import 'bill_refresh.dart'; import 'note_provider.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(); } /// 会让服务端数据发生变化的工具。**名字要和后端 AiToolService 保持一致** —— /// 改名了这里就会静默失效(不报错,只是数据不刷新)。 const _mutatingTools = { 'create_bill': _Mutated.bill, 'create_note': _Mutated.note, }; enum _Mutated { bill, note } class AssistantNotifier extends Notifier { /// 本次 App 运行期间是否已经自动恢复过。 /// 用它拦住「用户主动开了新对话,切个 Tab 回来旧对话又被拉回来」。 bool _autoRestored = false; @override AssistantState build() => const AssistantState(); /// 打开助手页时调用:界面还是空的就把最近一次对话展开。 /// /// 为什么放在页面里而不是 provider 的 build():build() 里不能同步改 state, /// 而这个恢复动作是异步的、而且只该在这个页面被打开时发生。 Future restoreLastSessionIfNeeded() async { if (_autoRestored) return; _autoRestored = true; if (state.sessionId != null || state.bubbles.isNotEmpty) return; try { // 会话列表本来就是按最近活跃倒序,第一条就是「上一次的对话」 final sessions = await ref.read(aiApiProvider).sessions(); if (!ref.mounted || sessions.isEmpty) return; await openSession(sessions.first.id); } catch (_) { // 拉不到就算了,用户还能从「历史对话」里自己找 } } /// 新对话:清空界面。后端那条旧会话还在,能从历史里翻出来。 void newChat() { _autoRestored = true; 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; // 这次对话动过哪些数据 final mutated = <_Mutated>{}; try { // 带上用户选中的配置。null = 用服务端 .env 那套(虚拟的「默认」项) final configId = ref.read(selectedAiConfigProvider); await for (final event in ref .read(aiApiProvider) .chat(sessionId: state.sessionId, configId: configId, content: trimmed)) { if (!ref.mounted) return; switch (event.type) { case AiEventType.delta: _appendDelta(event.content ?? ''); case AiEventType.tool: final what = _mutatingTools[event.toolName]; if (what != null) mutated.add(what); 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); _refreshMutatedData(mutated); } /// 让被 AI 改过的数据在 App 里失效重取。 /// /// 为什么必须显式做:AI 是在**服务端直接写库**的,App 毫不知情。 /// 而这些数据的 provider 被常驻页面(首页仪表盘的模块卡片、「我的」页) /// 一直监听着,永远不会被 autoDispose 回收 —— 不失效就是旧数。 /// 表现会很迷惑:账单列表反而刷新了(它是 push 出来的页面,provider 被回收过), /// 于是看起来"列表对了、汇总和预算环没对"。 void _refreshMutatedData(Set<_Mutated> mutated) { if (mutated.contains(_Mutated.bill)) { invalidateBillData(ref); } if (mutated.contains(_Mutated.note)) { // 四个都要失效:列表是 keepAlive 的 Notifier;概览被首页的笔记卡片常驻监听; // 标签和目录计数会随新建的笔记一起变 ref.invalidate(noteListProvider); ref.invalidate(noteOverviewProvider); ref.invalidate(noteTagsProvider); ref.invalidate(noteFolderTreeProvider); } } 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);