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);
}
}
}