AI 接入 V0.1

This commit is contained in:
2026-09-23 17:23:59 +08:00
parent a83cda63f5
commit 34bc2e04b0
9 changed files with 1018 additions and 116 deletions
+147
View File
@@ -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,
};
}
}