You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

151 lines
4.5 KiB

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<String> sendMessage({
required List<Map<String, String>> 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<Map<String, String>> 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();
}
}
}