292 lines
10 KiB
Dart
292 lines
10 KiB
Dart
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);
|