AI 接入 V1.0

UI更新
This commit is contained in:
2026-09-24 14:24:34 +08:00
parent 34bc2e04b0
commit 9a144f159f
65 changed files with 1195 additions and 196 deletions
+62 -1
View File
@@ -3,6 +3,8 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/ai_api.dart';
import '../core/network/api_exception.dart';
import '../models/ai.dart';
import 'bill_refresh.dart';
import 'note_provider.dart';
/// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句)
final aiStatusProvider = FutureProvider.autoDispose<bool>((ref) {
@@ -65,12 +67,45 @@ class AssistantState {
static const _sentinel = Object();
}
/// 会让服务端数据发生变化的工具。**名字要和后端 AiToolService 保持一致** ——
/// 改名了这里就会静默失效(不报错,只是数据不刷新)。
const _mutatingTools = {
'create_bill': _Mutated.bill,
'create_note': _Mutated.note,
};
enum _Mutated { bill, note }
class AssistantNotifier extends Notifier<AssistantState> {
/// 本次 App 运行期间是否已经自动恢复过。
/// 用它拦住「用户主动开了新对话,切个 Tab 回来旧对话又被拉回来」。
bool _autoRestored = false;
@override
AssistantState build() => const AssistantState();
/// 新对话:清空界面,但**不清 sessionId 为 null 之前的会话**(后端那边还在,能从历史里翻出来)
/// 打开助手页时调用:界面还是空的就把最近一次对话展开。
///
/// 为什么放在页面里而不是 provider 的 build():build() 里不能同步改 state,
/// 而这个恢复动作是异步的、而且只该在这个页面被打开时发生。
Future<void> 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();
}
@@ -119,6 +154,8 @@ class AssistantNotifier extends Notifier<AssistantState> {
// SSE 的收尾阶段出错(服务端异步派发、代理截断)很常见,
// 弹个红条出来只会让人以为回答没拿到。
var sawDone = false;
// 这次对话动过哪些数据
final mutated = <_Mutated>{};
try {
await for (final event in ref
.read(aiApiProvider)
@@ -129,6 +166,8 @@ class AssistantNotifier extends Notifier<AssistantState> {
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 告诉我们,必须记下来,
@@ -150,6 +189,28 @@ class AssistantNotifier extends Notifier<AssistantState> {
}
_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) {