Config.cs 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. using System;
  2. using System.Collections.Generic;
  3. using System.IO;
  4. using System.Linq;
  5. using System.Text;
  6. using Newtonsoft.Json;
  7. namespace LocalizationTool.Services
  8. {
  9. /// <summary>
  10. /// 工具配置(exe 旁 LocalizationTool.config.json)。
  11. /// 语义:BaseUrl / Model / Keys / AppId / Secret 字段为空 = 使用内置默认(开箱即用);
  12. /// 非空 = 自定义优先(互斥,不混入内置 Key)。配置文件里不写入内置凭证。
  13. /// </summary>
  14. public static class Config
  15. {
  16. /// <summary>内置默认 OpenAI 兼容 Base URL。</summary>
  17. public const string BuiltInBaseUrl = "https://api.agnes-ai.cn/v1";
  18. /// <summary>内置默认模型。</summary>
  19. public const string BuiltInModel = "agnes-2.5-flash";
  20. /// <summary>
  21. /// 内置默认模型的最大上下文(token)。agnes-2.5-flash 官方规格:512K context / 65.5K max output。
  22. /// 上下文用量指示器以此估算占比;0 = 未知,不显示占比。
  23. /// </summary>
  24. public const int BuiltInMaxContextTokens = 512000;
  25. /// <summary>内置默认单次回复的最大输出 token(防止思考模式把 arguments 截断)。</summary>
  26. public const int BuiltInMaxOutputTokens = 65536;
  27. public class TranslationConfig
  28. {
  29. /// <summary>提供方:openai(OpenAI 兼容 LLM)或 baidu(百度通用翻译)</summary>
  30. public string Provider { get; set; } = "openai";
  31. /// <summary>Base URL;空 = 内置默认(BuiltInBaseUrl),非空 = 自定义优先。</summary>
  32. public string BaseUrl { get; set; } = "";
  33. /// <summary>模型名;空 = 内置默认(BuiltInModel),非空 = 自定义优先。</summary>
  34. public string Model { get; set; } = "";
  35. /// <summary>
  36. /// 加密后的 API Key 列表(多 Key 轮询:一个超时/失败自动切下一个)。
  37. /// 空列表 = 使用内置默认 Key;非空 = 只用自定义(互斥)。
  38. /// </summary>
  39. public List<string> Keys { get; set; } = new List<string>();
  40. /// <summary>
  41. /// 模型最大上下文(token),用于上下文用量指示器;0 或空 = 使用内置默认(BuiltInMaxContextTokens)。
  42. /// </summary>
  43. public int MaxContextTokens { get; set; } = 0;
  44. /// <summary>
  45. /// 单次回复最大输出 token;0 或空 = 使用内置默认(BuiltInMaxOutputTokens)。
  46. /// 思考模型若太小会把 tool call arguments 截断成非法 JSON(400)。
  47. /// </summary>
  48. public int MaxOutputTokens { get; set; } = 0;
  49. /// <summary>旧版单字符串 ApiKey(仅兼容读取,保存时不再写入)。</summary>
  50. [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
  51. public string ApiKey { get; set; }
  52. public string FromLang { get; set; } = "zh";
  53. public string ToLang { get; set; } = "en";
  54. }
  55. public class BaiduConfig
  56. {
  57. /// <summary>加密后的 APP ID</summary>
  58. public string AppId { get; set; } = "";
  59. /// <summary>加密后的密钥</summary>
  60. public string Secret { get; set; } = "";
  61. public string FromLang { get; set; } = "zh";
  62. public string ToLang { get; set; } = "en";
  63. /// <summary>翻译模式:nmt = 机器翻译(默认,快)/ llm = 大模型翻译(质量好,需开通服务)</summary>
  64. public string ModelType { get; set; } = "nmt";
  65. /// <summary>
  66. /// 自定义翻译指令(reference 字段,llm 大模型模式生效):
  67. /// 对翻译结果的要求,如保留数字/符号只翻文本。
  68. /// </summary>
  69. public string Reference { get; set; } = "数字、英文单词、单位、特殊符号和表情图标保持原样不要翻译,只翻译其中的中文文本部分,符号的位置和数量保持不变。";
  70. }
  71. /// <summary>AI 助手(对话)配置</summary>
  72. public class AiChatConfig
  73. {
  74. /// <summary>
  75. /// 工具权限模式:
  76. /// ask(默认)= 修改类工具执行前弹确认框;
  77. /// trusted = 完全信任,所有工具自动执行不弹框(全部操作仍记录日志)。
  78. /// </summary>
  79. public string PermissionMode { get; set; } = "ask";
  80. /// <summary>
  81. /// 用户自定义提示词。为空 = 使用按当前语种动态生成的默认提示词
  82. /// (默认提示词会随加载的语言包动态变化,见 AiChatWindow.BuildDefaultPrompt)。
  83. /// </summary>
  84. public string CustomPrompt { get; set; } = "";
  85. }
  86. /// <summary>AI 助手联网查资料(web_search / fetch_url 工具)配置</summary>
  87. public class WebSearchConfig
  88. {
  89. /// <summary>搜索引擎:bing(默认,免 Key)/ bocha(博查,国内,需 Key)/ tavily(海外,需 Key)</summary>
  90. public string Provider { get; set; } = "bing";
  91. public string ApiKey { get; set; } = "";
  92. /// <summary>web_search 默认返回的结果条数(1~10)</summary>
  93. public int MaxResults { get; set; } = 8;
  94. }
  95. public class ConfigRoot
  96. {
  97. public string LastDirectory { get; set; } = "";
  98. public TranslationConfig Translation { get; set; } = new TranslationConfig();
  99. public BaiduConfig Baidu { get; set; } = new BaiduConfig();
  100. public AiChatConfig AiChat { get; set; } = new AiChatConfig();
  101. public WebSearchConfig WebSearch { get; set; } = new WebSearchConfig();
  102. }
  103. private static string ConfigPath =>
  104. Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "LocalizationTool.config.json");
  105. public static ConfigRoot Load()
  106. {
  107. ConfigRoot root = null;
  108. try
  109. {
  110. if (File.Exists(ConfigPath))
  111. {
  112. root = JsonConvert.DeserializeObject<ConfigRoot>(
  113. File.ReadAllText(ConfigPath, Encoding.UTF8));
  114. }
  115. }
  116. catch { root = null; }
  117. if (root == null) root = new ConfigRoot();
  118. // 迁移:老版本曾把内置凭证密文写入配置文件 → 清空,统一"空 = 默认"语义
  119. if (MigrateLegacyDefaults(root)) Save(root);
  120. return root;
  121. }
  122. public static void Save(ConfigRoot cfg)
  123. {
  124. File.WriteAllText(ConfigPath, JsonConvert.SerializeObject(cfg, Formatting.Indented),
  125. new UTF8Encoding(false));
  126. }
  127. /// <summary>
  128. /// 内置默认凭证(已按存储方案加密的密文常量,源码/二进制中不出现明文)。
  129. /// 配置文件里这些字段为空时,运行时兜底使用本内置默认。
  130. /// </summary>
  131. private static readonly string[] BuiltInKeys =
  132. {
  133. "UqvOuuwdHj1BrKHS7QAcTH3ml9SEFUd5DJ+B/tP9IQs3d6K45O0OCGR+v4DPxR5GJmmy",
  134. "UqvOyvoCIkUemLjJwB0tU16IpMvuLhJ0X+6IqMHWIhJIbqSly+o8X11qut/ShjQPYmOq",
  135. "UqvO7sQADm8Zu6L65g1Eewa9q+TREjsme4Ky8rLWE1B7GPGa1903WkFeg7jxxQ0hQEGX",
  136. "UqvO98IJFlQYu5nT/x4BcwGCwqT6DjotcK6p/7HmFAtRTbKm65gXJ0x++drw9jgDXQmy",
  137. };
  138. private const string BuiltInBaiduAppId = "E/DRsZ19XzsZ+Nq/h2RDJgQ=";
  139. private const string BuiltInBaiduSecret = "Spiq9tQPKH5Kq4by/DUeeFaltd8=";
  140. /// <summary>内置默认 Key 的明文(解密一次缓存)。</summary>
  141. private static readonly List<string> BuiltInKeyPlain =
  142. BuiltInKeys.Select(DecryptKey).Where(k => !string.IsNullOrEmpty(k)).ToList();
  143. private static readonly string BuiltInBaiduAppIdPlain = DecryptKey(BuiltInBaiduAppId);
  144. private static readonly string BuiltInBaiduSecretPlain = DecryptKey(BuiltInBaiduSecret);
  145. /// <summary>
  146. /// 迁移旧版配置:老版本 EnsureDefaults 会把内置凭证密文写入配置文件,
  147. /// 这里把它们清空,统一为"字段为空 = 使用内置默认"的新语义。返回是否有改动。
  148. /// </summary>
  149. private static bool MigrateLegacyDefaults(ConfigRoot root)
  150. {
  151. var changed = false;
  152. var t = root.Translation ?? (root.Translation = new TranslationConfig());
  153. if (t.Keys == null) { t.Keys = new List<string>(); changed = true; }
  154. else if (t.Keys.Count > 0)
  155. {
  156. // 老版本保存时会把内置 Key 附加到自定义 Key 之后做轮询兜底(混合列表)。
  157. // 迁移:过滤掉解密后 == 内置 Key 的密文;剩下的若为空 = 未自定义。
  158. var custom = t.Keys.Where(k => !BuiltInKeyPlain.Contains(DecryptKey(k))).ToList();
  159. if (custom.Count != t.Keys.Count)
  160. {
  161. t.Keys = custom;
  162. changed = true;
  163. }
  164. }
  165. // 等于内置默认值的 BaseUrl / Model 也归一为空(保持"空 = 默认"语义)
  166. if (string.Equals(t.BaseUrl?.Trim(), BuiltInBaseUrl, StringComparison.OrdinalIgnoreCase))
  167. { t.BaseUrl = ""; changed = true; }
  168. if (string.Equals(t.Model?.Trim(), BuiltInModel, StringComparison.OrdinalIgnoreCase))
  169. { t.Model = ""; changed = true; }
  170. var b = root.Baidu ?? (root.Baidu = new BaiduConfig());
  171. if (!string.IsNullOrEmpty(b.AppId) && DecryptKey(b.AppId) == BuiltInBaiduAppIdPlain)
  172. { b.AppId = ""; changed = true; }
  173. if (!string.IsNullOrEmpty(b.Secret) && DecryptKey(b.Secret) == BuiltInBaiduSecretPlain)
  174. { b.Secret = ""; changed = true; }
  175. return changed;
  176. }
  177. // Key 混淆:位置相关 XOR + Base64。
  178. // 明文不出现在源码、二进制常量与配置文件中;解码逻辑随字节位置变化,
  179. // 直接对二进制做字符串扫描或反编译搜 "sk-" 均取不到 Key。
  180. private static byte KeyByte(int i) => (byte)(0x5A ^ ((i * 31 + 0x7B) & 0xFF));
  181. /// <summary>加密:明文 → Base64 密文(保存到配置文件用)。</summary>
  182. public static string EncryptKey(string plain)
  183. {
  184. if (string.IsNullOrEmpty(plain)) return "";
  185. var bytes = Encoding.UTF8.GetBytes(plain);
  186. for (int i = 0; i < bytes.Length; i++)
  187. bytes[i] ^= KeyByte(i);
  188. return Convert.ToBase64String(bytes);
  189. }
  190. /// <summary>
  191. /// 解密:Base64 密文 → 明文。
  192. /// 依次尝试当前方案、旧版方案;都解不开时按明文原样返回(兼容手动填写的 Key)。
  193. /// </summary>
  194. public static string DecryptKey(string encrypted)
  195. {
  196. if (string.IsNullOrEmpty(encrypted)) return "";
  197. var s = TryDecrypt(encrypted, i => KeyByte(i)); // 当前方案:位置相关 XOR
  198. if (s == null) s = TryDecrypt(encrypted, _ => 0x5A); // 旧版:固定 0x5A
  199. return s ?? encrypted;
  200. }
  201. /// <summary>按给定密钥函数尝试解码;结果含控制字符/乱码则视为失败返回 null。</summary>
  202. private static string TryDecrypt(string encrypted, Func<int, byte> keyFn)
  203. {
  204. try
  205. {
  206. var bytes = Convert.FromBase64String(encrypted);
  207. if (bytes.Length == 0) return null;
  208. for (int i = 0; i < bytes.Length; i++)
  209. bytes[i] ^= keyFn(i);
  210. var s = Encoding.UTF8.GetString(bytes);
  211. if (s.Contains('\uFFFD')) return null;
  212. foreach (var c in s)
  213. if (char.IsControl(c)) return null;
  214. return s;
  215. }
  216. catch
  217. {
  218. return null;
  219. }
  220. }
  221. /// <summary>
  222. /// 掩码显示(设置界面回显用):保留前 5 后 4,中间打码,不显示完整 Key。
  223. /// </summary>
  224. public static string MaskKey(string plain)
  225. {
  226. if (string.IsNullOrEmpty(plain)) return "";
  227. if (plain.Length <= 10) return "****";
  228. return plain.Substring(0, 5) + "…" + plain.Substring(plain.Length - 4);
  229. }
  230. /// <summary>
  231. /// 获取解密后的 OpenAI 兼容 Key 列表(轮询用),已去重。
  232. /// 空配置 = 内置默认 Key;非空 = 只用自定义(互斥,不混入内置)。
  233. /// 兼容旧版单字符串 ApiKey 字段(该字段历史为明文,直接追加)。
  234. /// </summary>
  235. public static List<string> GetOpenAiKeys(TranslationConfig cfg)
  236. {
  237. var keys = new List<string>();
  238. if (cfg != null)
  239. {
  240. if (cfg.Keys != null)
  241. {
  242. foreach (var k in cfg.Keys)
  243. {
  244. var decoded = DecryptKey(k);
  245. if (!string.IsNullOrEmpty(decoded) && !keys.Contains(decoded))
  246. keys.Add(decoded);
  247. }
  248. }
  249. if (!string.IsNullOrEmpty(cfg.ApiKey) && !keys.Contains(cfg.ApiKey))
  250. keys.Add(cfg.ApiKey);
  251. }
  252. if (keys.Count == 0) return BuiltInKeyPlain.ToList(); // 空 = 内置默认
  253. return keys;
  254. }
  255. /// <summary>
  256. /// 解析实际使用的 Base URL:配置为空 → 内置默认;非空 → 自定义(去掉末尾斜杠)。
  257. /// </summary>
  258. public static string ResolveBaseUrl(TranslationConfig cfg) =>
  259. string.IsNullOrWhiteSpace(cfg?.BaseUrl) ? BuiltInBaseUrl : cfg.BaseUrl.TrimEnd('/');
  260. /// <summary>解析实际使用的模型名:配置为空 → 内置默认;非空 → 自定义。</summary>
  261. public static string ResolveModel(TranslationConfig cfg) =>
  262. string.IsNullOrWhiteSpace(cfg?.Model) ? BuiltInModel : cfg.Model.Trim();
  263. /// <summary>解析最大上下文 token:0/空 → 内置默认(agnes-2.5-flash 512K)。</summary>
  264. public static int ResolveMaxContextTokens(TranslationConfig cfg) =>
  265. cfg != null && cfg.MaxContextTokens > 0 ? cfg.MaxContextTokens : BuiltInMaxContextTokens;
  266. /// <summary>解析单次回复最大输出 token:0/空 → 内置默认(65536)。</summary>
  267. public static int ResolveMaxOutputTokens(TranslationConfig cfg) =>
  268. cfg != null && cfg.MaxOutputTokens > 0 ? cfg.MaxOutputTokens : BuiltInMaxOutputTokens;
  269. /// <summary>
  270. /// 解密百度凭证。AppId/Secret 任一为空或两者皆未自定义 → 整体回退内置默认
  271. /// (避免只填一半导致请求失败)。非空且完整 = 只用自定义。
  272. /// </summary>
  273. public static (string appId, string secret) GetBaiduCredentials(BaiduConfig cfg)
  274. {
  275. if (cfg == null) return (BuiltInBaiduAppIdPlain, BuiltInBaiduSecretPlain);
  276. var appId = DecryptKey(cfg.AppId);
  277. var secret = DecryptKey(cfg.Secret);
  278. if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(secret))
  279. return (BuiltInBaiduAppIdPlain, BuiltInBaiduSecretPlain);
  280. return (appId, secret);
  281. }
  282. /// <summary>OpenAI 兼容 AI 是否使用内置默认(BaseUrl / Model / Keys 均未自定义)。</summary>
  283. public static bool IsDefaultOpenAi(TranslationConfig cfg) =>
  284. cfg != null &&
  285. string.IsNullOrWhiteSpace(cfg.BaseUrl) &&
  286. string.IsNullOrWhiteSpace(cfg.Model) &&
  287. (cfg.Keys == null || cfg.Keys.Count == 0) &&
  288. string.IsNullOrWhiteSpace(cfg.ApiKey);
  289. /// <summary>百度翻译是否使用内置默认凭证(AppId / Secret 均未自定义)。</summary>
  290. public static bool IsDefaultBaidu(BaiduConfig cfg) =>
  291. cfg != null &&
  292. string.IsNullOrWhiteSpace(cfg.AppId) &&
  293. string.IsNullOrWhiteSpace(cfg.Secret);
  294. /// <summary>清空 OpenAI 兼容自定义配置,恢复为内置默认。</summary>
  295. public static void ResetOpenAiToDefault(TranslationConfig cfg)
  296. {
  297. if (cfg == null) return;
  298. cfg.BaseUrl = "";
  299. cfg.Model = "";
  300. cfg.Keys = new List<string>();
  301. cfg.ApiKey = null;
  302. }
  303. /// <summary>清空百度自定义凭证,恢复为内置默认。</summary>
  304. public static void ResetBaiduToDefault(BaiduConfig cfg)
  305. {
  306. if (cfg == null) return;
  307. cfg.AppId = "";
  308. cfg.Secret = "";
  309. }
  310. }
  311. }