Files
Aq-Accounting-Flutter/lib/providers/ai_provider.dart
T
2026-09-23 17:23:59 +08:00

190 lines
5.8 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../api/ai_api.dart';
import '../core/network/api_exception.dart';
import '../models/ai.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();
}
class AssistantNotifier extends Notifier<AssistantState> {
@override
AssistantState build() => const AssistantState();
/// 新对话:清空界面,但**不清 sessionId 为 null 之前的会话**(后端那边还在,能从历史里翻出来)
void newChat() {
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;
try {
await for (final event in ref
.read(aiApiProvider)
.chat(sessionId: state.sessionId, content: trimmed)) {
if (!ref.mounted) return;
switch (event.type) {
case AiEventType.delta:
_appendDelta(event.content ?? '');
case AiEventType.tool:
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);
}
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);