148 lines
5.0 KiB
Dart
148 lines
5.0 KiB
Dart
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,
|
||
};
|
||
}
|
||
}
|