using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Net; using System.Net.Http; using System.Text; using System.Threading.Tasks; using Newtonsoft.Json.Linq; namespace LocalizationTool.Services { /// AI 请求调用工具(Function Calling)的一次聚合结果。 public class AiToolCall { public string Id { get; set; } public string Name { get; set; } public string Arguments { get; set; } = ""; } /// 翻译提供方接口:把源文翻译到目标语言。 public interface ITranslationProvider { string Name { get; } bool IsConfigured { get; } Task TranslateAsync(string text, string fromLang, string toLang); } /// /// OpenAI 兼容的 Chat Completions 实现(可接 DeepSeek / Qwen / 通义 / agnes 等)。 /// 配置读取 exe 旁 LocalizationTool.config.json;支持多 Key 轮询—— /// 当前 Key 超时或请求失败时自动切换下一个 Key 重试,成功后轮转起点分摊负载。 /// public class OpenAiCompatibleProvider : ITranslationProvider { public string Name => "OpenAI 兼容(LLM)"; public bool IsConfigured => !string.IsNullOrWhiteSpace(_baseUrl) && _keys.Count > 0; private readonly string _baseUrl; private readonly string _model; private readonly List _keys; private readonly int _maxOutputTokens; // 单次回复输出上限(防思考模式截断 arguments) private int _keyIndex = 0; // 下一次请求从哪个 Key 开始(成功后向后轮转) public OpenAiCompatibleProvider(Config.TranslationConfig cfg) { // 空配置 → 运行时兜底内置默认(ResolveBaseUrl / ResolveModel / GetOpenAiKeys 内部处理) _baseUrl = Config.ResolveBaseUrl(cfg); _model = Config.ResolveModel(cfg); _keys = Config.GetOpenAiKeys(cfg); _maxOutputTokens = Config.ResolveMaxOutputTokens(cfg); } // ----- Key 健康度:失败/超时的 Key 进入冷却期并排到队尾,避免每次请求都先撞坏 Key ----- private readonly Dictionary _cooldownUntil = new Dictionary(); private readonly Dictionary _failStreak = new Dictionary(); /// 本次请求的 Key 顺序:从 _keyIndex 轮转;冷却中的 Key 排到队尾兜底。 private List OrderedKeys() { var healthy = new List(); var cooling = new List(); var now = DateTime.UtcNow; for (int i = 0; i < _keys.Count; i++) { var k = _keys[(_keyIndex + i) % _keys.Count]; var isCooling = _cooldownUntil.TryGetValue(k, out var until) && until > now; (isCooling ? cooling : healthy).Add(k); } healthy.AddRange(cooling); return healthy; } private void MarkKeySuccess(string key) { _cooldownUntil.Remove(key); _failStreak[key] = 0; var idx = _keys.IndexOf(key); if (idx >= 0) _keyIndex = (idx + 1) % _keys.Count; // 成功 → 下次从下一个 Key 开始(分摊用量) } private void MarkKeyFailure(string key) { _failStreak.TryGetValue(key, out var streak); streak++; _failStreak[key] = streak; // 冷却 2 分钟起步,连续失败翻倍,上限 10 分钟 var seconds = Math.Min(120d * Math.Pow(2, streak - 1), 600d); _cooldownUntil[key] = DateTime.UtcNow.AddSeconds(seconds); } public async Task TranslateAsync(string text, string fromLang, string toLang) { if (string.IsNullOrWhiteSpace(text)) return ""; if (_keys.Count == 0) throw new Exception("未配置任何 API Key"); // 语言代码 → 自然语言描述("ja-JP" → "日语(日本語)"),LLM 对母语名称理解最准 var fromName = DescribeLang(fromLang, "中文"); var toName = DescribeLang(toLang, "目标语言"); var payload = new { model = _model, temperature = 0.2, max_tokens = 500, // 界面文本都很短,限制输出长度,防止模型长篇大论 messages = new object[] { new { role = "system", content = "你是专业软件界面本地化翻译员。直接给出译文,不要思考过程、不要解释、不要引号、不要输出原文、不要多余标点。" }, new { role = "user", content = $"把下面的{fromName}直接翻译成{toName},只输出译文:\n{text}" }, }, }; // 多 Key 轮询:超时或失败自动切下一个;近期失败过的 Key 排到队尾 var errors = new List(); foreach (var key in OrderedKeys()) { try { var result = await DoTranslateAsync(payload, key).ConfigureAwait(false); if (!string.IsNullOrEmpty(result)) { MarkKeySuccess(key); return result; } errors.Add($"{Config.MaskKey(key)}: 返回为空"); MarkKeyFailure(key); } catch (Exception ex) { errors.Add($"{Config.MaskKey(key)}: {ex.Message}"); MarkKeyFailure(key); } } throw new Exception("所有 API Key 均失败:\n" + string.Join("\n", errors)); } private async Task DoTranslateAsync(object payload, string apiKey) { using (var client = new HttpClient()) { client.Timeout = TimeSpan.FromSeconds(30); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); var body = new StringContent(payload.ToJson(), Encoding.UTF8, "application/json"); var resp = await client.PostAsync(_baseUrl + "/chat/completions", body).ConfigureAwait(false); if (!resp.IsSuccessStatusCode) throw new Exception($"翻译请求失败: {(int)resp.StatusCode} {await resp.Content.ReadAsStringAsync().ConfigureAwait(false)}"); var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); var j = JObject.Parse(json); var result = (string)j["choices"]?[0]?["message"]?["content"]; return (result ?? "").Trim(); } } /// /// 语言代码 → 自然语言名称(查语言目录):"ja-JP" → "日语(日本語)"。 /// 查不到时按主代码再试一次;仍找不到返回 fallback。 /// private static string DescribeLang(string langCode, string fallback) { if (string.IsNullOrWhiteSpace(langCode)) return fallback; var entry = LanguageCatalog.Find(langCode); if (entry == null) { var main = langCode.Split('-')[0].ToLowerInvariant(); entry = LanguageCatalog.All.FirstOrDefault(e => string.Equals(e.Code.Split('-')[0], main, StringComparison.OrdinalIgnoreCase)); } if (entry == null) return fallback; return string.Equals(entry.Name, entry.ChineseName, StringComparison.Ordinal) ? entry.ChineseName // 小众语言:直接中文名,如 "日语"→"爱尔兰语" : $"{entry.ChineseName}({entry.Name})"; // 主流语言:日语(日本語) } /// /// 流式对话(OpenAI 兼容 SSE,支持 Function Calling),多 Key 轮询: /// 当前 Key 超时/请求失败时自动切换下一个 Key 重试; /// 若已向界面输出过增量内容则不重试(避免内容重复),直接抛错。 /// - onContent:正文增量回调(真实增量,非模拟) /// - onReasoning:思考过程增量回调(reasoning_content / reasoning) /// - tools:OpenAI tools 定义(可空);模型请求调用工具时返回聚合后的 toolCalls /// 返回 (正文, 工具调用列表)。toolCalls 非空时,调用方执行工具后把结果以 role=tool 消息续传。 /// 注意:回调在后台线程触发,UI 更新需自行 Dispatcher 调度。 /// public async Task<(string content, List toolCalls)> StreamChatAsync( JArray messages, Action onContent, Action onReasoning = null, JArray tools = null, System.Threading.CancellationToken ct = default) { if (_keys.Count == 0) throw new Exception("未配置任何 API Key"); // 输出上限走配置:思考模型若太小,tool call arguments 会被截断成非法 JSON(400)。 // agnes-2.5-flash 官方规格 max output 65.5K。 var maxOutput = _maxOutputTokens; object payload = tools == null || tools.Count == 0 ? (object)new { model = _model, temperature = 0.3, stream = true, max_tokens = maxOutput, messages, // 启用 Thinking 模式(agnes 官方文档:chat_template_kwargs.enable_thinking = true) chat_template_kwargs = new { enable_thinking = true }, } : (object)new { model = _model, temperature = 0.3, stream = true, max_tokens = maxOutput, messages, tools, chat_template_kwargs = new { enable_thinking = true }, }; var contentSb = new StringBuilder(); var delivered = false; // 是否已向界面交付过增量(交付后失败不能换 Key 重试,否则内容重复) var errors = new List(); foreach (var key in OrderedKeys()) { try { var (calls, finishReason) = await StreamChatOnceAsync(payload, key, contentSb, d => { delivered = true; onContent?.Invoke(d); }, r => { delivered = true; onReasoning?.Invoke(r); }, ct).ConfigureAwait(false); var ordered = OrderedToolCalls(calls); if (finishReason == "tool_calls" && ordered.Count == 0) throw new Exception("模型请求调用工具但未返回有效的 tool_calls 数据"); MarkKeySuccess(key); return (contentSb.ToString(), ordered); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; // 用户主动停止:不换 Key 重试 } catch (Exception ex) { MarkKeyFailure(key); if (delivered) throw; // 已输出过内容,重试会造成界面内容重复 errors.Add($"{Config.MaskKey(key)}: {ex.Message}"); } } throw new Exception("所有 API Key 均失败:\n" + string.Join("\n", errors)); } private static List OrderedToolCalls(Dictionary toolCalls) => toolCalls.OrderBy(kv => kv.Key).Select(kv => kv.Value).ToList(); /// /// SendAsync 但仅对"响应头等待"限时 30 秒;拿到响应后立刻解除限时, /// 之后正文流式读取不受该定时器影响(整体仍受 client.Timeout 5 分钟约束)。 /// private static async Task SendWithHeaderTimeoutAsync( HttpClient client, HttpRequestMessage req, System.Threading.CancellationTokenSource headerCts) { try { headerCts.CancelAfter(TimeSpan.FromSeconds(30)); return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, headerCts.Token) .ConfigureAwait(false); } finally { headerCts.CancelAfter(System.Threading.Timeout.InfiniteTimeSpan); } } /// 用单个 Key 执行一次流式请求(SSE 解析 + tool_calls 增量聚合)。 private async Task<(Dictionary toolCalls, string finishReason)> StreamChatOnceAsync( object payload, string apiKey, StringBuilder contentSb, Action onContent, Action onReasoning, System.Threading.CancellationToken ct) { var toolCalls = new Dictionary(); string finishReason = null; using (var client = new HttpClient()) { client.Timeout = TimeSpan.FromMinutes(5); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey); var body = new StringContent(payload.ToJson(), Encoding.UTF8, "application/json"); // ResponseHeadersRead:不等整个响应体,边下边读 SSE // (net48 的 PostAsync 没有 HttpCompletionOption 重载,须用 SendAsync + HttpRequestMessage) using (var req = new HttpRequestMessage(HttpMethod.Post, _baseUrl + "/chat/completions") { Content = body }) // 取消令牌:停止时中断进行中的网络请求(CancelPendingRequests) using (ct.Register(() => client.CancelPendingRequests())) // 响应头 30 秒内没到 → 判定该 Key 挂死(抛超时 → 上层冷却该 Key、换下一个), // 而不是干等 client.Timeout 的 5 分钟;流式正文读取不受此 30 秒限制 using (var headerCts = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(ct)) using (var resp = await SendWithHeaderTimeoutAsync(client, req, headerCts).ConfigureAwait(false)) { if (!resp.IsSuccessStatusCode) throw new Exception($"请求失败: {(int)resp.StatusCode} {await resp.Content.ReadAsStringAsync().ConfigureAwait(false)}"); using (var stream = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false)) using (var reader = new StreamReader(stream, Encoding.UTF8)) { string line; while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) { if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:", StringComparison.Ordinal)) continue; var data = line.Substring(5).Trim(); if (data == "[DONE]") break; JObject j; try { j = JObject.Parse(data); } catch { continue; } var choice = j["choices"]?[0]; var delta = choice?["delta"]; // 思考过程:DeepSeek R1 风格 reasoning_content,兼容 reasoning 字段 var reasoning = (string)delta?["reasoning_content"] ?? (string)delta?["reasoning"]; if (!string.IsNullOrEmpty(reasoning)) onReasoning?.Invoke(reasoning); var content = (string)delta?["content"]; if (!string.IsNullOrEmpty(content)) { contentSb.Append(content); onContent?.Invoke(content); } // Function Calling:tool_calls 增量按 index 聚合(arguments 分片到达) if (delta?["tool_calls"] is JArray tcArr) { foreach (var tc in tcArr) { var idx = tc["index"] != null ? (int)tc["index"] : 0; if (!toolCalls.TryGetValue(idx, out var item)) { item = new AiToolCall { Id = (string)tc["id"] ?? $"call_{idx}", }; if (tc["function"]?["name"] != null) item.Name = (string)tc["function"]["name"]; toolCalls[idx] = item; } if (tc["id"] != null) item.Id = (string)tc["id"]; if (tc["function"]?["name"] != null) item.Name = (string)tc["function"]["name"]; if (tc["function"]?["arguments"] != null) item.Arguments += (string)tc["function"]["arguments"]; } } if (choice?["finish_reason"] != null) finishReason = (string)choice["finish_reason"]; } } } } return (toolCalls, finishReason); } } internal static class JsonExtensions { public static string ToJson(this object o) { return Newtonsoft.Json.JsonConvert.SerializeObject(o); } } /// 上下文用量估算(AI 助手用量指示器用)。 public static class ContextEstimator { /// /// 估算一段文本的 token 数:中文约 1 token/字,英文约 4 字符/token。 /// 取折中 2 字符 ≈ 1 token(粗略但够用,用于占比显示)。 /// public static int EstimateTokens(string text) { if (string.IsNullOrEmpty(text)) return 0; return Math.Max(1, text.Length / 2); } /// 估算整组消息(含工具调用与结果)的 token 数。 public static int EstimateMessagesTokens(JArray messages) { int total = 0; foreach (var m in messages) { var obj = m as JObject; if (obj == null) continue; total += EstimateTokens(obj["content"]?.ToString() ?? ""); if (obj["tool_calls"] is JArray calls) foreach (var c in calls) total += EstimateTokens(c["function"]?["arguments"]?.ToString() ?? ""); } return total; } /// 格式化 token 数(如 12,345 → "12.3K")。 public static string FormatTokens(int tokens) => tokens >= 1000 ? $"{tokens / 1000.0:0.#}K" : tokens.ToString(); } /// /// 百度通用翻译 API 实现。需 appid + 密钥(安全密钥),签名 = md5(appid+q+salt+secret)。 /// 语言码映射:en-US→en、ja-JP→jp、ko-KR→kor、zh-CN→zh、fr→fra 等。 /// /// 百度翻译(官方 doc/21 通用文本翻译 API,大模型驱动)。 /// 接口:https://fanyi-api.baidu.com/api/trans/vip/translate /// 签名:sign = md5(appid + q + salt + 密钥),q 为原始文本(未 URL 编码)。 /// 批量:官方多 query —— q 内用 \n 分隔多条,一次请求翻多条(响应 trans_result 按行对应)。 /// public class BaiduTranslateProvider : ITranslationProvider { public string Name => "百度翻译"; public bool IsConfigured => !string.IsNullOrWhiteSpace(_appid) && !string.IsNullOrWhiteSpace(_secret); private readonly string _appid; private readonly string _secret; /// 单次批量请求的最大条数(控制 q 长度,避免超 6000 字节限制)。 public const int BatchSize = 20; private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) }; /// 翻译模式:nmt = 机器翻译(默认,快)/ llm = 大模型翻译(质量好)。 public string ModelType { get; set; } = "nmt"; /// 自定义翻译指令(reference 字段):对翻译结果的要求,如保留数字/符号只翻文本。 public string Reference { get; set; } = ""; public BaiduTranslateProvider(Config.BaiduConfig cfg) { var creds = Config.GetBaiduCredentials(cfg); _appid = (creds.appId ?? "").Trim(); _secret = (creds.secret ?? "").Trim(); // nmt / llm(非法值兜底 nmt) var mt = (cfg.ModelType ?? "").Trim().ToLowerInvariant(); ModelType = mt == "llm" ? "llm" : "nmt"; Reference = (cfg.Reference ?? "").Trim(); } public async Task TranslateAsync(string text, string fromLang, string toLang) { if (string.IsNullOrWhiteSpace(text)) return ""; var results = await TranslateBatchAsync(new[] { text }, fromLang, toLang).ConfigureAwait(false); return results[0]; } /// /// 批量翻译(官方多 query:q 用 \n 分隔多条,一次请求翻一批,返回与输入一一对应的译文列表)。 /// public async Task> TranslateBatchAsync(IList texts, string fromLang, string toLang) { var from = MapLang(fromLang); var to = MapLang(toLang); // 多 query:换行分隔(词条本身不含换行,安全) var q = string.Join("\n", texts); var salt = new Random().Next(100000).ToString(); // 签名用原始 q(含换行,未编码) var sign = Md5(_appid + q + salt + _secret); var form = new Dictionary { ["q"] = q, ["from"] = from, ["to"] = to, ["appid"] = _appid, ["salt"] = salt, ["sign"] = sign, ["model_type"] = ModelType ?? "nmt", // nmt = 机器翻译(默认)/ llm = 大模型翻译 }; // 自定义翻译指令(reference):llm 大模型模式生效——要求保留数字/符号只翻文本 if (!string.IsNullOrWhiteSpace(Reference)) form["reference"] = Reference; var resp = await _http.PostAsync( "https://fanyi-api.baidu.com/ait/api/aiTextTranslate", new FormUrlEncodedContent(form)).ConfigureAwait(false); var body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false); JObject j; try { j = JObject.Parse(body); } catch { throw new Exception($"百度翻译响应解析失败: {Truncate(body, 200)}"); } if (j["error_code"] != null) { var code = j["error_code"].ToString(); var msg = j["error_msg"]?.ToString() ?? "未知错误"; throw new BaiduApiException(code, BaiduApiException.DescribeError(code, msg)); } var arr = j["trans_result"] as JArray; var list = new List(); if (arr != null) foreach (var item in arr) list.Add(((string)item["dst"] ?? "").Trim()); // 保证与输入一一对应(不足补空) while (list.Count < texts.Count) list.Add(""); return list; } /// 系统语言代码 → 百度语言代码(查 LanguageCatalog 映射表,如 "ja-JP" → "jp")。 private static string MapLang(string lang) => LanguageCatalog.GetBaiduCode(lang); private static string Md5(string input) { using (var md5 = System.Security.Cryptography.MD5.Create()) { var bytes = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); var sb = new System.Text.StringBuilder(); foreach (var b in bytes) sb.Append(b.ToString("x2")); return sb.ToString(); } } private static string Truncate(string s, int max) => string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "..."; } /// 百度翻译 API 异常:携带原始错误码,便于上层精确处理(如限流重试、敏感词识别)。 public class BaiduApiException : Exception { public string Code { get; } public BaiduApiException(string code, string message) : base(message) { Code = code; } /// 百度错误码 → 中文说明(来源:官方文档错误码列表 + 实测)。 public static string DescribeError(string code, string raw) { switch (code) { case "20003": return "请求内容存在安全风险:文本触发百度内容安全审核被拦截(某些品牌名/词组会被误判,如 FANUC),建议该条人工翻译"; case "52001": return "请求超时,请重试"; case "52002": return "百度服务超时,请重试"; case "52003": return "未授权用户:APP ID 或密钥不正确"; case "54000": return "必填参数为空"; case "54001": return "签名错误:请检查 APP ID / 密钥是否正确"; case "54003": return "访问频率受限(QPS 超限):稍后重试或升级高级版"; case "54004": return "账户余额不足,请充值字符包"; case "54005": return "请求过于频繁,请稍后重试"; case "58001": return "目标语言不符合翻译规则(该语言方向不支持)"; case "58002": return "服务当前已停用"; case "58003": return "文本包含敏感词被拦截"; case "59000": return "没有权限"; case "90107": return "认证失败"; default: return $"{raw} (code {code})"; } } } /// 翻译入口:按配置选择 Provider,未配置时返回空并提示。 public static class Translation { private static ITranslationProvider _provider; public static ITranslationProvider Provider { get { if (_provider == null) { var cfg = Config.Load(); _provider = string.Equals(cfg.Translation.Provider, "baidu", StringComparison.OrdinalIgnoreCase) ? (ITranslationProvider)new BaiduTranslateProvider(cfg.Baidu) : new OpenAiCompatibleProvider(cfg.Translation); } return _provider; } } /// 配置变更后使 Provider 失效,下次访问重新读取配置。 public static void Reset() => _provider = null; /// 从指定配置创建翻译提供者(用于测试)。 public static ITranslationProvider Create(Config.ConfigRoot cfg) { return string.Equals(cfg.Translation.Provider, "baidu", StringComparison.OrdinalIgnoreCase) ? (ITranslationProvider)new BaiduTranslateProvider(cfg.Baidu) : new OpenAiCompatibleProvider(cfg.Translation); } } }