289 lines
9.4 KiB
Dart
289 lines
9.4 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/ai_config.dart';
|
||
import '../models/ai_persona.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,
|
||
int? configId,
|
||
int? personaId,
|
||
required String content,
|
||
}) async* {
|
||
final Response<ResponseBody> res;
|
||
try {
|
||
res = await _dio.post<ResponseBody>(
|
||
'/ai/chat',
|
||
// configId 传 null = 用服务端 .env 里那套(列表里虚拟的「默认」项)
|
||
// personaId 传 null = 用服务端内置的默认人设。两者互相独立
|
||
data: {
|
||
'sessionId': sessionId,
|
||
'configId': configId,
|
||
'personaId': personaId,
|
||
'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 连接中断');
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- AI 配置
|
||
|
||
/// 配置列表。后端把虚拟的「默认」项放在第一位(id 为 null)。
|
||
Future<List<AiConfigItem>> configs() async {
|
||
try {
|
||
final res = await _dio.get('/ai/configs');
|
||
final data = res.data;
|
||
if (data is! List) return const [];
|
||
return asMapList(data).map(AiConfigItem.fromJson).toList();
|
||
} on DioException catch (e) {
|
||
final err = e.error;
|
||
throw err is ApiException ? err : ApiException(-1, '配置列表加载失败');
|
||
}
|
||
}
|
||
|
||
/// 新增。创建时 apiKey 必填。
|
||
Future<AiConfigItem> createConfig({
|
||
required String name,
|
||
required String baseUrl,
|
||
required String apiKey,
|
||
required String model,
|
||
}) async {
|
||
try {
|
||
final res = await _dio.post('/ai/configs', data: {
|
||
'name': name,
|
||
'baseUrl': baseUrl,
|
||
'apiKey': apiKey,
|
||
'model': model,
|
||
});
|
||
return AiConfigItem.fromJson(Map<String, dynamic>.from(res.data as Map));
|
||
} on DioException catch (e) {
|
||
final err = e.error;
|
||
throw err is ApiException ? err : ApiException(-1, '保存失败');
|
||
}
|
||
}
|
||
|
||
/// 更新。[apiKey] 传空表示不改动 —— App 只有掩码,不该被迫重新输入 key。
|
||
Future<AiConfigItem> updateConfig({
|
||
required int id,
|
||
required String name,
|
||
required String baseUrl,
|
||
required String model,
|
||
String? apiKey,
|
||
}) async {
|
||
try {
|
||
final res = await _dio.put('/ai/configs/$id', data: {
|
||
'name': name,
|
||
'baseUrl': baseUrl,
|
||
'model': model,
|
||
if (apiKey != null && apiKey.isNotEmpty) 'apiKey': apiKey,
|
||
});
|
||
return AiConfigItem.fromJson(Map<String, dynamic>.from(res.data as Map));
|
||
} on DioException catch (e) {
|
||
final err = e.error;
|
||
throw err is ApiException ? err : ApiException(-1, '保存失败');
|
||
}
|
||
}
|
||
|
||
Future<void> deleteConfig(int id) async {
|
||
try {
|
||
await _dio.delete('/ai/configs/$id');
|
||
} on DioException catch (e) {
|
||
final err = e.error;
|
||
throw err is ApiException ? err : ApiException(-1, '删除失败');
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------- AI 人设
|
||
|
||
/// 人设列表。后端把虚拟的「默认」项放在第一位(id 为 null,带 defaultPrompt)。
|
||
Future<List<AiPersonaItem>> personas() async {
|
||
try {
|
||
final res = await _dio.get('/ai/personas');
|
||
final data = res.data;
|
||
if (data is! List) return const [];
|
||
return asMapList(data).map(AiPersonaItem.fromJson).toList();
|
||
} on DioException catch (e) {
|
||
final err = e.error;
|
||
throw err is ApiException ? err : ApiException(-1, '人设列表加载失败');
|
||
}
|
||
}
|
||
|
||
/// 新增一套人设。
|
||
Future<AiPersonaItem> createPersona({
|
||
required String name,
|
||
required String systemPrompt,
|
||
}) async {
|
||
try {
|
||
final res = await _dio.post('/ai/personas', data: {
|
||
'name': name,
|
||
'systemPrompt': systemPrompt,
|
||
});
|
||
return AiPersonaItem.fromJson(Map<String, dynamic>.from(res.data as Map));
|
||
} on DioException catch (e) {
|
||
final err = e.error;
|
||
throw err is ApiException ? err : ApiException(-1, '保存失败');
|
||
}
|
||
}
|
||
|
||
/// 修改人设。提示词是**整体替换**的 —— 每次都把完整文本传上来,
|
||
/// 没有模型配置那种「key 留空 = 不改动」的语义。
|
||
Future<AiPersonaItem> updatePersona({
|
||
required int id,
|
||
required String name,
|
||
required String systemPrompt,
|
||
}) async {
|
||
try {
|
||
final res = await _dio.put('/ai/personas/$id', data: {
|
||
'name': name,
|
||
'systemPrompt': systemPrompt,
|
||
});
|
||
return AiPersonaItem.fromJson(Map<String, dynamic>.from(res.data as Map));
|
||
} on DioException catch (e) {
|
||
final err = e.error;
|
||
throw err is ApiException ? err : ApiException(-1, '保存失败');
|
||
}
|
||
}
|
||
|
||
Future<void> deletePersona(int id) async {
|
||
try {
|
||
await _dio.delete('/ai/personas/$id');
|
||
} on DioException catch (e) {
|
||
final err = e.error;
|
||
throw err is ApiException ? err : ApiException(-1, '删除失败');
|
||
}
|
||
}
|
||
|
||
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,
|
||
};
|
||
}
|
||
}
|