TranslationProvider.cs 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.Linq;
  5. using System.Net;
  6. using System.Net.Http;
  7. using System.Text;
  8. using System.Threading.Tasks;
  9. using Newtonsoft.Json.Linq;
  10. namespace LocalizationTool.Services
  11. {
  12. /// <summary>AI 请求调用工具(Function Calling)的一次聚合结果。</summary>
  13. public class AiToolCall
  14. {
  15. public string Id { get; set; }
  16. public string Name { get; set; }
  17. public string Arguments { get; set; } = "";
  18. }
  19. /// <summary>翻译提供方接口:把源文翻译到目标语言。</summary>
  20. public interface ITranslationProvider
  21. {
  22. string Name { get; }
  23. bool IsConfigured { get; }
  24. Task<string> TranslateAsync(string text, string fromLang, string toLang);
  25. }
  26. /// <summary>
  27. /// OpenAI 兼容的 Chat Completions 实现(可接 DeepSeek / Qwen / 通义 / agnes 等)。
  28. /// 配置读取 exe 旁 LocalizationTool.config.json;支持多 Key 轮询——
  29. /// 当前 Key 超时或请求失败时自动切换下一个 Key 重试,成功后轮转起点分摊负载。
  30. /// </summary>
  31. public class OpenAiCompatibleProvider : ITranslationProvider
  32. {
  33. public string Name => "OpenAI 兼容(LLM)";
  34. public bool IsConfigured =>
  35. !string.IsNullOrWhiteSpace(_baseUrl) && _keys.Count > 0;
  36. private readonly string _baseUrl;
  37. private readonly string _model;
  38. private readonly List<string> _keys;
  39. private readonly int _maxOutputTokens; // 单次回复输出上限(防思考模式截断 arguments)
  40. private int _keyIndex = 0; // 下一次请求从哪个 Key 开始(成功后向后轮转)
  41. public OpenAiCompatibleProvider(Config.TranslationConfig cfg)
  42. {
  43. // 空配置 → 运行时兜底内置默认(ResolveBaseUrl / ResolveModel / GetOpenAiKeys 内部处理)
  44. _baseUrl = Config.ResolveBaseUrl(cfg);
  45. _model = Config.ResolveModel(cfg);
  46. _keys = Config.GetOpenAiKeys(cfg);
  47. _maxOutputTokens = Config.ResolveMaxOutputTokens(cfg);
  48. }
  49. // ----- Key 健康度:失败/超时的 Key 进入冷却期并排到队尾,避免每次请求都先撞坏 Key -----
  50. private readonly Dictionary<string, DateTime> _cooldownUntil = new Dictionary<string, DateTime>();
  51. private readonly Dictionary<string, int> _failStreak = new Dictionary<string, int>();
  52. /// <summary>本次请求的 Key 顺序:从 _keyIndex 轮转;冷却中的 Key 排到队尾兜底。</summary>
  53. private List<string> OrderedKeys()
  54. {
  55. var healthy = new List<string>();
  56. var cooling = new List<string>();
  57. var now = DateTime.UtcNow;
  58. for (int i = 0; i < _keys.Count; i++)
  59. {
  60. var k = _keys[(_keyIndex + i) % _keys.Count];
  61. var isCooling = _cooldownUntil.TryGetValue(k, out var until) && until > now;
  62. (isCooling ? cooling : healthy).Add(k);
  63. }
  64. healthy.AddRange(cooling);
  65. return healthy;
  66. }
  67. private void MarkKeySuccess(string key)
  68. {
  69. _cooldownUntil.Remove(key);
  70. _failStreak[key] = 0;
  71. var idx = _keys.IndexOf(key);
  72. if (idx >= 0) _keyIndex = (idx + 1) % _keys.Count; // 成功 → 下次从下一个 Key 开始(分摊用量)
  73. }
  74. private void MarkKeyFailure(string key)
  75. {
  76. _failStreak.TryGetValue(key, out var streak);
  77. streak++;
  78. _failStreak[key] = streak;
  79. // 冷却 2 分钟起步,连续失败翻倍,上限 10 分钟
  80. var seconds = Math.Min(120d * Math.Pow(2, streak - 1), 600d);
  81. _cooldownUntil[key] = DateTime.UtcNow.AddSeconds(seconds);
  82. }
  83. public async Task<string> TranslateAsync(string text, string fromLang, string toLang)
  84. {
  85. if (string.IsNullOrWhiteSpace(text)) return "";
  86. if (_keys.Count == 0) throw new Exception("未配置任何 API Key");
  87. // 语言代码 → 自然语言描述("ja-JP" → "日语(日本語)"),LLM 对母语名称理解最准
  88. var fromName = DescribeLang(fromLang, "中文");
  89. var toName = DescribeLang(toLang, "目标语言");
  90. var payload = new
  91. {
  92. model = _model,
  93. temperature = 0.2,
  94. max_tokens = 500, // 界面文本都很短,限制输出长度,防止模型长篇大论
  95. messages = new object[]
  96. {
  97. new { role = "system", content = "你是专业软件界面本地化翻译员。直接给出译文,不要思考过程、不要解释、不要引号、不要输出原文、不要多余标点。" },
  98. new { role = "user", content = $"把下面的{fromName}直接翻译成{toName},只输出译文:\n{text}" },
  99. },
  100. };
  101. // 多 Key 轮询:超时或失败自动切下一个;近期失败过的 Key 排到队尾
  102. var errors = new List<string>();
  103. foreach (var key in OrderedKeys())
  104. {
  105. try
  106. {
  107. var result = await DoTranslateAsync(payload, key).ConfigureAwait(false);
  108. if (!string.IsNullOrEmpty(result))
  109. {
  110. MarkKeySuccess(key);
  111. return result;
  112. }
  113. errors.Add($"{Config.MaskKey(key)}: 返回为空");
  114. MarkKeyFailure(key);
  115. }
  116. catch (Exception ex)
  117. {
  118. errors.Add($"{Config.MaskKey(key)}: {ex.Message}");
  119. MarkKeyFailure(key);
  120. }
  121. }
  122. throw new Exception("所有 API Key 均失败:\n" + string.Join("\n", errors));
  123. }
  124. private async Task<string> DoTranslateAsync(object payload, string apiKey)
  125. {
  126. using (var client = new HttpClient())
  127. {
  128. client.Timeout = TimeSpan.FromSeconds(30);
  129. client.DefaultRequestHeaders.Authorization =
  130. new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
  131. var body = new StringContent(payload.ToJson(), Encoding.UTF8, "application/json");
  132. var resp = await client.PostAsync(_baseUrl + "/chat/completions", body).ConfigureAwait(false);
  133. if (!resp.IsSuccessStatusCode)
  134. throw new Exception($"翻译请求失败: {(int)resp.StatusCode} {await resp.Content.ReadAsStringAsync().ConfigureAwait(false)}");
  135. var json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
  136. var j = JObject.Parse(json);
  137. var result = (string)j["choices"]?[0]?["message"]?["content"];
  138. return (result ?? "").Trim();
  139. }
  140. }
  141. /// <summary>
  142. /// 语言代码 → 自然语言名称(查语言目录):"ja-JP" → "日语(日本語)"。
  143. /// 查不到时按主代码再试一次;仍找不到返回 fallback。
  144. /// </summary>
  145. private static string DescribeLang(string langCode, string fallback)
  146. {
  147. if (string.IsNullOrWhiteSpace(langCode)) return fallback;
  148. var entry = LanguageCatalog.Find(langCode);
  149. if (entry == null)
  150. {
  151. var main = langCode.Split('-')[0].ToLowerInvariant();
  152. entry = LanguageCatalog.All.FirstOrDefault(e =>
  153. string.Equals(e.Code.Split('-')[0], main, StringComparison.OrdinalIgnoreCase));
  154. }
  155. if (entry == null) return fallback;
  156. return string.Equals(entry.Name, entry.ChineseName, StringComparison.Ordinal)
  157. ? entry.ChineseName // 小众语言:直接中文名,如 "日语"→"爱尔兰语"
  158. : $"{entry.ChineseName}({entry.Name})"; // 主流语言:日语(日本語)
  159. }
  160. /// <summary>
  161. /// 流式对话(OpenAI 兼容 SSE,支持 Function Calling),多 Key 轮询:
  162. /// 当前 Key 超时/请求失败时自动切换下一个 Key 重试;
  163. /// 若已向界面输出过增量内容则不重试(避免内容重复),直接抛错。
  164. /// - onContent:正文增量回调(真实增量,非模拟)
  165. /// - onReasoning:思考过程增量回调(reasoning_content / reasoning)
  166. /// - tools:OpenAI tools 定义(可空);模型请求调用工具时返回聚合后的 toolCalls
  167. /// 返回 (正文, 工具调用列表)。toolCalls 非空时,调用方执行工具后把结果以 role=tool 消息续传。
  168. /// 注意:回调在后台线程触发,UI 更新需自行 Dispatcher 调度。
  169. /// </summary>
  170. public async Task<(string content, List<AiToolCall> toolCalls)> StreamChatAsync(
  171. JArray messages,
  172. Action<string> onContent,
  173. Action<string> onReasoning = null,
  174. JArray tools = null,
  175. System.Threading.CancellationToken ct = default)
  176. {
  177. if (_keys.Count == 0) throw new Exception("未配置任何 API Key");
  178. // 输出上限走配置:思考模型若太小,tool call arguments 会被截断成非法 JSON(400)。
  179. // agnes-2.5-flash 官方规格 max output 65.5K。
  180. var maxOutput = _maxOutputTokens;
  181. object payload = tools == null || tools.Count == 0
  182. ? (object)new
  183. {
  184. model = _model,
  185. temperature = 0.3,
  186. stream = true,
  187. max_tokens = maxOutput,
  188. messages,
  189. // 启用 Thinking 模式(agnes 官方文档:chat_template_kwargs.enable_thinking = true)
  190. chat_template_kwargs = new { enable_thinking = true },
  191. }
  192. : (object)new
  193. {
  194. model = _model,
  195. temperature = 0.3,
  196. stream = true,
  197. max_tokens = maxOutput,
  198. messages,
  199. tools,
  200. chat_template_kwargs = new { enable_thinking = true },
  201. };
  202. var contentSb = new StringBuilder();
  203. var delivered = false; // 是否已向界面交付过增量(交付后失败不能换 Key 重试,否则内容重复)
  204. var errors = new List<string>();
  205. foreach (var key in OrderedKeys())
  206. {
  207. try
  208. {
  209. var (calls, finishReason) = await StreamChatOnceAsync(payload, key, contentSb,
  210. d => { delivered = true; onContent?.Invoke(d); },
  211. r => { delivered = true; onReasoning?.Invoke(r); },
  212. ct).ConfigureAwait(false);
  213. var ordered = OrderedToolCalls(calls);
  214. if (finishReason == "tool_calls" && ordered.Count == 0)
  215. throw new Exception("模型请求调用工具但未返回有效的 tool_calls 数据");
  216. MarkKeySuccess(key);
  217. return (contentSb.ToString(), ordered);
  218. }
  219. catch (OperationCanceledException) when (ct.IsCancellationRequested)
  220. {
  221. throw; // 用户主动停止:不换 Key 重试
  222. }
  223. catch (Exception ex)
  224. {
  225. MarkKeyFailure(key);
  226. if (delivered) throw; // 已输出过内容,重试会造成界面内容重复
  227. errors.Add($"{Config.MaskKey(key)}: {ex.Message}");
  228. }
  229. }
  230. throw new Exception("所有 API Key 均失败:\n" + string.Join("\n", errors));
  231. }
  232. private static List<AiToolCall> OrderedToolCalls(Dictionary<int, AiToolCall> toolCalls) =>
  233. toolCalls.OrderBy(kv => kv.Key).Select(kv => kv.Value).ToList();
  234. /// <summary>
  235. /// SendAsync 但仅对"响应头等待"限时 30 秒;拿到响应后立刻解除限时,
  236. /// 之后正文流式读取不受该定时器影响(整体仍受 client.Timeout 5 分钟约束)。
  237. /// </summary>
  238. private static async Task<HttpResponseMessage> SendWithHeaderTimeoutAsync(
  239. HttpClient client, HttpRequestMessage req,
  240. System.Threading.CancellationTokenSource headerCts)
  241. {
  242. try
  243. {
  244. headerCts.CancelAfter(TimeSpan.FromSeconds(30));
  245. return await client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, headerCts.Token)
  246. .ConfigureAwait(false);
  247. }
  248. finally
  249. {
  250. headerCts.CancelAfter(System.Threading.Timeout.InfiniteTimeSpan);
  251. }
  252. }
  253. /// <summary>用单个 Key 执行一次流式请求(SSE 解析 + tool_calls 增量聚合)。</summary>
  254. private async Task<(Dictionary<int, AiToolCall> toolCalls, string finishReason)> StreamChatOnceAsync(
  255. object payload,
  256. string apiKey,
  257. StringBuilder contentSb,
  258. Action<string> onContent,
  259. Action<string> onReasoning,
  260. System.Threading.CancellationToken ct)
  261. {
  262. var toolCalls = new Dictionary<int, AiToolCall>();
  263. string finishReason = null;
  264. using (var client = new HttpClient())
  265. {
  266. client.Timeout = TimeSpan.FromMinutes(5);
  267. client.DefaultRequestHeaders.Authorization =
  268. new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", apiKey);
  269. var body = new StringContent(payload.ToJson(), Encoding.UTF8, "application/json");
  270. // ResponseHeadersRead:不等整个响应体,边下边读 SSE
  271. // (net48 的 PostAsync 没有 HttpCompletionOption 重载,须用 SendAsync + HttpRequestMessage)
  272. using (var req = new HttpRequestMessage(HttpMethod.Post, _baseUrl + "/chat/completions") { Content = body })
  273. // 取消令牌:停止时中断进行中的网络请求(CancelPendingRequests)
  274. using (ct.Register(() => client.CancelPendingRequests()))
  275. // 响应头 30 秒内没到 → 判定该 Key 挂死(抛超时 → 上层冷却该 Key、换下一个),
  276. // 而不是干等 client.Timeout 的 5 分钟;流式正文读取不受此 30 秒限制
  277. using (var headerCts = System.Threading.CancellationTokenSource.CreateLinkedTokenSource(ct))
  278. using (var resp = await SendWithHeaderTimeoutAsync(client, req, headerCts).ConfigureAwait(false))
  279. {
  280. if (!resp.IsSuccessStatusCode)
  281. throw new Exception($"请求失败: {(int)resp.StatusCode} {await resp.Content.ReadAsStringAsync().ConfigureAwait(false)}");
  282. using (var stream = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false))
  283. using (var reader = new StreamReader(stream, Encoding.UTF8))
  284. {
  285. string line;
  286. while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
  287. {
  288. if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data:", StringComparison.Ordinal))
  289. continue;
  290. var data = line.Substring(5).Trim();
  291. if (data == "[DONE]") break;
  292. JObject j;
  293. try { j = JObject.Parse(data); } catch { continue; }
  294. var choice = j["choices"]?[0];
  295. var delta = choice?["delta"];
  296. // 思考过程:DeepSeek R1 风格 reasoning_content,兼容 reasoning 字段
  297. var reasoning = (string)delta?["reasoning_content"] ?? (string)delta?["reasoning"];
  298. if (!string.IsNullOrEmpty(reasoning))
  299. onReasoning?.Invoke(reasoning);
  300. var content = (string)delta?["content"];
  301. if (!string.IsNullOrEmpty(content))
  302. {
  303. contentSb.Append(content);
  304. onContent?.Invoke(content);
  305. }
  306. // Function Calling:tool_calls 增量按 index 聚合(arguments 分片到达)
  307. if (delta?["tool_calls"] is JArray tcArr)
  308. {
  309. foreach (var tc in tcArr)
  310. {
  311. var idx = tc["index"] != null ? (int)tc["index"] : 0;
  312. if (!toolCalls.TryGetValue(idx, out var item))
  313. {
  314. item = new AiToolCall
  315. {
  316. Id = (string)tc["id"] ?? $"call_{idx}",
  317. };
  318. if (tc["function"]?["name"] != null)
  319. item.Name = (string)tc["function"]["name"];
  320. toolCalls[idx] = item;
  321. }
  322. if (tc["id"] != null) item.Id = (string)tc["id"];
  323. if (tc["function"]?["name"] != null) item.Name = (string)tc["function"]["name"];
  324. if (tc["function"]?["arguments"] != null)
  325. item.Arguments += (string)tc["function"]["arguments"];
  326. }
  327. }
  328. if (choice?["finish_reason"] != null)
  329. finishReason = (string)choice["finish_reason"];
  330. }
  331. }
  332. }
  333. }
  334. return (toolCalls, finishReason);
  335. }
  336. }
  337. internal static class JsonExtensions
  338. {
  339. public static string ToJson(this object o)
  340. {
  341. return Newtonsoft.Json.JsonConvert.SerializeObject(o);
  342. }
  343. }
  344. /// <summary>上下文用量估算(AI 助手用量指示器用)。</summary>
  345. public static class ContextEstimator
  346. {
  347. /// <summary>
  348. /// 估算一段文本的 token 数:中文约 1 token/字,英文约 4 字符/token。
  349. /// 取折中 2 字符 ≈ 1 token(粗略但够用,用于占比显示)。
  350. /// </summary>
  351. public static int EstimateTokens(string text)
  352. {
  353. if (string.IsNullOrEmpty(text)) return 0;
  354. return Math.Max(1, text.Length / 2);
  355. }
  356. /// <summary>估算整组消息(含工具调用与结果)的 token 数。</summary>
  357. public static int EstimateMessagesTokens(JArray messages)
  358. {
  359. int total = 0;
  360. foreach (var m in messages)
  361. {
  362. var obj = m as JObject;
  363. if (obj == null) continue;
  364. total += EstimateTokens(obj["content"]?.ToString() ?? "");
  365. if (obj["tool_calls"] is JArray calls)
  366. foreach (var c in calls)
  367. total += EstimateTokens(c["function"]?["arguments"]?.ToString() ?? "");
  368. }
  369. return total;
  370. }
  371. /// <summary>格式化 token 数(如 12,345 → "12.3K")。</summary>
  372. public static string FormatTokens(int tokens) =>
  373. tokens >= 1000 ? $"{tokens / 1000.0:0.#}K" : tokens.ToString();
  374. }
  375. /// <summary>
  376. /// 百度通用翻译 API 实现。需 appid + 密钥(安全密钥),签名 = md5(appid+q+salt+secret)。
  377. /// 语言码映射:en-US→en、ja-JP→jp、ko-KR→kor、zh-CN→zh、fr→fra 等。
  378. /// <summary>
  379. /// 百度翻译(官方 doc/21 通用文本翻译 API,大模型驱动)。
  380. /// 接口:https://fanyi-api.baidu.com/api/trans/vip/translate
  381. /// 签名:sign = md5(appid + q + salt + 密钥),q 为原始文本(未 URL 编码)。
  382. /// 批量:官方多 query —— q 内用 \n 分隔多条,一次请求翻多条(响应 trans_result 按行对应)。
  383. /// </summary>
  384. public class BaiduTranslateProvider : ITranslationProvider
  385. {
  386. public string Name => "百度翻译";
  387. public bool IsConfigured =>
  388. !string.IsNullOrWhiteSpace(_appid) && !string.IsNullOrWhiteSpace(_secret);
  389. private readonly string _appid;
  390. private readonly string _secret;
  391. /// <summary>单次批量请求的最大条数(控制 q 长度,避免超 6000 字节限制)。</summary>
  392. public const int BatchSize = 20;
  393. private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
  394. /// <summary>翻译模式:nmt = 机器翻译(默认,快)/ llm = 大模型翻译(质量好)。</summary>
  395. public string ModelType { get; set; } = "nmt";
  396. /// <summary>自定义翻译指令(reference 字段):对翻译结果的要求,如保留数字/符号只翻文本。</summary>
  397. public string Reference { get; set; } = "";
  398. public BaiduTranslateProvider(Config.BaiduConfig cfg)
  399. {
  400. var creds = Config.GetBaiduCredentials(cfg);
  401. _appid = (creds.appId ?? "").Trim();
  402. _secret = (creds.secret ?? "").Trim();
  403. // nmt / llm(非法值兜底 nmt)
  404. var mt = (cfg.ModelType ?? "").Trim().ToLowerInvariant();
  405. ModelType = mt == "llm" ? "llm" : "nmt";
  406. Reference = (cfg.Reference ?? "").Trim();
  407. }
  408. public async Task<string> TranslateAsync(string text, string fromLang, string toLang)
  409. {
  410. if (string.IsNullOrWhiteSpace(text)) return "";
  411. var results = await TranslateBatchAsync(new[] { text }, fromLang, toLang).ConfigureAwait(false);
  412. return results[0];
  413. }
  414. /// <summary>
  415. /// 批量翻译(官方多 query:q 用 \n 分隔多条,一次请求翻一批,返回与输入一一对应的译文列表)。
  416. /// </summary>
  417. public async Task<List<string>> TranslateBatchAsync(IList<string> texts, string fromLang, string toLang)
  418. {
  419. var from = MapLang(fromLang);
  420. var to = MapLang(toLang);
  421. // 多 query:换行分隔(词条本身不含换行,安全)
  422. var q = string.Join("\n", texts);
  423. var salt = new Random().Next(100000).ToString();
  424. // 签名用原始 q(含换行,未编码)
  425. var sign = Md5(_appid + q + salt + _secret);
  426. var form = new Dictionary<string, string>
  427. {
  428. ["q"] = q,
  429. ["from"] = from,
  430. ["to"] = to,
  431. ["appid"] = _appid,
  432. ["salt"] = salt,
  433. ["sign"] = sign,
  434. ["model_type"] = ModelType ?? "nmt", // nmt = 机器翻译(默认)/ llm = 大模型翻译
  435. };
  436. // 自定义翻译指令(reference):llm 大模型模式生效——要求保留数字/符号只翻文本
  437. if (!string.IsNullOrWhiteSpace(Reference))
  438. form["reference"] = Reference;
  439. var resp = await _http.PostAsync(
  440. "https://fanyi-api.baidu.com/ait/api/aiTextTranslate",
  441. new FormUrlEncodedContent(form)).ConfigureAwait(false);
  442. var body = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
  443. JObject j;
  444. try { j = JObject.Parse(body); }
  445. catch { throw new Exception($"百度翻译响应解析失败: {Truncate(body, 200)}"); }
  446. if (j["error_code"] != null)
  447. {
  448. var code = j["error_code"].ToString();
  449. var msg = j["error_msg"]?.ToString() ?? "未知错误";
  450. throw new BaiduApiException(code, BaiduApiException.DescribeError(code, msg));
  451. }
  452. var arr = j["trans_result"] as JArray;
  453. var list = new List<string>();
  454. if (arr != null)
  455. foreach (var item in arr)
  456. list.Add(((string)item["dst"] ?? "").Trim());
  457. // 保证与输入一一对应(不足补空)
  458. while (list.Count < texts.Count) list.Add("");
  459. return list;
  460. }
  461. /// <summary>系统语言代码 → 百度语言代码(查 LanguageCatalog 映射表,如 "ja-JP" → "jp")。</summary>
  462. private static string MapLang(string lang) => LanguageCatalog.GetBaiduCode(lang);
  463. private static string Md5(string input)
  464. {
  465. using (var md5 = System.Security.Cryptography.MD5.Create())
  466. {
  467. var bytes = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input));
  468. var sb = new System.Text.StringBuilder();
  469. foreach (var b in bytes) sb.Append(b.ToString("x2"));
  470. return sb.ToString();
  471. }
  472. }
  473. private static string Truncate(string s, int max) =>
  474. string.IsNullOrEmpty(s) || s.Length <= max ? s : s.Substring(0, max) + "...";
  475. }
  476. /// <summary>百度翻译 API 异常:携带原始错误码,便于上层精确处理(如限流重试、敏感词识别)。</summary>
  477. public class BaiduApiException : Exception
  478. {
  479. public string Code { get; }
  480. public BaiduApiException(string code, string message) : base(message) { Code = code; }
  481. /// <summary>百度错误码 → 中文说明(来源:官方文档错误码列表 + 实测)。</summary>
  482. public static string DescribeError(string code, string raw)
  483. {
  484. switch (code)
  485. {
  486. case "20003": return "请求内容存在安全风险:文本触发百度内容安全审核被拦截(某些品牌名/词组会被误判,如 FANUC),建议该条人工翻译";
  487. case "52001": return "请求超时,请重试";
  488. case "52002": return "百度服务超时,请重试";
  489. case "52003": return "未授权用户:APP ID 或密钥不正确";
  490. case "54000": return "必填参数为空";
  491. case "54001": return "签名错误:请检查 APP ID / 密钥是否正确";
  492. case "54003": return "访问频率受限(QPS 超限):稍后重试或升级高级版";
  493. case "54004": return "账户余额不足,请充值字符包";
  494. case "54005": return "请求过于频繁,请稍后重试";
  495. case "58001": return "目标语言不符合翻译规则(该语言方向不支持)";
  496. case "58002": return "服务当前已停用";
  497. case "58003": return "文本包含敏感词被拦截";
  498. case "59000": return "没有权限";
  499. case "90107": return "认证失败";
  500. default: return $"{raw} (code {code})";
  501. }
  502. }
  503. }
  504. /// <summary>翻译入口:按配置选择 Provider,未配置时返回空并提示。</summary>
  505. public static class Translation
  506. {
  507. private static ITranslationProvider _provider;
  508. public static ITranslationProvider Provider
  509. {
  510. get
  511. {
  512. if (_provider == null)
  513. {
  514. var cfg = Config.Load();
  515. _provider = string.Equals(cfg.Translation.Provider, "baidu", StringComparison.OrdinalIgnoreCase)
  516. ? (ITranslationProvider)new BaiduTranslateProvider(cfg.Baidu)
  517. : new OpenAiCompatibleProvider(cfg.Translation);
  518. }
  519. return _provider;
  520. }
  521. }
  522. /// <summary>配置变更后使 Provider 失效,下次访问重新读取配置。</summary>
  523. public static void Reset() => _provider = null;
  524. /// <summary>从指定配置创建翻译提供者(用于测试)。</summary>
  525. public static ITranslationProvider Create(Config.ConfigRoot cfg)
  526. {
  527. return string.Equals(cfg.Translation.Provider, "baidu", StringComparison.OrdinalIgnoreCase)
  528. ? (ITranslationProvider)new BaiduTranslateProvider(cfg.Baidu)
  529. : new OpenAiCompatibleProvider(cfg.Translation);
  530. }
  531. }
  532. }