| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600 |
- 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
- {
- /// <summary>AI 请求调用工具(Function Calling)的一次聚合结果。</summary>
- public class AiToolCall
- {
- public string Id { get; set; }
- public string Name { get; set; }
- public string Arguments { get; set; } = "";
- }
- /// <summary>翻译提供方接口:把源文翻译到目标语言。</summary>
- public interface ITranslationProvider
- {
- string Name { get; }
- bool IsConfigured { get; }
- Task<string> TranslateAsync(string text, string fromLang, string toLang);
- }
- /// <summary>
- /// OpenAI 兼容的 Chat Completions 实现(可接 DeepSeek / Qwen / 通义 / agnes 等)。
- /// 配置读取 exe 旁 LocalizationTool.config.json;支持多 Key 轮询——
- /// 当前 Key 超时或请求失败时自动切换下一个 Key 重试,成功后轮转起点分摊负载。
- /// </summary>
- 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<string> _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<string, DateTime> _cooldownUntil = new Dictionary<string, DateTime>();
- private readonly Dictionary<string, int> _failStreak = new Dictionary<string, int>();
- /// <summary>本次请求的 Key 顺序:从 _keyIndex 轮转;冷却中的 Key 排到队尾兜底。</summary>
- private List<string> OrderedKeys()
- {
- var healthy = new List<string>();
- var cooling = new List<string>();
- 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<string> 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<string>();
- 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<string> 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();
- }
- }
- /// <summary>
- /// 语言代码 → 自然语言名称(查语言目录):"ja-JP" → "日语(日本語)"。
- /// 查不到时按主代码再试一次;仍找不到返回 fallback。
- /// </summary>
- 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})"; // 主流语言:日语(日本語)
- }
- /// <summary>
- /// 流式对话(OpenAI 兼容 SSE,支持 Function Calling),多 Key 轮询:
- /// 当前 Key 超时/请求失败时自动切换下一个 Key 重试;
- /// 若已向界面输出过增量内容则不重试(避免内容重复),直接抛错。
- /// - onContent:正文增量回调(真实增量,非模拟)
- /// - onReasoning:思考过程增量回调(reasoning_content / reasoning)
- /// - tools:OpenAI tools 定义(可空);模型请求调用工具时返回聚合后的 toolCalls
- /// 返回 (正文, 工具调用列表)。toolCalls 非空时,调用方执行工具后把结果以 role=tool 消息续传。
- /// 注意:回调在后台线程触发,UI 更新需自行 Dispatcher 调度。
- /// </summary>
- public async Task<(string content, List<AiToolCall> toolCalls)> StreamChatAsync(
- JArray messages,
- Action<string> onContent,
- Action<string> 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<string>();
- 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<AiToolCall> OrderedToolCalls(Dictionary<int, AiToolCall> toolCalls) =>
- toolCalls.OrderBy(kv => kv.Key).Select(kv => kv.Value).ToList();
- /// <summary>
- /// SendAsync 但仅对"响应头等待"限时 30 秒;拿到响应后立刻解除限时,
- /// 之后正文流式读取不受该定时器影响(整体仍受 client.Timeout 5 分钟约束)。
- /// </summary>
- private static async Task<HttpResponseMessage> 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);
- }
- }
- /// <summary>用单个 Key 执行一次流式请求(SSE 解析 + tool_calls 增量聚合)。</summary>
- private async Task<(Dictionary<int, AiToolCall> toolCalls, string finishReason)> StreamChatOnceAsync(
- object payload,
- string apiKey,
- StringBuilder contentSb,
- Action<string> onContent,
- Action<string> onReasoning,
- System.Threading.CancellationToken ct)
- {
- var toolCalls = new Dictionary<int, AiToolCall>();
- 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);
- }
- }
- /// <summary>上下文用量估算(AI 助手用量指示器用)。</summary>
- public static class ContextEstimator
- {
- /// <summary>
- /// 估算一段文本的 token 数:中文约 1 token/字,英文约 4 字符/token。
- /// 取折中 2 字符 ≈ 1 token(粗略但够用,用于占比显示)。
- /// </summary>
- public static int EstimateTokens(string text)
- {
- if (string.IsNullOrEmpty(text)) return 0;
- return Math.Max(1, text.Length / 2);
- }
- /// <summary>估算整组消息(含工具调用与结果)的 token 数。</summary>
- 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;
- }
- /// <summary>格式化 token 数(如 12,345 → "12.3K")。</summary>
- public static string FormatTokens(int tokens) =>
- tokens >= 1000 ? $"{tokens / 1000.0:0.#}K" : tokens.ToString();
- }
- /// <summary>
- /// 百度通用翻译 API 实现。需 appid + 密钥(安全密钥),签名 = md5(appid+q+salt+secret)。
- /// 语言码映射:en-US→en、ja-JP→jp、ko-KR→kor、zh-CN→zh、fr→fra 等。
- /// <summary>
- /// 百度翻译(官方 doc/21 通用文本翻译 API,大模型驱动)。
- /// 接口:https://fanyi-api.baidu.com/api/trans/vip/translate
- /// 签名:sign = md5(appid + q + salt + 密钥),q 为原始文本(未 URL 编码)。
- /// 批量:官方多 query —— q 内用 \n 分隔多条,一次请求翻多条(响应 trans_result 按行对应)。
- /// </summary>
- public class BaiduTranslateProvider : ITranslationProvider
- {
- public string Name => "百度翻译";
- public bool IsConfigured =>
- !string.IsNullOrWhiteSpace(_appid) && !string.IsNullOrWhiteSpace(_secret);
- private readonly string _appid;
- private readonly string _secret;
- /// <summary>单次批量请求的最大条数(控制 q 长度,避免超 6000 字节限制)。</summary>
- public const int BatchSize = 20;
- private static readonly HttpClient _http = new HttpClient { Timeout = TimeSpan.FromSeconds(30) };
- /// <summary>翻译模式:nmt = 机器翻译(默认,快)/ llm = 大模型翻译(质量好)。</summary>
- public string ModelType { get; set; } = "nmt";
- /// <summary>自定义翻译指令(reference 字段):对翻译结果的要求,如保留数字/符号只翻文本。</summary>
- 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<string> 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];
- }
- /// <summary>
- /// 批量翻译(官方多 query:q 用 \n 分隔多条,一次请求翻一批,返回与输入一一对应的译文列表)。
- /// </summary>
- public async Task<List<string>> TranslateBatchAsync(IList<string> 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<string, string>
- {
- ["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<string>();
- if (arr != null)
- foreach (var item in arr)
- list.Add(((string)item["dst"] ?? "").Trim());
- // 保证与输入一一对应(不足补空)
- while (list.Count < texts.Count) list.Add("");
- return list;
- }
- /// <summary>系统语言代码 → 百度语言代码(查 LanguageCatalog 映射表,如 "ja-JP" → "jp")。</summary>
- 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) + "...";
- }
- /// <summary>百度翻译 API 异常:携带原始错误码,便于上层精确处理(如限流重试、敏感词识别)。</summary>
- public class BaiduApiException : Exception
- {
- public string Code { get; }
- public BaiduApiException(string code, string message) : base(message) { Code = code; }
- /// <summary>百度错误码 → 中文说明(来源:官方文档错误码列表 + 实测)。</summary>
- 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})";
- }
- }
- }
- /// <summary>翻译入口:按配置选择 Provider,未配置时返回空并提示。</summary>
- 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;
- }
- }
- /// <summary>配置变更后使 Provider 失效,下次访问重新读取配置。</summary>
- public static void Reset() => _provider = null;
- /// <summary>从指定配置创建翻译提供者(用于测试)。</summary>
- 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);
- }
- }
- }
|