MainViewModel.AiTools.cs 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Text;
  5. using System.Threading.Tasks;
  6. using System.Windows;
  7. using LocalizationTool.Models;
  8. using LocalizationTool.Services;
  9. using Newtonsoft.Json.Linq;
  10. namespace LocalizationTool.ViewModels
  11. {
  12. public partial class MainViewModel
  13. {
  14. private const Newtonsoft.Json.Formatting FormattingNone = Newtonsoft.Json.Formatting.None;
  15. /// <summary>AI 助手可用工具集(OpenAI Function Calling schema)。</summary>
  16. public JArray BuildAiTools()
  17. {
  18. JObject Fn(string name, string desc, JObject props, params string[] required) => new JObject
  19. {
  20. ["type"] = "function",
  21. ["function"] = new JObject
  22. {
  23. ["name"] = name,
  24. ["description"] = desc,
  25. ["parameters"] = new JObject
  26. {
  27. ["type"] = "object",
  28. ["properties"] = props,
  29. ["required"] = new JArray(required),
  30. },
  31. },
  32. };
  33. return new JArray
  34. {
  35. Fn("list_languages", "列出当前加载的所有目标语言及各语言词条数/缺译数", new JObject()),
  36. Fn("get_stats", "获取词条统计:总数、各语言已译/缺译数量", new JObject()),
  37. Fn("search_entries", "按关键词搜索词条(匹配中文原文,返回最多 20 条含各语言译文)",
  38. new JObject { ["keyword"] = new JObject { ["type"] = "string", ["description"] = "搜索关键词" } }, "keyword"),
  39. Fn("get_entry", "读取某个词条的全部语言译文",
  40. new JObject { ["key"] = new JObject { ["type"] = "string", ["description"] = "中文原文(词条 key)" } }, "key"),
  41. Fn("add_entry", "新增一条词条:中文 key + 各语言译文(已存在则报错)。写入后自动保存并刷新表格",
  42. new JObject
  43. {
  44. ["key"] = new JObject { ["type"] = "string", ["description"] = "中文原文(key)" },
  45. ["translations"] = new JObject { ["type"] = "object", ["description"] = "各语言译文,键为语言代码(如 en-US/ja-JP),可只给部分语言,留空的为缺译" },
  46. }, "key"),
  47. Fn("add_entries", "批量新增多个词条(key 已存在则跳过该条)。添加后自动保存并刷新表格。用户提供待拆分内容时,先按分隔符拆分、逐条翻译后用本工具一次性添加",
  48. new JObject
  49. {
  50. ["entries"] = new JObject
  51. {
  52. ["type"] = "array",
  53. ["description"] = "词条数组,每项 { \"key\": \"中文原文\", \"translations\": { \"en-US\": \"...\", \"ja-JP\": \"...\" } }",
  54. ["items"] = new JObject
  55. {
  56. ["type"] = "object",
  57. ["properties"] = new JObject
  58. {
  59. ["key"] = new JObject { ["type"] = "string", ["description"] = "中文原文(key)" },
  60. ["translations"] = new JObject { ["type"] = "object", ["description"] = "各语言译文" },
  61. },
  62. ["required"] = new JArray("key", "translations"),
  63. },
  64. },
  65. }, "entries"),
  66. Fn("update_translation", "更新某词条某语言的译文(不存在则创建该译文)。写入后自动保存并刷新表格",
  67. new JObject
  68. {
  69. ["key"] = new JObject { ["type"] = "string", ["description"] = "中文原文(词条 key)" },
  70. ["lang"] = new JObject { ["type"] = "string", ["description"] = "语言代码(如 en-US)" },
  71. ["value"] = new JObject { ["type"] = "string", ["description"] = "新译文" },
  72. }, "key", "lang", "value"),
  73. Fn("delete_entry", "删除一个词条(所有语言包中移除,需谨慎)",
  74. new JObject { ["key"] = new JObject { ["type"] = "string", ["description"] = "中文原文(词条 key)" } }, "key"),
  75. Fn("delete_entries", "批量删除多个词条(所有语言包中移除,只弹一次确认框)。keys 与 indices 至少提供一个;删除多个词条时务必用本工具一次性完成,不要逐条调用 delete_entry",
  76. new JObject
  77. {
  78. ["keys"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["type"] = "string" }, ["description"] = "要删除的词条 key(中文原文)数组" },
  79. ["indices"] = new JObject { ["type"] = "array", ["items"] = new JObject { ["type"] = "integer" }, ["description"] = "要删除的表格序号(# 列)数组,如 [3,5,8]" },
  80. }),
  81. Fn("get_validation_issues", "获取最近一次校验发现的所有问题词条(含原文、语言、当前错误译文、问题原因)。用于批量重译:拿到后一次性调用 batch_update_translations 写回全部修正。注意:日语等豁免语言中译文与中文原文相同的汉字词属正常写法,已自动排除,无需重译",
  82. new JObject()),
  83. Fn("batch_update_translations", "批量写回多个词条的多个语言译文(推荐用于批量重译——一次调用完成全部修正,不要逐条调用 update_translation)",
  84. new JObject
  85. {
  86. ["entries"] = new JObject
  87. {
  88. ["type"] = "array",
  89. ["description"] = "词条数组,每项 { \"key\": \"中文原文\", \"translations\": { \"en-US\": \"...\", \"ja-JP\": \"...\" } }",
  90. ["items"] = new JObject
  91. {
  92. ["type"] = "object",
  93. ["properties"] = new JObject
  94. {
  95. ["key"] = new JObject { ["type"] = "string", ["description"] = "中文原文(词条 key)" },
  96. ["translations"] = new JObject { ["type"] = "object", ["description"] = "该词条要更新的各语言译文" },
  97. },
  98. ["required"] = new JArray("key", "translations"),
  99. },
  100. },
  101. }, "entries"),
  102. Fn("save_all", "把所有语言包保存到磁盘(.dat)", new JObject()),
  103. Fn("get_missing_translations", "列出缺译词条(含表格序号 index、中文 key)。lang 可选:只看某语言;不传返回所有语言的缺译",
  104. new JObject
  105. {
  106. ["lang"] = new JObject { ["type"] = "string", ["description"] = "语言代码(如 en-US),可选" },
  107. }),
  108. Fn("baidu_translate_missing", "用百度翻译批量补译缺译词条并保存(一次调用完成,内部自动分批)。lang 必填;indices 可选——按表格序号只补译指定行,缺省翻译该语言全部缺译",
  109. new JObject
  110. {
  111. ["lang"] = new JObject { ["type"] = "string", ["description"] = "目标语言代码(如 en-US)" },
  112. ["indices"] = new JObject { ["type"] = "array", ["description"] = "表格序号数组(可选),如 [3,5,8]" },
  113. }, "lang"),
  114. Fn("delete_entry_by_index", "按表格序号删除单个词条(会弹出确认框,用户确认后才执行)",
  115. new JObject { ["index"] = new JObject { ["type"] = "integer", ["description"] = "表格序号(# 列)" } }, "index"),
  116. Fn("read_file", "读取任意路径的文本文件内容(自动检测 UTF-8 / GBK)。适合查看代码、配置、日志",
  117. new JObject { ["path"] = new JObject { ["type"] = "string", ["description"] = "文件完整路径,如 F:\\Team\\TeamAAS\\Some\\File.cs" } }, "path"),
  118. Fn("write_file", "写入(覆盖)任意路径的文本文件,父目录不存在会自动创建。适合生成新文件、整文件重写",
  119. new JObject
  120. {
  121. ["path"] = new JObject { ["type"] = "string", ["description"] = "文件完整路径" },
  122. ["content"] = new JObject { ["type"] = "string", ["description"] = "要写入的完整文件内容" },
  123. }, "path", "content"),
  124. Fn("edit_file", "对任意路径的文本文件做局部替换(old_text 在文件中必须唯一出现一次,多/零次都会失败)。适合改代码:先 read_file 拿到内容,再精确替换",
  125. new JObject
  126. {
  127. ["path"] = new JObject { ["type"] = "string", ["description"] = "文件完整路径" },
  128. ["old_text"] = new JObject { ["type"] = "string", ["description"] = "被替换的原文(必须唯一出现)" },
  129. ["new_text"] = new JObject { ["type"] = "string", ["description"] = "替换后的新文本" },
  130. }, "path", "old_text", "new_text"),
  131. Fn("list_directory", "列出目录下的子目录与文件(自动跳过 bin / obj / .git / node_modules 等无关目录)。适合先摸清项目结构",
  132. new JObject { ["path"] = new JObject { ["type"] = "string", ["description"] = "目录完整路径" } }, "path"),
  133. Fn("run_command", "执行 cmd 或 dotnet 命令并获取输出。适合查询系统信息、目录内容、git 操作、编译等",
  134. new JObject
  135. {
  136. ["command"] = new JObject { ["type"] = "string", ["description"] = "要执行的命令,如 \"dir\" 或 \"--version\"" },
  137. ["dotnet"] = new JObject { ["type"] = "boolean", ["description"] = "true=执行 dotnet 命令;false(默认)=执行 cmd 命令" },
  138. }, "command"),
  139. Fn("web_search", "联网搜索:用搜索引擎查询互联网上的最新资料(行业术语、产品名称、文档、新闻等),返回标题/链接/摘要。需要了解详情时,再对结果里的 url 调用 fetch_url 读取网页正文",
  140. new JObject
  141. {
  142. ["query"] = new JObject { ["type"] = "string", ["description"] = "搜索关键词" },
  143. ["count"] = new JObject { ["type"] = "integer", ["description"] = "结果条数(可选,默认 8,最多 10)" },
  144. }, "query"),
  145. Fn("fetch_url", "读取指定网址网页的正文文本(自动去除 HTML 标签)。配合 web_search 使用:先搜索拿到链接,再读取正文了解详情",
  146. new JObject
  147. {
  148. ["url"] = new JObject { ["type"] = "string", ["description"] = "网页地址(http/https)" },
  149. }, "url"),
  150. };
  151. }
  152. /// <summary>
  153. /// AI 工具执行器:在 UI 线程操作语言包数据,返回 JSON 结果字符串回传给模型。
  154. /// 修改类工具执行后自动保存 + 刷新表格。
  155. /// </summary>
  156. public async Task<string> ExecuteAiToolAsync(string name, string argsJson)
  157. {
  158. try
  159. {
  160. var args = string.IsNullOrWhiteSpace(argsJson) ? new JObject() : JObject.Parse(argsJson);
  161. var source = _packs.FirstOrDefault(p => p.IsSource);
  162. switch (name)
  163. {
  164. case "list_languages":
  165. {
  166. var arr = new JArray();
  167. foreach (var t in _targets)
  168. {
  169. var total = _rows.Count;
  170. var missing = _rows.Sum(r => r.Cells.TryGetValue(t.LanguageCode, out var c) ? (c.IsMissing ? 1 : 0) : 0);
  171. arr.Add(new JObject { ["code"] = t.LanguageCode, ["name"] = t.LanguageName, ["translated"] = total - missing, ["missing"] = missing });
  172. }
  173. return new JObject { ["ok"] = true, ["languages"] = arr }.ToString(Newtonsoft.Json.Formatting.None);
  174. }
  175. case "get_stats":
  176. {
  177. var arr = new JArray();
  178. foreach (var t in _targets)
  179. {
  180. var missing = _rows.Sum(r => r.Cells.TryGetValue(t.LanguageCode, out var c) ? (c.IsMissing ? 1 : 0) : 0);
  181. arr.Add(new JObject { ["code"] = t.LanguageCode, ["total"] = _rows.Count, ["translated"] = _rows.Count - missing, ["missing"] = missing });
  182. }
  183. return new JObject { ["ok"] = true, ["totalEntries"] = _rows.Count, ["byLanguage"] = arr }.ToString(Newtonsoft.Json.Formatting.None);
  184. }
  185. case "search_entries":
  186. {
  187. var kw = (string)args["keyword"] ?? "";
  188. var arr = new JArray();
  189. foreach (var r in _rows.Where(r => r.Key != null && r.Key.Contains(kw)).Take(20))
  190. {
  191. var tr = new JObject();
  192. foreach (var c in r.Cells.Values)
  193. tr[c.LangCode] = c.Value;
  194. arr.Add(new JObject { ["index"] = r.Index, ["key"] = r.Key, ["translations"] = tr });
  195. }
  196. return new JObject { ["ok"] = true, ["count"] = arr.Count, ["entries"] = arr }.ToString(Newtonsoft.Json.Formatting.None);
  197. }
  198. case "get_entry":
  199. {
  200. var key = (string)args["key"];
  201. var row = _rows.FirstOrDefault(r => r.Key == key);
  202. if (row == null)
  203. return new JObject { ["ok"] = false, ["error"] = "词条不存在: " + key }.ToString(Newtonsoft.Json.Formatting.None);
  204. var tr = new JObject();
  205. foreach (var c in row.Cells.Values) tr[c.LangCode] = c.Value;
  206. return new JObject { ["ok"] = true, ["key"] = key, ["translations"] = tr }.ToString(Newtonsoft.Json.Formatting.None);
  207. }
  208. case "add_entry":
  209. {
  210. var key = (string)args["key"];
  211. if (string.IsNullOrWhiteSpace(key)) return new JObject { ["ok"] = false, ["error"] = "key 不能为空" }.ToString(Newtonsoft.Json.Formatting.None);
  212. if (source == null) return new JObject { ["ok"] = false, ["error"] = "未找到 zh-CN 源语言包" }.ToString(Newtonsoft.Json.Formatting.None);
  213. if (source.Translations.ContainsKey(key))
  214. return new JObject { ["ok"] = false, ["error"] = "词条已存在: " + key }.ToString(Newtonsoft.Json.Formatting.None);
  215. source.Translations[key] = key;
  216. var tr = args["translations"] as JObject;
  217. if (tr != null)
  218. {
  219. foreach (var lang in _targets)
  220. {
  221. var val = (string)tr[lang.LanguageCode];
  222. if (!string.IsNullOrWhiteSpace(val))
  223. lang.Translations[key] = val;
  224. }
  225. }
  226. foreach (var lang in _packs) PackRepository.Save(lang);
  227. LoadPacks();
  228. return new JObject { ["ok"] = true, ["message"] = "词条已添加并保存" }.ToString(Newtonsoft.Json.Formatting.None);
  229. }
  230. case "add_entries":
  231. {
  232. if (!(args["entries"] is JArray newEntries) || newEntries.Count == 0)
  233. return new JObject { ["ok"] = false, ["error"] = "entries 不能为空" }.ToString(Newtonsoft.Json.Formatting.None);
  234. if (source == null)
  235. return new JObject { ["ok"] = false, ["error"] = "未找到 zh-CN 源语言包" }.ToString(Newtonsoft.Json.Formatting.None);
  236. int added = 0;
  237. var skipped = new JArray();
  238. foreach (var e in newEntries)
  239. {
  240. var key = (string)e["key"];
  241. if (string.IsNullOrWhiteSpace(key)) continue;
  242. key = key.Trim();
  243. if (source.Translations.ContainsKey(key))
  244. {
  245. skipped.Add(key);
  246. continue;
  247. }
  248. source.Translations[key] = key;
  249. if (e["translations"] is JObject tr)
  250. {
  251. foreach (var lang in _targets)
  252. {
  253. var val = (string)tr[lang.LanguageCode];
  254. if (!string.IsNullOrWhiteSpace(val))
  255. lang.Translations[key] = val;
  256. }
  257. }
  258. added++;
  259. }
  260. foreach (var p in _packs) PackRepository.Save(p);
  261. LoadPacks();
  262. return new JObject
  263. {
  264. ["ok"] = true,
  265. ["added"] = added,
  266. ["skipped"] = skipped,
  267. ["message"] = $"批量新增完成:新增 {added} 条,跳过(已存在) {skipped.Count} 条。已保存并刷新表格。",
  268. }.ToString(Newtonsoft.Json.Formatting.None);
  269. }
  270. case "update_translation":
  271. {
  272. var key = (string)args["key"];
  273. var langCode = (string)args["lang"];
  274. var value = (string)args["value"] ?? "";
  275. var lang = _targets.FirstOrDefault(t => t.LanguageCode == langCode);
  276. if (lang == null) return new JObject { ["ok"] = false, ["error"] = "语言未加载: " + langCode }.ToString(Newtonsoft.Json.Formatting.None);
  277. lang.Translations[key] = value;
  278. PackRepository.Save(lang);
  279. LoadPacks();
  280. return new JObject { ["ok"] = true, ["message"] = $"已更新 {langCode} 译文" }.ToString(Newtonsoft.Json.Formatting.None);
  281. }
  282. case "delete_entry":
  283. {
  284. var key = (string)args["key"];
  285. if (string.IsNullOrWhiteSpace(key))
  286. return new JObject { ["ok"] = false, ["error"] = "key 不能为空" }.ToString(FormattingNone);
  287. return ConfirmAndDeleteKeys(new List<string> { key });
  288. }
  289. case "delete_entries":
  290. {
  291. var keys = new List<string>();
  292. if (args["keys"] is JArray keyArr)
  293. foreach (var k in keyArr) keys.Add((string)k);
  294. if (args["indices"] is JArray idxArr)
  295. {
  296. foreach (var i in idxArr)
  297. {
  298. var row = _rows.FirstOrDefault(r => r.Index == (int)i);
  299. if (row != null) keys.Add(row.Key);
  300. }
  301. }
  302. if (keys.Count == 0)
  303. return new JObject { ["ok"] = false, ["error"] = "keys 与 indices 至少提供一个" }.ToString(FormattingNone);
  304. return ConfirmAndDeleteKeys(keys);
  305. }
  306. case "get_validation_issues":
  307. {
  308. var arr = new JArray();
  309. foreach (var i in _lastIssues)
  310. {
  311. var langMain = i.LangCode.Split('-')[0].ToLowerInvariant();
  312. if (!i.IsError &&
  313. i.Message.Contains("疑似未翻译") &&
  314. TranslationValidator.NotTranslatedIgnoredLangs.Contains(langMain))
  315. continue;
  316. arr.Add(new JObject
  317. {
  318. ["key"] = i.Row.Key,
  319. ["lang"] = i.LangCode,
  320. ["current"] = i.Cell.Value,
  321. ["problem"] = i.Message,
  322. ["is_error"] = i.IsError,
  323. });
  324. }
  325. if (arr.Count == 0)
  326. return new JObject { ["ok"] = true, ["count"] = 0, ["message"] = "暂无需要重译的问题(日语等豁免语言的同文词条已自动排除;请先点“校验”生成问题清单)" }.ToString(Newtonsoft.Json.Formatting.None);
  327. return new JObject { ["ok"] = true, ["count"] = arr.Count, ["issues"] = arr }.ToString(Newtonsoft.Json.Formatting.None);
  328. }
  329. case "batch_update_translations":
  330. {
  331. if (!(args["entries"] is JArray entries) || entries.Count == 0)
  332. return new JObject { ["ok"] = false, ["error"] = "entries 不能为空" }.ToString(Newtonsoft.Json.Formatting.None);
  333. int updated = 0, missingKey = 0;
  334. var failed = new JArray();
  335. foreach (var e in entries)
  336. {
  337. var key = (string)e["key"];
  338. if (string.IsNullOrWhiteSpace(key)) continue;
  339. var exists = _packs.Any(p => p.Translations.ContainsKey(key));
  340. if (!exists) { missingKey++; failed.Add(new JObject { ["key"] = key, ["error"] = "词条不存在" }); continue; }
  341. if (e["translations"] is JObject tr)
  342. {
  343. foreach (var lang in _targets)
  344. {
  345. var val = (string)tr[lang.LanguageCode];
  346. if (!string.IsNullOrWhiteSpace(val))
  347. lang.Translations[key] = val;
  348. }
  349. updated++;
  350. }
  351. }
  352. foreach (var p in _packs) PackRepository.Save(p);
  353. LoadPacks();
  354. return new JObject
  355. {
  356. ["ok"] = true,
  357. ["updated"] = updated,
  358. ["missingKey"] = missingKey,
  359. ["failed"] = failed,
  360. ["message"] = $"批量更新完成:成功 {updated} 条,key 不存在 {missingKey} 条。已保存并刷新表格。",
  361. }.ToString(Newtonsoft.Json.Formatting.None);
  362. }
  363. case "save_all":
  364. {
  365. foreach (var p in _packs) PackRepository.Save(p);
  366. return new JObject { ["ok"] = true, ["message"] = "全部语言包已保存" }.ToString(Newtonsoft.Json.Formatting.None);
  367. }
  368. case "get_missing_translations":
  369. {
  370. var langCode = (string)args["lang"];
  371. var langs = string.IsNullOrWhiteSpace(langCode)
  372. ? _targets
  373. : _targets.Where(t => t.LanguageCode == langCode).ToList();
  374. if (!string.IsNullOrWhiteSpace(langCode) && langs.Count == 0)
  375. return new JObject { ["ok"] = false, ["error"] = "语言未加载: " + langCode }.ToString(Newtonsoft.Json.Formatting.None);
  376. var arr = new JArray();
  377. foreach (var lang in langs)
  378. {
  379. foreach (var r in _rows)
  380. {
  381. if (r.Cells.TryGetValue(lang.LanguageCode, out var c) && c.IsMissing)
  382. arr.Add(new JObject { ["index"] = r.Index, ["key"] = r.Key, ["lang"] = lang.LanguageCode });
  383. }
  384. }
  385. return new JObject { ["ok"] = true, ["count"] = arr.Count, ["missing"] = arr }.ToString(Newtonsoft.Json.Formatting.None);
  386. }
  387. case "baidu_translate_missing":
  388. {
  389. var langCode = (string)args["lang"];
  390. var lang = _targets.FirstOrDefault(t => t.LanguageCode == langCode);
  391. if (lang == null)
  392. return new JObject { ["ok"] = false, ["error"] = "语言未加载: " + langCode }.ToString(Newtonsoft.Json.Formatting.None);
  393. IEnumerable<RowVm> scopeRows = _rows;
  394. if (args["indices"] is JArray idxArr && idxArr.Count > 0)
  395. {
  396. var idxSet = new HashSet<int>(idxArr.Select(x => (int)x));
  397. scopeRows = _rows.Where(r => idxSet.Contains(r.Index));
  398. }
  399. var missing = scopeRows
  400. .Select(r => new { r, cell = r.Cells.TryGetValue(lang.LanguageCode, out var c) ? c : null })
  401. .Where(x => x.cell != null && x.cell.IsMissing)
  402. .ToList();
  403. if (missing.Count == 0)
  404. return new JObject { ["ok"] = true, ["translated"] = 0, ["message"] = $"{langCode} 没有缺译项" }.ToString(Newtonsoft.Json.Formatting.None);
  405. var baidu = new BaiduTranslateProvider(_cfg.Baidu);
  406. int done = 0;
  407. var fails = new JArray();
  408. for (int i = 0; i < missing.Count; i += BaiduTranslateProvider.BatchSize)
  409. {
  410. var batch = missing.Skip(i).Take(BaiduTranslateProvider.BatchSize).ToList();
  411. try
  412. {
  413. var results = await baidu.TranslateBatchAsync(
  414. batch.Select(x => x.r.Key).ToList(),
  415. _cfg.Translation.FromLang,
  416. lang.LanguageCode);
  417. for (int k = 0; k < batch.Count && k < results.Count; k++)
  418. {
  419. if (!string.IsNullOrWhiteSpace(results[k]))
  420. {
  421. batch[k].cell.Value = results[k];
  422. lang.Translations[batch[k].r.Key] = results[k];
  423. done++;
  424. }
  425. else
  426. fails.Add(new JObject { ["index"] = batch[k].r.Index, ["key"] = batch[k].r.Key });
  427. }
  428. }
  429. catch (Exception ex)
  430. {
  431. foreach (var x in batch)
  432. fails.Add(new JObject { ["index"] = x.r.Index, ["key"] = x.r.Key, ["error"] = ex.Message });
  433. }
  434. }
  435. PackRepository.Save(lang);
  436. LoadPacks();
  437. return new JObject
  438. {
  439. ["ok"] = true,
  440. ["lang"] = langCode,
  441. ["translated"] = done,
  442. ["failed"] = fails,
  443. ["message"] = $"百度翻译补译完成:成功 {done},失败 {fails.Count}。已保存并刷新表格。",
  444. }.ToString(Newtonsoft.Json.Formatting.None);
  445. }
  446. case "delete_entry_by_index":
  447. {
  448. var idx = (int)args["index"];
  449. var row = _rows.FirstOrDefault(r => r.Index == idx);
  450. if (row == null)
  451. return new JObject { ["ok"] = false, ["error"] = $"序号 {idx} 不存在" }.ToString(FormattingNone);
  452. return ConfirmAndDeleteKeys(new List<string> { row.Key });
  453. }
  454. case "run_command":
  455. {
  456. var command = (string)args["command"];
  457. if (string.IsNullOrWhiteSpace(command))
  458. return new JObject { ["ok"] = false, ["error"] = "command 不能为空" }.ToString(Newtonsoft.Json.Formatting.None);
  459. var isDotnet = args["dotnet"] != null && (bool)args["dotnet"];
  460. var psi = new System.Diagnostics.ProcessStartInfo
  461. {
  462. FileName = isDotnet ? "dotnet" : "cmd.exe",
  463. Arguments = isDotnet ? command : $"/c {command}",
  464. RedirectStandardOutput = true,
  465. RedirectStandardError = true,
  466. UseShellExecute = false,
  467. CreateNoWindow = true,
  468. WorkingDirectory = string.IsNullOrWhiteSpace(_dir) ? AppDomain.CurrentDomain.BaseDirectory : _dir,
  469. StandardOutputEncoding = Encoding.GetEncoding(936),
  470. StandardErrorEncoding = Encoding.GetEncoding(936),
  471. };
  472. using (var p = System.Diagnostics.Process.Start(psi))
  473. {
  474. if (p == null)
  475. return new JObject { ["ok"] = false, ["error"] = "进程启动失败" }.ToString(Newtonsoft.Json.Formatting.None);
  476. var outputTask = p.StandardOutput.ReadToEndAsync();
  477. var errorTask = p.StandardError.ReadToEndAsync();
  478. var exited = await Task.Run(() => p.WaitForExit(30000));
  479. var output = (await outputTask) ?? "";
  480. var errOut = (await errorTask) ?? "";
  481. if (!exited) { try { p.Kill(); } catch { } }
  482. var resultObj = new JObject
  483. {
  484. ["ok"] = exited,
  485. ["exitCode"] = exited ? p.ExitCode : -1,
  486. ["stdout"] = output,
  487. ["stderr"] = errOut,
  488. };
  489. if ((output + errOut).Length > 3000)
  490. resultObj["stdout"] = output.Length > 2500 ? output.Substring(0, 2500) + "…(截断)" : output;
  491. return resultObj.ToString(Newtonsoft.Json.Formatting.None);
  492. }
  493. }
  494. case "web_search":
  495. {
  496. var query = (string)args["query"];
  497. if (string.IsNullOrWhiteSpace(query))
  498. return new JObject { ["ok"] = false, ["error"] = "query 不能为空" }.ToString(Newtonsoft.Json.Formatting.None);
  499. int count = args["count"] != null && args["count"].Type == JTokenType.Integer
  500. ? (int)args["count"] : _cfg.WebSearch.MaxResults;
  501. count = Math.Min(Math.Max(count, 1), 10);
  502. var web = new WebSearchProvider(_cfg.WebSearch);
  503. var results = await web.SearchAsync(query, count);
  504. return new JObject
  505. {
  506. ["ok"] = true,
  507. ["query"] = query,
  508. ["count"] = results.Count,
  509. ["results"] = results,
  510. }.ToString(Newtonsoft.Json.Formatting.None);
  511. }
  512. case "fetch_url":
  513. {
  514. var url = (string)args["url"];
  515. if (string.IsNullOrWhiteSpace(url))
  516. return new JObject { ["ok"] = false, ["error"] = "url 不能为空" }.ToString(Newtonsoft.Json.Formatting.None);
  517. var web = new WebSearchProvider(_cfg.WebSearch);
  518. var text = await web.FetchUrlAsync(url);
  519. return new JObject { ["ok"] = true, ["url"] = url, ["text"] = text }
  520. .ToString(Newtonsoft.Json.Formatting.None);
  521. }
  522. // ---------- 文件工具(任意路径读写,权限由 ask/trusted 统一管控) ----------
  523. case "read_file":
  524. {
  525. var path = (string)args["path"];
  526. if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
  527. return new JObject { ["ok"] = false, ["error"] = "文件不存在: " + path }.ToString(Newtonsoft.Json.Formatting.None);
  528. try
  529. {
  530. var text = ReadTextFile(path);
  531. const int limit = 20000;
  532. var truncated = text.Length > limit;
  533. if (truncated) text = text.Substring(0, limit) + "\n…(内容较长已截断)";
  534. return new JObject
  535. {
  536. ["ok"] = true,
  537. ["path"] = path,
  538. ["length"] = new System.IO.FileInfo(path).Length,
  539. ["truncated"] = truncated,
  540. ["content"] = text,
  541. }.ToString(Newtonsoft.Json.Formatting.None);
  542. }
  543. catch (Exception ex)
  544. {
  545. return new JObject { ["ok"] = false, ["error"] = $"读取失败:{ex.Message}" }.ToString(Newtonsoft.Json.Formatting.None);
  546. }
  547. }
  548. case "write_file":
  549. {
  550. var path = (string)args["path"];
  551. var content = (string)args["content"] ?? "";
  552. if (string.IsNullOrWhiteSpace(path))
  553. return new JObject { ["ok"] = false, ["error"] = "path 不能为空" }.ToString(Newtonsoft.Json.Formatting.None);
  554. try
  555. {
  556. var dir = System.IO.Path.GetDirectoryName(path);
  557. if (!string.IsNullOrEmpty(dir)) System.IO.Directory.CreateDirectory(dir);
  558. System.IO.File.WriteAllText(path, content, new UTF8Encoding(false));
  559. return new JObject { ["ok"] = true, ["path"] = path, ["bytes"] = content.Length, ["message"] = "已写入文件" }.ToString(Newtonsoft.Json.Formatting.None);
  560. }
  561. catch (Exception ex)
  562. {
  563. return new JObject { ["ok"] = false, ["error"] = $"写入失败:{ex.Message}" }.ToString(Newtonsoft.Json.Formatting.None);
  564. }
  565. }
  566. case "edit_file":
  567. {
  568. var path = (string)args["path"];
  569. var oldText = (string)args["old_text"] ?? "";
  570. var newText = (string)args["new_text"] ?? "";
  571. if (string.IsNullOrWhiteSpace(path) || !System.IO.File.Exists(path))
  572. return new JObject { ["ok"] = false, ["error"] = "文件不存在: " + path }.ToString(Newtonsoft.Json.Formatting.None);
  573. if (string.IsNullOrEmpty(oldText))
  574. return new JObject { ["ok"] = false, ["error"] = "old_text 不能为空" }.ToString(Newtonsoft.Json.Formatting.None);
  575. try
  576. {
  577. var text = ReadTextFile(path);
  578. var idx = text.IndexOf(oldText, StringComparison.Ordinal);
  579. if (idx < 0)
  580. return new JObject { ["ok"] = false, ["error"] = "old_text 在文件中不存在(请先 read_file 确认原文再替换)" }.ToString(Newtonsoft.Json.Formatting.None);
  581. var last = text.LastIndexOf(oldText, StringComparison.Ordinal);
  582. if (last != idx)
  583. return new JObject { ["ok"] = false, ["error"] = "old_text 在文件中出现多次,请给出更长的唯一上下文" }.ToString(Newtonsoft.Json.Formatting.None);
  584. text = text.Substring(0, idx) + newText + text.Substring(idx + oldText.Length);
  585. System.IO.File.WriteAllText(path, text, new UTF8Encoding(false));
  586. return new JObject { ["ok"] = true, ["path"] = path, ["message"] = "替换成功" }.ToString(Newtonsoft.Json.Formatting.None);
  587. }
  588. catch (Exception ex)
  589. {
  590. return new JObject { ["ok"] = false, ["error"] = $"替换失败:{ex.Message}" }.ToString(Newtonsoft.Json.Formatting.None);
  591. }
  592. }
  593. case "list_directory":
  594. {
  595. var path = (string)args["path"];
  596. if (string.IsNullOrWhiteSpace(path) || !System.IO.Directory.Exists(path))
  597. return new JObject { ["ok"] = false, ["error"] = "目录不存在: " + path }.ToString(Newtonsoft.Json.Formatting.None);
  598. try
  599. {
  600. var ignored = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
  601. { "bin", "obj", ".git", ".vs", "node_modules", "packages", ".idea", "dist", "build", "debug", "release" };
  602. var dirs = System.IO.Directory.GetDirectories(path)
  603. .Where(d => !ignored.Contains(System.IO.Path.GetFileName(d)))
  604. .OrderBy(d => d, StringComparer.OrdinalIgnoreCase)
  605. .Select(d => $"[目录] {System.IO.Path.GetFileName(d)}")
  606. .ToList();
  607. var files = System.IO.Directory.GetFiles(path)
  608. .OrderBy(f => f, StringComparer.OrdinalIgnoreCase)
  609. .Select(f => $"[文件] {System.IO.Path.GetFileName(f)} ({new System.IO.FileInfo(f).Length} B)")
  610. .ToList();
  611. var all = dirs.Concat(files).ToList();
  612. return new JObject
  613. {
  614. ["ok"] = true,
  615. ["path"] = path,
  616. ["count"] = all.Count,
  617. ["entries"] = new JArray(all),
  618. }.ToString(Newtonsoft.Json.Formatting.None);
  619. }
  620. catch (Exception ex)
  621. {
  622. return new JObject { ["ok"] = false, ["error"] = $"读取目录失败:{ex.Message}" }.ToString(Newtonsoft.Json.Formatting.None);
  623. }
  624. }
  625. default:
  626. return new JObject { ["ok"] = false, ["error"] = $"未知工具: {name}" }.ToString(Newtonsoft.Json.Formatting.None);
  627. }
  628. }
  629. catch (Exception ex)
  630. {
  631. return new JObject { ["ok"] = false, ["error"] = ex.Message }.ToString(Newtonsoft.Json.Formatting.None);
  632. }
  633. }
  634. /// <summary>
  635. /// 删除工具统一入口:无论删几条,只弹一次确认框(列出数量与前几条 key),
  636. /// 确认后从所有语言包移除、统一保存一次、刷新一次。
  637. /// </summary>
  638. private string ConfirmAndDeleteKeys(List<string> keys)
  639. {
  640. keys = keys.Where(k => !string.IsNullOrWhiteSpace(k)).Distinct().ToList();
  641. if (keys.Count == 0)
  642. return new JObject { ["ok"] = false, ["error"] = "没有可删除的词条" }.ToString(FormattingNone);
  643. var existing = keys.Where(k => _packs.Any(p => p.Translations.ContainsKey(k))).ToList();
  644. var notFound = keys.Except(existing).ToList();
  645. if (existing.Count == 0)
  646. return new JObject { ["ok"] = false, ["error"] = "词条均不存在,未删除任何内容", ["notFound"] = JArray.FromObject(keys) }.ToString(FormattingNone);
  647. var preview = string.Join("、", existing.Take(8).Select(k => $"「{k}」"));
  648. var more = existing.Count > 8 ? $"\n… 等共 {existing.Count} 条" : "";
  649. var confirm = MessageBox.Show(
  650. $"确认删除 {existing.Count} 个词条?\n{preview}{more}\n\n该操作将从所有语言包中移除(不可撤销)。",
  651. "删除确认", MessageBoxButton.YesNo, MessageBoxImage.Warning);
  652. if (confirm != MessageBoxResult.Yes)
  653. return new JObject { ["ok"] = false, ["error"] = "用户取消了删除" }.ToString(FormattingNone);
  654. foreach (var p in _packs)
  655. foreach (var k in existing)
  656. p.Translations.Remove(k);
  657. foreach (var p in _packs) PackRepository.Save(p);
  658. LoadPacks();
  659. var result = new JObject
  660. {
  661. ["ok"] = true,
  662. ["deleted"] = existing.Count,
  663. ["message"] = $"已删除 {existing.Count} 个词条并保存全部语言包。",
  664. };
  665. if (notFound.Count > 0) result["notFound"] = JArray.FromObject(notFound);
  666. return result.ToString(FormattingNone);
  667. }
  668. /// <summary>读取文本文件,自动检测编码:UTF-8 无 BOM 或带 BOM,否则回退 GBK(Windows 中文系统常见)。</summary>
  669. private static string ReadTextFile(string path)
  670. {
  671. var bytes = System.IO.File.ReadAllBytes(path);
  672. if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
  673. return new UTF8Encoding(true).GetString(bytes, 3, bytes.Length - 3);
  674. try
  675. {
  676. var utf8 = new UTF8Encoding(false, true);
  677. return utf8.GetString(bytes);
  678. }
  679. catch (DecoderFallbackException)
  680. {
  681. return Encoding.GetEncoding(936).GetString(bytes); // GBK
  682. }
  683. }
  684. /// <summary>
  685. /// 执行 AI 生成的"添加行"指令:中文源新增 key(key 即中文原文),
  686. /// AI 提供的各语言译文直接写入对应语言包。已存在的 key 不覆盖。写入后保存全部并刷新表格。
  687. /// </summary>
  688. public void ApplyRowsFromAi(List<AddRowPayload> rows)
  689. {
  690. if (rows == null || rows.Count == 0) return;
  691. var source = _packs.FirstOrDefault(p => p.IsSource);
  692. int addedKeys = 0;
  693. foreach (var row in rows)
  694. {
  695. if (string.IsNullOrWhiteSpace(row.Key)) continue;
  696. if (source != null && !source.Translations.ContainsKey(row.Key))
  697. {
  698. source.Translations[row.Key] = row.Key;
  699. addedKeys++;
  700. }
  701. foreach (var lang in _targets)
  702. {
  703. if (row.Translations != null &&
  704. row.Translations.TryGetValue(lang.LanguageCode, out var val) &&
  705. !string.IsNullOrWhiteSpace(val))
  706. {
  707. lang.Translations[row.Key] = val;
  708. }
  709. }
  710. }
  711. foreach (var lang in _packs) PackRepository.Save(lang);
  712. LoadPacks();
  713. Stats = $"AI 指令已执行:新增词条 {addedKeys} 个(含译文同步)。";
  714. }
  715. }
  716. }