AI 接入 V0.1
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/network/api_exception.dart';
|
||||
import '../core/network/dio_client.dart';
|
||||
import '../models/ai.dart';
|
||||
import '../models/json_util.dart';
|
||||
|
||||
final aiApiProvider = Provider<AiApi>((ref) => AiApi(ref.watch(dioProvider)));
|
||||
|
||||
/// AI 助手接口
|
||||
class AiApi {
|
||||
final Dio _dio;
|
||||
|
||||
AiApi(this._dio);
|
||||
|
||||
/// 后端是否配好了 key(没配的话 App 显示提示而不是让用户白问一句)
|
||||
Future<bool> status() async {
|
||||
try {
|
||||
final res = await _dio.get('/ai/status');
|
||||
final data = res.data;
|
||||
return data is Map && data['enabled'] == true;
|
||||
} on DioException {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ChatSessionItem>> sessions() async {
|
||||
try {
|
||||
final res = await _dio.get('/ai/sessions');
|
||||
final data = res.data;
|
||||
if (data is! List) return const [];
|
||||
return asMapList(data).map(ChatSessionItem.fromJson).toList();
|
||||
} on DioException catch (e) {
|
||||
final err = e.error;
|
||||
throw err is ApiException ? err : ApiException(-1, '会话列表加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ChatMessageItem>> messages(int sessionId) async {
|
||||
try {
|
||||
final res = await _dio.get('/ai/sessions/$sessionId/messages');
|
||||
final data = res.data;
|
||||
if (data is! List) return const [];
|
||||
return asMapList(data).map(ChatMessageItem.fromJson).toList();
|
||||
} on DioException catch (e) {
|
||||
final err = e.error;
|
||||
throw err is ApiException ? err : ApiException(-1, '消息加载失败');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSession(int sessionId) async {
|
||||
try {
|
||||
await _dio.delete('/ai/sessions/$sessionId');
|
||||
} on DioException catch (e) {
|
||||
final err = e.error;
|
||||
throw err is ApiException ? err : ApiException(-1, '删除会话失败');
|
||||
}
|
||||
}
|
||||
|
||||
/// 发起对话,返回事件流。
|
||||
///
|
||||
/// 用 dio 的 [ResponseType.stream] 拿原始字节流、自己按 SSE 协议切分 ——
|
||||
/// dio 没有内置 SSE 支持,但 OpenAI 兼容接口的流格式很简单
|
||||
/// (`event: xxx` / `data: {...}` / 空行分隔),手动解析比多引一个依赖可控。
|
||||
///
|
||||
/// 注意:[sessionId] 传 null 表示开新会话,会话 ID 会由后端在 done 事件里返回。
|
||||
Stream<AiEvent> chat({int? sessionId, required String content}) async* {
|
||||
final Response<ResponseBody> res;
|
||||
try {
|
||||
res = await _dio.post<ResponseBody>(
|
||||
'/ai/chat',
|
||||
data: {'sessionId': sessionId, 'content': content},
|
||||
options: Options(
|
||||
responseType: ResponseType.stream,
|
||||
// LLM 生成慢 + 工具循环可能跑好几轮,给足;后端 SseEmitter 是 180s
|
||||
receiveTimeout: const Duration(minutes: 3),
|
||||
headers: {'Accept': 'text/event-stream'},
|
||||
),
|
||||
);
|
||||
} on DioException catch (e) {
|
||||
final err = e.error;
|
||||
yield AiEvent.error(err is ApiException ? err.message : 'AI 请求失败');
|
||||
return;
|
||||
}
|
||||
|
||||
final body = res.data;
|
||||
if (body == null) {
|
||||
yield const AiEvent.error('AI 没有返回内容');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// utf8.decoder 作为流转换器能正确处理「多字节字符被 chunk 切断」的情况,
|
||||
// 直接对每块调 utf8.decode 会在这里出乱码。
|
||||
// cast 是必须的:dio 给的是 Stream<Uint8List>,而 utf8.decoder 是
|
||||
// StreamTransformer<List<int>, String>,Dart 的泛型不支持这个方向的隐式转换。
|
||||
final lines = body.stream
|
||||
.cast<List<int>>()
|
||||
.transform(utf8.decoder)
|
||||
.transform(const LineSplitter());
|
||||
|
||||
var event = '';
|
||||
final data = StringBuffer();
|
||||
|
||||
await for (final line in lines) {
|
||||
if (line.isEmpty) {
|
||||
// 空行 = 一个事件结束
|
||||
final payload = data.toString();
|
||||
data.clear();
|
||||
if (payload.isNotEmpty) {
|
||||
final parsed = _parseEvent(event, payload);
|
||||
if (parsed != null) yield parsed;
|
||||
}
|
||||
event = '';
|
||||
continue;
|
||||
}
|
||||
if (line.startsWith('event:')) {
|
||||
event = line.substring(6).trim();
|
||||
} else if (line.startsWith('data:')) {
|
||||
data.write(line.substring(5).trim());
|
||||
}
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
final err = e.error;
|
||||
yield AiEvent.error(err is ApiException ? err.message : 'AI 连接中断');
|
||||
} catch (_) {
|
||||
yield const AiEvent.error('AI 连接中断');
|
||||
}
|
||||
}
|
||||
|
||||
AiEvent? _parseEvent(String event, String payload) {
|
||||
final decoded = jsonDecode(payload);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
|
||||
return switch (event) {
|
||||
'delta' => AiEvent.delta(asString(decoded['content'])),
|
||||
'tool' => AiEvent.tool(asString(decoded['name'], fallback: '查询')),
|
||||
'done' => AiEvent.done(asNullableInt(decoded['sessionId'])),
|
||||
'error' => AiEvent.error(asString(decoded['message'], fallback: 'AI 服务出错')),
|
||||
// 未知事件直接忽略,后端以后加新事件类型时老版本 App 不会崩
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user