Files
Aq-Accounting-Flutter/lib/api/ai_api.dart
T
2026-09-28 11:57:54 +08:00

217 lines
7.2 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/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, required String content}) async* {
final Response<ResponseBody> res;
try {
res = await _dio.post<ResponseBody>(
'/ai/chat',
// configId 传 null = 用服务端 .env 里那套(列表里虚拟的「默认」项)
data: {'sessionId': sessionId, 'configId': configId, '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, '删除失败');
}
}
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,
};
}
}