AiChatWindow.xaml.cs 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814
  1. using System;
  2. using System.Collections.Generic;
  3. using System.Linq;
  4. using System.Text;
  5. using System.Text.RegularExpressions;
  6. using System.Threading.Tasks;
  7. using System.Threading;
  8. using System.IO;
  9. using System.Windows;
  10. using System.Windows.Controls;
  11. using System.Windows.Documents;
  12. using System.Windows.Input;
  13. using System.Windows.Media;
  14. using LocalizationTool.Models;
  15. using LocalizationTool.Services;
  16. using Newtonsoft.Json.Linq;
  17. namespace LocalizationTool.Views
  18. {
  19. /// <summary>
  20. /// AI 问答窗口:流式对话 + 工具调用(Function Calling)。
  21. /// 此窗口交互性强(逐字渲染、折叠思考区),故保留在 code-behind。
  22. /// </summary>
  23. public partial class AiChatWindow : Window
  24. {
  25. private const int MaxToolRounds = 5000;
  26. /// <summary>单条工具结果的最大字符数(超出截断,防单条撑爆上下文)。</summary>
  27. private const int ToolResultCharLimit = 4000;
  28. /// <summary>上下文裁剪阈值:按最大上下文 token 的 60% 折算为字符预算(1 token ≈ 2 字符粗估),超出则裁剪最早轮次。</summary>
  29. private int ContextCharBudget =>
  30. Math.Max(20000, (int)(Config.ResolveMaxContextTokens(_cfg.Translation) * 0.6 * 2));
  31. private readonly Config.ConfigRoot _cfg;
  32. private readonly Action<List<AddRowPayload>> _applyRows;
  33. private readonly List<PackFile> _langs;
  34. private readonly JArray _tools;
  35. private readonly Func<string, string, Task<string>> _executeTool;
  36. private readonly JArray _historyMessages = new JArray();
  37. private CancellationTokenSource _cts;
  38. private bool _running;
  39. private StreamWriter _logWriter;
  40. public AiChatWindow(Config.ConfigRoot cfg, List<PackFile> targetLangs,
  41. JArray tools, Func<string, string, Task<string>> executeTool, Action<List<AddRowPayload>> applyRows)
  42. {
  43. InitializeComponent();
  44. Icon = App.AppIcon;
  45. _cfg = cfg ?? new Config.ConfigRoot();
  46. _langs = targetLangs ?? new List<PackFile>();
  47. _tools = tools;
  48. _executeTool = executeTool;
  49. _applyRows = applyRows;
  50. InitLog();
  51. CmbPermission.ItemsSource = new List<string> { "每次询问(修改需确认)", "完全信任(自动执行)" };
  52. CmbPermission.SelectedIndex = IsTrusted ? 1 : 0;
  53. TxtSystemPrompt.Text = string.IsNullOrWhiteSpace(_cfg.AiChat?.CustomPrompt)
  54. ? BuildDefaultPrompt(_langs)
  55. : _cfg.AiChat.CustomPrompt;
  56. AppendSystemLine("🤖 AI 助手已就绪。它可以直接查询/新增/修改/删除词条并保存文件——直接下指令即可。");
  57. LoadQuickPrompts();
  58. }
  59. private bool IsTrusted =>
  60. string.Equals(_cfg.AiChat?.PermissionMode, "trusted", StringComparison.OrdinalIgnoreCase);
  61. private static readonly HashSet<string> ReadOnlyTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
  62. {
  63. "list_languages", "get_stats", "search_entries", "get_entry",
  64. "get_validation_issues", "get_missing_translations",
  65. "read_file", "list_directory",
  66. "web_search", "fetch_url",
  67. };
  68. /// <summary>自带确认框的删除类工具:执行器内部会弹详细删除确认,权限层不再重复询问(避免一次删除弹两个框)。</summary>
  69. private static readonly HashSet<string> SelfConfirmTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
  70. {
  71. "delete_entry", "delete_entry_by_index", "delete_entries",
  72. };
  73. /// <summary>本轮工具调用已整批放行(ask 模式下多操作批量确认一次)。</summary>
  74. private bool _roundApproved;
  75. /// <summary>只读工具直接放行;修改类工具在 ask 模式下需用户确认(删除类自带确认框、或本轮已整批放行的除外)。</summary>
  76. private bool EnsurePermission(string toolName, string argsJson)
  77. {
  78. if (ReadOnlyTools.Contains(toolName) || SelfConfirmTools.Contains(toolName)) return true;
  79. if (IsTrusted || _roundApproved)
  80. {
  81. Log("PERMISSION", $"{toolName} → {(IsTrusted ? "allowed (trusted)" : "allowed (round batch)")}");
  82. return true;
  83. }
  84. var argsShort = argsJson ?? "";
  85. if (argsShort.Length > 160) argsShort = argsShort.Substring(0, 160) + "…";
  86. var confirm = MessageBox.Show($"AI 请求执行工具:{toolName}\n\n参数:{argsShort}\n\n允许执行?",
  87. "权限确认", MessageBoxButton.YesNo, MessageBoxImage.Question);
  88. var allowed = confirm == MessageBoxResult.Yes;
  89. Log("PERMISSION", $"{toolName} → {(allowed ? "allowed" : "denied (user)")}");
  90. return allowed;
  91. }
  92. /// <summary>权限模式切换:立即持久化。</summary>
  93. private void CmbPermission_SelectionChanged(object sender, SelectionChangedEventArgs e)
  94. {
  95. _cfg.AiChat.PermissionMode = CmbPermission.SelectedIndex == 1 ? "trusted" : "ask";
  96. Config.Save(_cfg);
  97. Log("PERMISSION_MODE", _cfg.AiChat.PermissionMode);
  98. AppendSystemLine(CmbPermission.SelectedIndex == 1
  99. ? "🔓 已切换为【完全信任】:所有工具自动执行,不弹确认(操作仍全部记录日志)"
  100. : "🔒 已切换为【每次询问】:修改类工具执行前会弹确认框");
  101. }
  102. // ---------- 会话日志 ----------
  103. private void InitLog()
  104. {
  105. try
  106. {
  107. var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AiLogs");
  108. Directory.CreateDirectory(dir);
  109. var path = Path.Combine(dir, $"ai_{DateTime.Now:yyyyMMdd_HHmmss}.log");
  110. _logWriter = new StreamWriter(path, true, Encoding.UTF8) { AutoFlush = true };
  111. _logWriter.WriteLine($"========== 会话开始 {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==========");
  112. _logWriter.WriteLine($"[SYS] 目标语言: {string.Join(", ", _langs.Select(l => l.LanguageCode))}");
  113. _logWriter.WriteLine($"[SYS] 权限模式: {_cfg.AiChat?.PermissionMode ?? "ask"}");
  114. }
  115. catch { }
  116. }
  117. private void Log(string tag, string text)
  118. {
  119. try { _logWriter?.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{tag}] {text}"); }
  120. catch { }
  121. }
  122. private void CloseLog()
  123. {
  124. try
  125. {
  126. _logWriter?.WriteLine($"========== 会话结束 {DateTime.Now:HH:mm:ss} ==========");
  127. _logWriter?.Dispose();
  128. _logWriter = null;
  129. }
  130. catch { }
  131. }
  132. protected override void OnClosed(EventArgs e)
  133. {
  134. CloseLog();
  135. base.OnClosed(e);
  136. }
  137. /// <summary>按当前语种动态生成默认系统提示词。</summary>
  138. internal static string BuildDefaultPrompt(List<PackFile> langs)
  139. {
  140. var list = (langs ?? new List<PackFile>()).Where(l => !l.IsSource).ToList();
  141. if (list.Count == 0)
  142. {
  143. list = new List<PackFile>
  144. {
  145. new PackFile { LanguageCode = "en-US", LanguageName = "English" },
  146. new PackFile { LanguageCode = "ja-JP", LanguageName = "日本語" },
  147. };
  148. }
  149. var langList = string.Join("、", list.Select(l =>
  150. {
  151. var name = LanguageCatalog.Find(l.LanguageCode)?.Name ?? l.LanguageName;
  152. return $"{name}({l.LanguageCode})";
  153. }));
  154. var example = string.Join(", ", list.Select(l => $"\"{l.LanguageCode}\": \"<{l.LanguageName}译文>\""));
  155. var sb = new StringBuilder();
  156. sb.AppendLine("你是 TeamAAS 本地化助手,负责软件界面词条的翻译与整理。");
  157. sb.AppendLine($"当前语言包的目标语言共 {list.Count} 种:{langList}。");
  158. sb.AppendLine();
  159. sb.AppendLine("当用户要求新增词条(或你判断需要补充词条)时,输出一个 json 指令代码块(用 ```json 包裹),格式:");
  160. sb.AppendLine("```json");
  161. sb.AppendLine("{ \"action\": \"add\", \"rows\": [ { \"key\": \"中文原文\", \"translations\": { " + example + " } } ] }");
  162. sb.AppendLine("```");
  163. sb.AppendLine($"- translations 只需包含上述 {list.Count} 种语言,key 为中文原文,译文使用对应语言的地道表达,品牌名/型号保持原文。");
  164. sb.AppendLine("- 翻译时数字、英文单词、单位、特殊符号、表情图标必须保持原样不翻译,符号的位置和数量与原文一致。");
  165. sb.AppendLine("- 可在同一个 rows 数组里放多条,实现批量添加。");
  166. sb.AppendLine("- 该格式仅在需要添加词条时输出,不要在普通对话中复述示例。");
  167. sb.AppendLine();
  168. sb.AppendLine("## 工具能力");
  169. sb.AppendLine("你可以调用以下工具直接操作语言包(用户提出查询/修改/新增/删除/保存请求时优先调用工具):");
  170. sb.AppendLine("- list_languages(): 列出已加载语言及词条统计");
  171. sb.AppendLine("- get_stats(): 词条总数与各语言缺译统计");
  172. sb.AppendLine("- search_entries(keyword): 按关键词搜索词条");
  173. sb.AppendLine("- get_entry(key): 读取某词条的全部译文");
  174. sb.AppendLine("- add_entry(key, translations): 新增词条并保存");
  175. sb.AppendLine("- update_translation(key, lang, value): 更新某语言译文并保存");
  176. sb.AppendLine("- delete_entry(key): 删除单个词条(需用户明确要求)");
  177. sb.AppendLine("- delete_entries(keys?, indices?): 批量删除多个词条(keys 或 indices 数组二选一或混用,只弹一次确认)。删除多个词条时必须用本工具一次性完成,禁止逐条调 delete_entry");
  178. sb.AppendLine("- get_validation_issues(): 获取最近校验发现的问题词条(含表格序号 index、原文/语言/当前错误译文/原因)。日语等豁免语言的同文条目已自动排除");
  179. sb.AppendLine("- batch_update_translations(entries): 批量写回修正");
  180. sb.AppendLine("- get_missing_translations(lang): 列出缺译词条(含表格序号 index)。lang 可选");
  181. sb.AppendLine("- baidu_translate_missing(lang, indices?): 用百度翻译批量补译缺译并保存(indices 可选,按表格序号只补指定行;缺省补全部缺译)");
  182. sb.AppendLine("- delete_entry_by_index(index): 按表格序号删除单个词条(会弹确认框)");
  183. sb.AppendLine("- read_file(path): 读取任意路径文本文件(自动识别 UTF-8/GBK)。改代码前先读原文件");
  184. sb.AppendLine("- write_file(path, content): 写入/覆盖任意路径文件(父目录自动创建)");
  185. sb.AppendLine("- edit_file(path, old_text, new_text): 对任意路径文件做局部替换(old_text 必须唯一出现)。改代码首选,先 read_file 再替换");
  186. sb.AppendLine("- list_directory(path): 列出目录下的子目录与文件(自动跳过 bin/obj/.git 等)");
  187. sb.AppendLine("- run_command(command, dotnet?): 执行 cmd/dotnet 命令获取输出。适合查询系统信息、目录内容、git 操作、编译等");
  188. sb.AppendLine("- web_search(query, count?): 联网搜索,返回网页标题/链接/摘要。用户问到你不确定的行业术语、产品名、最新资料时使用");
  189. sb.AppendLine("- fetch_url(url): 读取网页正文文本。配合 web_search 使用:先搜索拿到链接,再读取正文了解详情");
  190. sb.AppendLine("- save_all(): 保存全部语言包到磁盘");
  191. sb.AppendLine();
  192. sb.AppendLine("## 工具权限说明");
  193. sb.AppendLine("只读工具(list_languages/get_stats/search_entries/get_entry/get_validation_issues/get_missing_translations/read_file/list_directory/web_search/fetch_url)始终直接执行;");
  194. sb.AppendLine("修改类工具(write_file/edit_file/run_command 及词条增删改等)在 ask 权限模式下需要用户在确认框中允许,trusted 权限模式下自动执行;所有操作均被记录到日志。");
  195. sb.AppendLine();
  196. sb.AppendLine("## 文件/代码操作(重要)");
  197. sb.AppendLine("用户要求查看或修改代码/文件时:");
  198. sb.AppendLine("1. 先用 list_directory() 摸清目录结构,再用 read_file() 读取目标文件;");
  199. sb.AppendLine("2. 修改优先用 edit_file() 做局部替换(old_text 取文件中的唯一上下文,宁可多取几行保证唯一);");
  200. sb.AppendLine("3. 新建文件用 write_file();需要编译/测试用 run_command(dotnet ...);");
  201. sb.AppendLine("4. 修改后向用户汇报改了什么,若用户要求可再用 run_command 编译验证。");
  202. sb.AppendLine();
  203. sb.AppendLine("## 批量重译工作流(重要)");
  204. sb.AppendLine("用户要求\"重新翻译有问题的词条/修正错误翻译\"时:");
  205. sb.AppendLine("1. 先调用 get_validation_issues() 获取全部问题词条;");
  206. sb.AppendLine("2. 逐条分析问题原因(如译文丢失正文、疑似未翻译),结合中文原文重新给出地道译文;");
  207. sb.AppendLine("3. 调用一次 batch_update_translations() 把所有修正一次性写回——禁止逐条调用 update_translation 循环修改;");
  208. sb.AppendLine("4. 完成后向用户汇报修正数量与内容。");
  209. sb.AppendLine("注意:问题清单里的 index 是表格序号,用户按序号要求操作(如\"删除第 5 行\"\"用百度翻译补译第 3、8 行\")时直接传该 index。");
  210. sb.AppendLine("注意:日语中译文与中文原文完全相同通常是汉字词的正常写法,这类条目已在问题清单中自动排除,不要尝试重译它们。");
  211. sb.AppendLine();
  212. sb.AppendLine("## 拆分添加规则(重要)");
  213. sb.AppendLine("用户提供一段内容要求添加词条时(如\"手机,电脑,冰红茶\"),先按以下分隔符把内容拆分为多个独立词条:");
  214. sb.AppendLine("空格、逗号(,)、句号(.)、中文逗号(,)、中文句号(。)、冒号(:)、中文冒号(:)、大于号(>)、制表符(Tab)、左方括号([)、右方括号(])、感叹号(!)、中文感叹号(!)、问号(?)、中文问号(?)、连字符(-)");
  215. sb.AppendLine("1. 每段去除首尾空白,忽略空段;");
  216. sb.AppendLine("2. 每段作为一条独立的中文词条(key),分别翻译成所有目标语言;");
  217. sb.AppendLine("3. 调用一次 add_entries() 批量添加(禁止逐条调用 add_entry);");
  218. sb.AppendLine("4. 完成后向用户汇报添加了哪些词条。");
  219. sb.AppendLine();
  220. sb.AppendLine("其他正常对话直接回答。");
  221. return sb.ToString();
  222. }
  223. // ---------- UI 辅助 ----------
  224. private void ScrollToEnd() => ChatScroll.ScrollToEnd();
  225. public class PromptOption
  226. {
  227. public string Name { get; set; }
  228. public string Text { get; set; }
  229. public override string ToString() => Name;
  230. }
  231. private readonly List<PromptOption> _quickPrompts = new List<PromptOption>
  232. {
  233. new PromptOption { Name = "🔁 重译校验失败词条", Text = "把我校验失败的内容进行重新翻译" },
  234. new PromptOption { Name = "📊 缺译统计", Text = "统计一下各语言的缺译情况" },
  235. new PromptOption { Name = "🈳 补齐全部缺译", Text = "把所有缺译的词条全部补齐翻译并保存" },
  236. new PromptOption { Name = "🧹 清理多余空格", Text = "把所有译文首尾的多余空格清理掉并保存" },
  237. new PromptOption { Name = "❓ 检查疑似未翻译", Text = "列出疑似未翻译的词条" },
  238. new PromptOption { Name = "📖 查看指定词条译文", Text = "帮我读取这条词条的全部译文:" },
  239. };
  240. private bool _suppressQuickReset;
  241. private void LoadQuickPrompts()
  242. {
  243. CmbQuickPrompts.ItemsSource = _quickPrompts;
  244. }
  245. private void CmbQuickPrompts_SelectionChanged(object sender, SelectionChangedEventArgs e)
  246. {
  247. if (_suppressQuickReset) return;
  248. if (!(CmbQuickPrompts.SelectedItem is PromptOption p) || string.IsNullOrEmpty(p.Text)) return;
  249. TxtInput.Text = p.Text;
  250. TxtInput.Focus();
  251. TxtInput.CaretIndex = TxtInput.Text.Length;
  252. ScrollToEnd();
  253. _suppressQuickReset = true;
  254. CmbQuickPrompts.SelectedIndex = -1;
  255. _suppressQuickReset = false;
  256. }
  257. private async Task SendAsync()
  258. {
  259. if (_running) return;
  260. var input = TxtInput.Text?.Trim();
  261. if (string.IsNullOrEmpty(input)) return;
  262. var openai = new OpenAiCompatibleProvider(_cfg.Translation);
  263. if (!openai.IsConfigured)
  264. {
  265. AppendSystemLine("⚠️ 请先在“翻译设置 → OpenAI 配置”填写 BaseUrl / Model / API Key。");
  266. return;
  267. }
  268. TxtInput.Clear();
  269. AppendUserMessage(input);
  270. Log("USER", input);
  271. var messages = new JArray
  272. {
  273. new JObject { ["role"] = "system", ["content"] = TxtSystemPrompt.Text },
  274. };
  275. foreach (var m in _historyMessages) messages.Add(m);
  276. messages.Add(new JObject { ["role"] = "user", ["content"] = input });
  277. _historyMessages.Add(new JObject { ["role"] = "user", ["content"] = input }); // 历史完整:user 先入
  278. // 历史超预算 → 裁剪最早轮次,防上下文超限(模型输出截断导致 tool arguments 非法)
  279. if (TrimContext(messages))
  280. AppendSystemLine("✂️ 对话历史较长,已自动裁剪最早的几轮(保留最近对话,防止上下文超限)。");
  281. UpdateContextUsage(messages);
  282. _cts = new CancellationTokenSource();
  283. _running = true;
  284. BtnSend.Content = "⏹ 停止";
  285. Log("RUN", "开始");
  286. try
  287. {
  288. for (int round = 0; round < MaxToolRounds; round++)
  289. {
  290. _cts.Token.ThrowIfCancellationRequested();
  291. Expander thinkingBox = null;
  292. Paragraph thinkingPara = null;
  293. var thinkingSb = new StringBuilder();
  294. RichTextBox contentRtb = null;
  295. Paragraph contentPara = null;
  296. var contentSb = new StringBuilder();
  297. var (content, toolCalls) = await openai.StreamChatAsync(messages,
  298. delta =>
  299. {
  300. Dispatcher.Invoke(() =>
  301. {
  302. if (contentRtb == null) contentRtb = AppendAiContentBlock(out contentPara);
  303. contentSb.Append(delta);
  304. contentPara.Inlines.Add(new Run(delta));
  305. ScrollToEnd();
  306. });
  307. },
  308. reasoning =>
  309. {
  310. Dispatcher.Invoke(() =>
  311. {
  312. if (thinkingBox == null)
  313. (thinkingBox, thinkingPara) = AppendThinkingBlock();
  314. thinkingSb.Append(reasoning);
  315. thinkingPara.Inlines.Add(new Run(reasoning));
  316. thinkingBox.Header = $"💭 思考中…(已思考 {thinkingSb.Length} 字,点击展开)";
  317. ScrollToEnd();
  318. });
  319. },
  320. _tools, _cts.Token);
  321. if (thinkingSb.Length > 0) Log("REASONING", thinkingSb.ToString());
  322. Log("CONTENT", contentSb.ToString());
  323. if (thinkingBox != null)
  324. thinkingBox.Header = $"💭 思考过程({thinkingSb.Length} 字 · 已完成,点击展开)";
  325. if (contentRtb == null && thinkingBox != null)
  326. AppendLineSpacer();
  327. if (toolCalls == null || toolCalls.Count == 0)
  328. {
  329. // user 已在发送时入历史,这里只补 assistant
  330. _historyMessages.Add(new JObject { ["role"] = "assistant", ["content"] = content ?? "" });
  331. TryApplyRowInstructions(content ?? "");
  332. return;
  333. }
  334. var assistantMsg = new JObject
  335. {
  336. ["role"] = "assistant",
  337. ["content"] = string.IsNullOrEmpty(content) ? null : content,
  338. ["tool_calls"] = new JArray(toolCalls.Select(t => new JObject
  339. {
  340. ["id"] = t.Id,
  341. ["type"] = "function",
  342. ["function"] = new JObject { ["name"] = t.Name, ["arguments"] = t.Arguments },
  343. })),
  344. };
  345. messages.Add(assistantMsg);
  346. _historyMessages.Add(assistantMsg);
  347. // ask 模式下本轮含 ≥2 个修改类工具 → 整批只确认一次(选"否"退回逐个询问)
  348. _roundApproved = false;
  349. var modifyCalls = toolCalls
  350. .Where(t => !ReadOnlyTools.Contains(t.Name) && !SelfConfirmTools.Contains(t.Name))
  351. .ToList();
  352. if (!IsTrusted && modifyCalls.Count >= 2)
  353. {
  354. var summary = string.Join("\n", modifyCalls
  355. .GroupBy(t => t.Name)
  356. .Select(g => $"{g.Key} ×{g.Count()}"));
  357. var batchOk = MessageBox.Show(
  358. $"AI 本轮要执行 {modifyCalls.Count} 个修改操作:\n{summary}\n\n全部允许执行?(选“否”将逐个询问)",
  359. "批量权限确认", MessageBoxButton.YesNo, MessageBoxImage.Question);
  360. _roundApproved = batchOk == MessageBoxResult.Yes;
  361. Log("PERMISSION", $"round batch ({modifyCalls.Count} ops) → {(_roundApproved ? "allowed" : "per-call")}");
  362. }
  363. foreach (var tc in toolCalls)
  364. {
  365. _cts.Token.ThrowIfCancellationRequested();
  366. AppendToolCall(tc.Name, tc.Arguments);
  367. Log("TOOL_CALL", $"{tc.Name}({tc.Arguments})");
  368. string toolResultContent;
  369. if (!EnsurePermission(tc.Name, tc.Arguments))
  370. {
  371. toolResultContent = new JObject { ["ok"] = false, ["error"] = "用户在权限确认中拒绝了该操作" }.ToString(Newtonsoft.Json.Formatting.None);
  372. AppendToolResult(toolResultContent);
  373. }
  374. else
  375. {
  376. var result = _executeTool != null
  377. ? await _executeTool(tc.Name, tc.Arguments)
  378. : new JObject { ["ok"] = false, ["error"] = "工具执行器未注入" }.ToString(Newtonsoft.Json.Formatting.None);
  379. Log("TOOL_RESULT", result);
  380. AppendToolResult(result);
  381. toolResultContent = result;
  382. }
  383. var toolMsg = new JObject
  384. {
  385. ["role"] = "tool",
  386. ["tool_call_id"] = tc.Id,
  387. ["content"] = TruncateToolResult(toolResultContent),
  388. };
  389. messages.Add(toolMsg);
  390. _historyMessages.Add(toolMsg); // 历史要完整:tool_calls 后必须有对应 tool 结果
  391. }
  392. // 单轮工具调用多/结果大也可能撑爆上下文 → 追加后立即裁剪
  393. if (TrimContext(messages))
  394. AppendSystemLine("✂️ 工具调用较多,已自动裁剪最早轮次以控制上下文长度。");
  395. UpdateContextUsage(messages);
  396. }
  397. AppendSystemLine($"⚠️ 工具调用轮次已达上限({MaxToolRounds} 轮),已停止。");
  398. }
  399. catch (OperationCanceledException)
  400. {
  401. AppendSystemLine("⏹ 已停止。");
  402. Log("STOP", "用户停止了本次运行");
  403. }
  404. catch (Exception ex)
  405. {
  406. AppendSystemLine($"❌ {ex.Message}");
  407. Log("ERROR", ex.Message);
  408. }
  409. finally
  410. {
  411. _running = false;
  412. BtnSend.Content = "发送";
  413. _cts?.Dispose();
  414. _cts = null;
  415. }
  416. }
  417. /// <summary>运行中点击发送按钮 = 停止本次运行。</summary>
  418. private void StopAi()
  419. {
  420. _cts?.Cancel();
  421. AppendSystemLine("⏹ 正在停止…");
  422. Log("STOP_REQUEST", "用户点击了停止");
  423. }
  424. // ---------- 上下文窗口管理 ----------
  425. /// <summary>统计一条消息的字符数(content + 工具调用参数)。</summary>
  426. private static int MsgChars(JToken token)
  427. {
  428. var m = token as JObject;
  429. if (m == null) return 0;
  430. var n = (m["content"]?.ToString() ?? "").Length;
  431. if (m["tool_calls"] is JArray calls)
  432. foreach (var c in calls)
  433. n += (c["function"]?["arguments"]?.ToString() ?? "").Length;
  434. return n;
  435. }
  436. /// <summary>
  437. /// 消息超预算时按"轮次"裁剪最早的对话:
  438. /// 一轮 = user + assistant(+tool_calls) + 其后的若干 tool 结果,整体丢弃,
  439. /// 保证 tool_calls 与 tool 结果配对完整,不会拆散导致协议错误。
  440. /// 返回是否发生了裁剪。
  441. /// </summary>
  442. private bool TrimContext(JArray messages)
  443. {
  444. int total = messages.Sum(MsgChars);
  445. if (total <= ContextCharBudget) return false;
  446. // 1) 把 messages[1..] 按轮次分组(每轮从 user 开始,到下一个 user 前结束)
  447. var rounds = new List<List<JObject>>();
  448. var cur = new List<JObject>();
  449. for (int i = 1; i < messages.Count; i++)
  450. {
  451. var m = (JObject)messages[i];
  452. if ((m["role"]?.ToString() == "user") && cur.Count > 0)
  453. {
  454. rounds.Add(cur);
  455. cur = new List<JObject>();
  456. }
  457. cur.Add(m);
  458. }
  459. if (cur.Count > 0) rounds.Add(cur);
  460. // 2) 从最早轮次开始丢,直到剩余总长可放进预算
  461. int idx = 0;
  462. int remain = total;
  463. while (idx < rounds.Count && remain - SumRound(rounds[idx]) > ContextCharBudget)
  464. {
  465. remain -= SumRound(rounds[idx]);
  466. idx++;
  467. }
  468. // 3) 重组:system + 保留的轮次
  469. var trimmed = new JArray { messages[0] };
  470. for (int k = idx; k < rounds.Count; k++)
  471. foreach (var m in rounds[k]) trimmed.Add(m);
  472. messages.Clear();
  473. foreach (var m in trimmed) messages.Add(m);
  474. return idx > 0;
  475. }
  476. private static int SumRound(List<JObject> round) => round.Sum(MsgChars);
  477. /// <summary>刷新上下文用量指示器:估算消息 token 占用,按配置的最大上下文显示占比。</summary>
  478. private void UpdateContextUsage(JArray messages)
  479. {
  480. try
  481. {
  482. int used = ContextEstimator.EstimateMessagesTokens(messages);
  483. int max = Config.ResolveMaxContextTokens(_cfg.Translation);
  484. if (max > 0)
  485. {
  486. int percent = (int)Math.Round(used * 100.0 / max);
  487. ProgressContext.Value = Math.Min(percent, 100);
  488. ProgressContext.ToolTip = $"已用约 {ContextEstimator.FormatTokens(used)} / {ContextEstimator.FormatTokens(max)} token";
  489. TxtContextUsage.Text = $"上下文: {ContextEstimator.FormatTokens(used)} / {ContextEstimator.FormatTokens(max)}({percent}%)";
  490. ProgressContext.Foreground = percent >= 90
  491. ? System.Windows.Media.Brushes.OrangeRed
  492. : percent >= 70 ? System.Windows.Media.Brushes.Orange : System.Windows.Media.Brushes.DodgerBlue;
  493. }
  494. else
  495. {
  496. ProgressContext.Value = 0;
  497. TxtContextUsage.Text = $"上下文: 约 {ContextEstimator.FormatTokens(used)} token(未设上限)";
  498. }
  499. }
  500. catch
  501. {
  502. // 指示器失败不影响对话
  503. }
  504. }
  505. /// <summary>工具结果超长时截断(保留头部,提示省略)。</summary>
  506. private static string TruncateToolResult(string result)
  507. {
  508. if (string.IsNullOrEmpty(result) || result.Length <= ToolResultCharLimit) return result;
  509. return result.Substring(0, ToolResultCharLimit) + $"\n…(已截断,共 {result.Length} 字符)";
  510. }
  511. // ---------- 消息流 UI ----------
  512. private RichTextBox MakeReadOnlyRtb(double fontSize, Brush foreground = null)
  513. {
  514. var doc = new FlowDocument(new Paragraph { Margin = new Thickness(0) });
  515. doc.PagePadding = new Thickness(0);
  516. var rtb = new RichTextBox
  517. {
  518. Document = doc,
  519. IsReadOnly = true,
  520. BorderThickness = new Thickness(0),
  521. Background = Brushes.Transparent,
  522. Padding = new Thickness(0),
  523. IsUndoEnabled = false,
  524. VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
  525. HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
  526. FontSize = fontSize,
  527. };
  528. if (foreground != null) rtb.Foreground = foreground;
  529. return rtb;
  530. }
  531. private void AppendUserMessage(string text)
  532. {
  533. var rtb = MakeReadOnlyRtb(13);
  534. var p = (Paragraph)rtb.Document.Blocks.First();
  535. p.Inlines.Add(new Run("你:") { FontWeight = FontWeights.Bold });
  536. p.Inlines.Add(text);
  537. rtb.Margin = new Thickness(0, 10, 0, 2);
  538. ChatPanel.Children.Add(rtb);
  539. ScrollToEnd();
  540. }
  541. private RichTextBox AppendAiContentBlock(out Paragraph para)
  542. {
  543. var rtb = MakeReadOnlyRtb(13);
  544. var p = (Paragraph)rtb.Document.Blocks.First();
  545. p.Inlines.Add(new Run("AI:") { FontWeight = FontWeights.Bold });
  546. rtb.Margin = new Thickness(0, 2, 0, 8);
  547. ChatPanel.Children.Add(rtb);
  548. ScrollToEnd();
  549. para = p;
  550. return rtb;
  551. }
  552. private (Expander expander, Paragraph para) AppendThinkingBlock()
  553. {
  554. var rtb = MakeReadOnlyRtb(11, Brushes.Gray);
  555. var p = (Paragraph)rtb.Document.Blocks.First();
  556. var expander = new Expander
  557. {
  558. Header = "💭 思考中…(点击展开)",
  559. IsExpanded = false,
  560. Content = rtb,
  561. Margin = new Thickness(0, 6, 0, 2),
  562. };
  563. ChatPanel.Children.Add(expander);
  564. ScrollToEnd();
  565. return (expander, p);
  566. }
  567. private void AppendSystemLine(string text)
  568. {
  569. var rtb = MakeReadOnlyRtb(12, Brushes.DarkOrange);
  570. ((Paragraph)rtb.Document.Blocks.First()).Inlines.Add(text);
  571. rtb.Margin = new Thickness(0, 4, 0, 4);
  572. ChatPanel.Children.Add(rtb);
  573. ScrollToEnd();
  574. }
  575. private void AppendLineSpacer()
  576. {
  577. ChatPanel.Children.Add(new TextBlock { Text = " ", FontSize = 6 });
  578. }
  579. private void AppendToolCall(string name, string arguments)
  580. {
  581. var argsShort = arguments ?? "";
  582. if (argsShort.Length > 120) argsShort = argsShort.Substring(0, 120) + "…";
  583. var tb = new TextBlock
  584. {
  585. TextWrapping = TextWrapping.Wrap,
  586. Margin = new Thickness(0, 4, 0, 0),
  587. FontSize = 12,
  588. };
  589. tb.Inlines.Add(new Run("🔧 "));
  590. tb.Inlines.Add(new Run(name) { FontWeight = FontWeights.Bold, Foreground = Brushes.RoyalBlue });
  591. tb.Inlines.Add(new Run($"({argsShort})") { Foreground = Brushes.Gray, FontSize = 11 });
  592. ChatPanel.Children.Add(tb);
  593. ScrollToEnd();
  594. }
  595. private void AppendToolResult(string result)
  596. {
  597. var shortResult = result ?? "";
  598. if (shortResult.Length > 160) shortResult = shortResult.Substring(0, 160) + "…";
  599. var tb = new TextBlock
  600. {
  601. Text = $" ↳ {shortResult}",
  602. TextWrapping = TextWrapping.Wrap,
  603. Margin = new Thickness(18, 0, 0, 2),
  604. FontSize = 11,
  605. Foreground = Brushes.DarkSlateGray,
  606. };
  607. ChatPanel.Children.Add(tb);
  608. ScrollToEnd();
  609. }
  610. private async void BtnSend_Click(object sender, RoutedEventArgs e)
  611. {
  612. if (_running) { StopAi(); return; }
  613. await SendAsync();
  614. }
  615. // 用 PreviewKeyDown(隧道事件)在 TextBox 内部处理回车之前拦截:
  616. // 普通 KeyDown 会晚于 TextBoxBase 的类处理器,回车已被消费成换行。
  617. private async void TxtInput_PreviewKeyDown(object sender, KeyEventArgs e)
  618. {
  619. if (e.Key != Key.Enter) return;
  620. // Ctrl+Enter = 换行:WPF 的 TextBox 不响应 Ctrl+Enter(只认纯回车),需手动在光标处插入
  621. if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control)
  622. {
  623. var tb = (TextBox)sender;
  624. tb.SelectedText = "\n"; // 有选中内容则替换,否则在光标处插入
  625. tb.CaretIndex++; // 光标移到新行
  626. e.Handled = true;
  627. return;
  628. }
  629. // Enter = 发送(输入法组词中的回车以 ImeProcessed 到达,不会进到这里,不会误发)
  630. e.Handled = true;
  631. if (_running) { StopAi(); return; }
  632. await SendAsync();
  633. }
  634. private void BtnSavePrompt_Click(object sender, RoutedEventArgs e)
  635. {
  636. _cfg.AiChat.CustomPrompt = TxtSystemPrompt.Text;
  637. Config.Save(_cfg);
  638. AppendSystemLine("✅ 提示词已保存为自定义版本(点“↺ 恢复默认”可回到按语种动态生成)。");
  639. }
  640. private void BtnResetPrompt_Click(object sender, RoutedEventArgs e)
  641. {
  642. _cfg.AiChat.CustomPrompt = "";
  643. Config.Save(_cfg);
  644. TxtSystemPrompt.Text = BuildDefaultPrompt(_langs);
  645. AppendSystemLine("✅ 已恢复动态默认提示词(按当前语种生成)。");
  646. }
  647. /// <summary>扫描回复中的 ```json {"action":"add","rows":[...]} ``` 指令块并执行(旧机制兼容,主用工具调用)。</summary>
  648. private void TryApplyRowInstructions(string reply)
  649. {
  650. if (string.IsNullOrEmpty(reply) || _applyRows == null) return;
  651. var all = new List<AddRowPayload>();
  652. var matches = Regex.Matches(reply, "```(?:json)?\\s*(\\{.*?\\})\\s*```", RegexOptions.Singleline);
  653. foreach (Match m in matches)
  654. {
  655. try
  656. {
  657. var j = JObject.Parse(m.Groups[1].Value);
  658. if (!string.Equals((string)j["action"], "add", StringComparison.OrdinalIgnoreCase)) continue;
  659. if (!(j["rows"] is JArray rowsToken)) continue;
  660. foreach (var r in rowsToken)
  661. {
  662. var key = (string)r["key"];
  663. if (string.IsNullOrWhiteSpace(key)) continue;
  664. if (key.Contains('<') || key.Contains('>') || key.Contains('{') || key.Contains("译文"))
  665. continue;
  666. var trans = new Dictionary<string, string>();
  667. if (r["translations"] is JObject t)
  668. foreach (var pp in t.Properties())
  669. trans[pp.Name] = (string)pp.Value ?? "";
  670. all.Add(new AddRowPayload { Key = key.Trim(), Translations = trans });
  671. }
  672. }
  673. catch { }
  674. }
  675. if (all.Count == 0) return;
  676. try
  677. {
  678. _applyRows(all);
  679. AppendSystemLine($"✅ 已按 AI 指令添加 {all.Count} 行,语言包已保存并刷新表格。");
  680. }
  681. catch (Exception ex)
  682. {
  683. AppendSystemLine($"❌ 行指令执行失败:{ex.Message}");
  684. }
  685. }
  686. /// <summary>手动压缩上下文:移除早期对话轮次以释放 token 空间。</summary>
  687. private void BtnCompressContext_Click(object sender, RoutedEventArgs e)
  688. {
  689. if (_historyMessages.Count < 2)
  690. {
  691. MessageBox.Show("对话轮次不足,至少需要 2 轮对话才能进行有效压缩。",
  692. "提示", MessageBoxButton.OK, MessageBoxImage.Information);
  693. return;
  694. }
  695. int currentTokens = ContextEstimator.EstimateMessagesTokens(_historyMessages);
  696. int targetTokens = ContextCharBudget / 2 / 2; // 压缩到预算的 25%(约一半)
  697. if (currentTokens <= targetTokens)
  698. {
  699. AppendSystemLine($"ℹ️ 当前上下文大小 ({ContextEstimator.FormatTokens(currentTokens)} token) 已低于压缩目标 ({ContextEstimator.FormatTokens(targetTokens)} token),无需压缩。");
  700. return;
  701. }
  702. int messagesToKeep = Math.Max(2, _historyMessages.Count / 2);
  703. int toRemove = _historyMessages.Count - messagesToKeep;
  704. // 移除最早的 toRemove 条消息(保留最新消息)
  705. for (int i = 0; i < toRemove; i++)
  706. {
  707. _historyMessages.RemoveAt(0);
  708. }
  709. int newTokens = ContextEstimator.EstimateMessagesTokens(_historyMessages);
  710. UpdateContextUsage(_historyMessages);
  711. AppendSystemLine($"🗜️ 上下文压缩完成!已裁剪 {toRemove} 轮最早对话,保留 {messagesToKeep} 轮最新消息。使用 token 从 {ContextEstimator.FormatTokens(currentTokens)} 降至 {ContextEstimator.FormatTokens(newTokens)}。");
  712. }
  713. }
  714. }