190 lines
5.8 KiB
Dart
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);
|