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((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, int? personaId, required String content, }) async* { final Response res; try { res = await _dio.post( '/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,而 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, '删除失败'); } } // ---------------------------------------------------------------- AI 人设 /// 人设列表。后端把虚拟的「默认」项放在第一位(id 为 null,带 defaultPrompt)。 Future> 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, '人设列表加载失败'); } } /// 新增一套人设。[avatar] 传 null 表示不设头像。 Future createPersona({ required String name, required String systemPrompt, String? avatar, }) async { try { final res = await _dio.post('/ai/personas', data: { 'name': name, 'systemPrompt': systemPrompt, 'avatar': ?avatar, }); return AiPersonaItem.fromJson(Map.from(res.data as Map)); } on DioException catch (e) { final err = e.error; throw err is ApiException ? err : ApiException(-1, '保存失败'); } } /// 修改人设。提示词是**整体替换**的 —— 每次都把完整文本传上来, /// 没有模型配置那种「key 留空 = 不改动」的语义。 /// /// 头像不一样:[avatar] 传 null = **不改动**(用户只改提示词时不该被迫重选头像), /// [clearAvatar] 传 true = 清空回到默认头像。 Future updatePersona({ required int id, required String name, required String systemPrompt, String? avatar, bool clearAvatar = false, }) async { try { final res = await _dio.put('/ai/personas/$id', data: { 'name': name, 'systemPrompt': systemPrompt, 'avatar': ?avatar, if (clearAvatar) 'clearAvatar': true, }); return AiPersonaItem.fromJson(Map.from(res.data as Map)); } on DioException catch (e) { final err = e.error; throw err is ApiException ? err : ApiException(-1, '保存失败'); } } /// 上传人设头像,返回**相对路径**(再由 create/update 提交)。 /// /// 只落盘不落库 —— 用户在编辑弹层里可能传了又取消,不该留下记录。 Future uploadPersonaAvatar(String filePath) async { try { final form = FormData.fromMap({ 'file': await MultipartFile.fromFile(filePath, filename: _fileName(filePath)), }); final res = await _dio.post( '/ai/personas/avatar', data: form, options: Options(receiveTimeout: const Duration(seconds: 60)), ); final data = res.data; if (data is Map && data['path'] != null) { return data['path'].toString(); } throw ApiException(-1, '上传失败:服务端没返回路径'); } on DioException catch (e) { final err = e.error; throw err is ApiException ? err : ApiException(-1, '头像上传失败'); } } Future 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, '删除失败'); } } /// 从路径里取个带扩展名的文件名,后端靠它判断文件类型 static String _fileName(String path) { final normalized = path.replaceAll('\\', '/'); final name = normalized.substring(normalized.lastIndexOf('/') + 1); return name.isEmpty ? 'avatar.jpg' : name; } 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, }; } }