Files
Aq-Accounting-Flutter/lib/providers/ai_provider.dart
T
2026-09-28 17:16:34 +08:00

260 lines
8.9 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 'ai_persona_provider.dart';
import 'bill_refresh.dart';
import 'note_provider.dart';
/// 后端 AI 是否配好了(没配 key 时 App 显示提示,而不是让用户白问一句)
final aiStatusProvider = FutureProvider.autoDispose<bool>((ref) {
return ref.watch(aiApiProvider).status();
});
/// 会话列表
final chatSessionsProvider = FutureProvider.autoDispose<List<ChatSessionItem>>((ref) {
return ref.watch(aiApiProvider).sessions();
});
/// 助手页状态
class AssistantState {
/// null = 新对话(还没落库,第一条消息发出去时后端才建)
final int? sessionId;
final List<ChatBubble> 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<ChatBubble>? 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<AssistantState> {
/// 本次 App 运行期间是否已经自动恢复过。
/// 用它拦住「用户主动开了新对话,切个 Tab 回来旧对话又被拉回来」。
bool _autoRestored = false;
@override
AssistantState build() => const AssistantState();
/// 打开助手页时调用:界面还是空的就把最近一次对话展开。
///
/// 为什么放在页面里而不是 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();
}
void clearError() {
state = state.copyWith(error: null);
}
/// 打开历史会话
Future<void> 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<void> 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);
// 人设是独立的另一套。null = 用服务端内置的默认人设
final personaId = ref.read(selectedAiPersonaProvider);
await for (final event in ref.read(aiApiProvider).chat(
sessionId: state.sessionId,
configId: configId,
personaId: personaId,
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, AssistantState>(AssistantNotifier.new);