AI 接入 V0.1
This commit is contained in:
@@ -0,0 +1,189 @@
|
||||
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);
|
||||
Reference in New Issue
Block a user