import 'dart:convert'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:http/http.dart' as http; import '../models/appconfig.dart'; import 'ai_service.dart'; /// 语音纪要「Ask AI」用的文本大模型(OpenAI 兼容接口,客户端直连)。 /// /// 2026-09-17 起只读后台「第三方服务配置」里的 **`llm_qwen`**(通义千问), /// 与服务端会议总结用的是同一家(服务端走 `llmv_qwen`)。火山(`llm_doubao`) /// 已从后台删除,这里不再兜底、也不再退回 `OPENAI_*` 那几个老 env 键。 class DeapsoundAIService implements AiService { static const String svcId = 'llm_qwen'; late final String baseUrl; late final String apiKey; late final String model; DeapsoundAIService() { apiKey = AppConfig.cred(svcId, 'api_key', '') ?? ''; baseUrl = AppConfig.cred(svcId, 'base_url', '') ?? ''; model = AppConfig.cred(svcId, 'model', '') ?? ''; } /// 测试用:不读 AppConfig,只为验证 [chatUrl] 的拼接 @visibleForTesting DeapsoundAIService.debugWithBaseUrl(String base) : baseUrl = base, apiKey = '', model = ''; /// 后台 `llm_qwen.base_url` 填的是不带路径的根地址 /// (`https://dashscope.aliyuncs.com/compatible-mode/v1`),这里补上 /// `/chat/completions`;已经带了的原样用。 Uri get chatUrl { var base = baseUrl.trim(); while (base.endsWith('/')) { base = base.substring(0, base.length - 1); } if (!base.endsWith('/chat/completions')) base = '$base/chat/completions'; return Uri.parse(base); } @override Future sendMessage({ required List> messages, String? systemPrompt, String? userProperties, }) async { final url = chatUrl; final headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer $apiKey', }; final requestBody = { "model": model, "messages": messages, }; try { final response = await http.post( url, headers: headers, body: jsonEncode(requestBody), ); if (response.statusCode != 200) { throw Exception('Unknown error'); } final result = jsonDecode(response.body); return result['choices'].first['message']['content']; } catch (e) { throw Exception('Unknown error'); } } @override Stream sendMessageStream({ required List> messages, String? systemPrompt, String? userProperties, }) async* { final client = http.Client(); final url = chatUrl; final headers = { 'Content-Type': 'application/json', 'Authorization': 'Bearer $apiKey', }; final requestBody = { "model": model, "messages": messages, "stream": true, }; // ⚠️ 凭据来自 user_getappconfig_v3 的 llm_qwen;没拿到时 baseUrl 是空串, // Uri.parse('') 能过、client.send 才抛 "No host specified"。这里先给一句能看懂的。 if (baseUrl.isEmpty || apiKey.isEmpty || model.isEmpty) { throw Exception( 'AI 服务未配置($svcId: base_url/api_key/model 缺失),请检查后台「第三方服务配置」'); } try { final request = http.Request('POST', url) ..headers.addAll(headers) ..body = jsonEncode(requestBody); final response = await client.send(request); if (response.statusCode != 200) { // 把服务商的状态码和错误体带出去:401/403 是 key 不对、400 多半是 model 不存在, // 全压成 'Unknown error' 就只能靠猜。 String body = ''; try { body = await response.stream.bytesToString(); } catch (_) {} if (body.length > 300) body = body.substring(0, 300); throw Exception('HTTP ${response.statusCode} $body'); } await for (final chunk in response.stream .transform(utf8.decoder) .transform(const LineSplitter())) { if (chunk.isEmpty) continue; if (chunk.startsWith('data:')) { final jsonStr = chunk.substring(5).trim(); if (jsonStr == '[DONE]') { break; } final data = jsonDecode(jsonStr); final content = data['choices'][0]['delta']['content'] ?? ''; if (content.isNotEmpty) { yield content; } } } } catch (e) { // 原样抛出(上面 HTTP 分支已经带了状态码),不要再包一层 'Unknown error' rethrow; } finally { client.close(); } } }