Files

292 lines
10 KiB
Dart
Raw Permalink 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 '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;
/// 当前这次对话锁定的**人设 id**。null = 内置默认人设。
///
/// 这是「一次对话从头到尾是同一个人」的实现点:
/// · 新建会话时定下来(用户在选人设弹层里选的,或默认用全局那个)
/// · 打开历史会话时从会话记录里读出来
/// · 之后每次 send 都用它,**不再看全局的 selectedAiPersonaProvider** ——
/// 否则用户在设置页切一下人设,正在聊的这轮就换性格了
final int? personaId;
const AssistantState({
this.sessionId,
this.bubbles = const [],
this.streaming = false,
this.activeTool,
this.error,
this.loadingHistory = false,
this.personaId,
});
/// 真的一句都没有(不是加载中、也不是出错)
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,
Object? personaId = _sentinel,
}) {
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,
personaId: personaId == _sentinel ? this.personaId : personaId as int?,
);
}
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, personaId: sessions.first.personaId);
} catch (_) {
// 拉不到就算了,用户还能从「历史对话」里自己找
}
}
/// 新对话。
///
/// [personaId] 是这次对话要用的人设,**传了就锁定**,之后这轮不会再变。
/// 传 null 表示用内置默认人设。
///
/// 注意这里**不传全局的 selectedAiPersonaProvider** —— 那是「默认选哪套」的偏好,
/// 由调用方(页面上的选人设弹层)决定要不要用它兜底。
void newChat({int? personaId}) {
_autoRestored = true;
state = AssistantState(personaId: personaId);
}
void clearError() {
state = state.copyWith(error: null);
}
/// 打开历史会话
///
/// 人设从**会话记录**里读,而不是用全局偏好 —— 三个月前那次对话是谁聊的,
/// 现在打开还得是谁,不能因为你现在改了默认人设就变。
Future<void> openSession(int sessionId, {int? personaId}) async {
state = AssistantState(
sessionId: sessionId,
personaId: personaId,
loadingHistory: true,
);
try {
final items = await ref.read(aiApiProvider).messages(sessionId);
if (!ref.mounted) return;
state = AssistantState(
sessionId: sessionId,
personaId: personaId,
bubbles: items.map(ChatBubble.fromItem).toList(),
);
} catch (e) {
if (!ref.mounted) return;
state = AssistantState(
sessionId: sessionId,
personaId: personaId,
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 {
// 模型还是全局偏好(换模型是想马上生效的)
final configId = ref.read(selectedAiConfigProvider);
// 人设**不用全局偏好,用这次对话锁定的那个**。
//
// 这是「一个会话里始终是同一个人设」的关键:state.personaId 在新建会话
// 或打开历史会话时就定了,中途用户在设置页怎么换都不影响正在聊的这轮。
// 想换人设得开新会话 —— 这正是需求要的语义。
final personaId = state.personaId;
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);