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((ref) => AiApi(ref.watch(dioProvider))); /// AI 助手接口 class AiApi { final Dio _dio; AiApi(this._dio); /// 后端是否配好了 key(没配的话 App 显示提示而不是让用户白问一句) Future 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> 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> 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 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 chat({int? sessionId, int? configId, required String content}) async* { final Response res; try { res = await _dio.post( '/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,而 utf8.decoder 是 // StreamTransformer, String>,Dart 的泛型不支持这个方向的隐式转换。 final lines = body.stream .cast>() .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> 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 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.from(res.data as Map)); } on DioException catch (e) { final err = e.error; throw err is ApiException ? err : ApiException(-1, '保存失败'); } } /// 更新。[apiKey] 传空表示不改动 —— App 只有掩码,不该被迫重新输入 key。 Future 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.from(res.data as Map)); } on DioException catch (e) { final err = e.error; throw err is ApiException ? err : ApiException(-1, '保存失败'); } } Future 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) 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, }; } }