| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814 |
- using System;
- using System.Collections.Generic;
- using System.Linq;
- using System.Text;
- using System.Text.RegularExpressions;
- using System.Threading.Tasks;
- using System.Threading;
- using System.IO;
- using System.Windows;
- using System.Windows.Controls;
- using System.Windows.Documents;
- using System.Windows.Input;
- using System.Windows.Media;
- using LocalizationTool.Models;
- using LocalizationTool.Services;
- using Newtonsoft.Json.Linq;
- namespace LocalizationTool.Views
- {
- /// <summary>
- /// AI 问答窗口:流式对话 + 工具调用(Function Calling)。
- /// 此窗口交互性强(逐字渲染、折叠思考区),故保留在 code-behind。
- /// </summary>
- public partial class AiChatWindow : Window
- {
- private const int MaxToolRounds = 5000;
- /// <summary>单条工具结果的最大字符数(超出截断,防单条撑爆上下文)。</summary>
- private const int ToolResultCharLimit = 4000;
- /// <summary>上下文裁剪阈值:按最大上下文 token 的 60% 折算为字符预算(1 token ≈ 2 字符粗估),超出则裁剪最早轮次。</summary>
- private int ContextCharBudget =>
- Math.Max(20000, (int)(Config.ResolveMaxContextTokens(_cfg.Translation) * 0.6 * 2));
- private readonly Config.ConfigRoot _cfg;
- private readonly Action<List<AddRowPayload>> _applyRows;
- private readonly List<PackFile> _langs;
- private readonly JArray _tools;
- private readonly Func<string, string, Task<string>> _executeTool;
- private readonly JArray _historyMessages = new JArray();
- private CancellationTokenSource _cts;
- private bool _running;
- private StreamWriter _logWriter;
- public AiChatWindow(Config.ConfigRoot cfg, List<PackFile> targetLangs,
- JArray tools, Func<string, string, Task<string>> executeTool, Action<List<AddRowPayload>> applyRows)
- {
- InitializeComponent();
- Icon = App.AppIcon;
- _cfg = cfg ?? new Config.ConfigRoot();
- _langs = targetLangs ?? new List<PackFile>();
- _tools = tools;
- _executeTool = executeTool;
- _applyRows = applyRows;
- InitLog();
- CmbPermission.ItemsSource = new List<string> { "每次询问(修改需确认)", "完全信任(自动执行)" };
- CmbPermission.SelectedIndex = IsTrusted ? 1 : 0;
- TxtSystemPrompt.Text = string.IsNullOrWhiteSpace(_cfg.AiChat?.CustomPrompt)
- ? BuildDefaultPrompt(_langs)
- : _cfg.AiChat.CustomPrompt;
- AppendSystemLine("🤖 AI 助手已就绪。它可以直接查询/新增/修改/删除词条并保存文件——直接下指令即可。");
- LoadQuickPrompts();
- }
- private bool IsTrusted =>
- string.Equals(_cfg.AiChat?.PermissionMode, "trusted", StringComparison.OrdinalIgnoreCase);
- private static readonly HashSet<string> ReadOnlyTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
- {
- "list_languages", "get_stats", "search_entries", "get_entry",
- "get_validation_issues", "get_missing_translations",
- "read_file", "list_directory",
- "web_search", "fetch_url",
- };
- /// <summary>自带确认框的删除类工具:执行器内部会弹详细删除确认,权限层不再重复询问(避免一次删除弹两个框)。</summary>
- private static readonly HashSet<string> SelfConfirmTools = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
- {
- "delete_entry", "delete_entry_by_index", "delete_entries",
- };
- /// <summary>本轮工具调用已整批放行(ask 模式下多操作批量确认一次)。</summary>
- private bool _roundApproved;
- /// <summary>只读工具直接放行;修改类工具在 ask 模式下需用户确认(删除类自带确认框、或本轮已整批放行的除外)。</summary>
- private bool EnsurePermission(string toolName, string argsJson)
- {
- if (ReadOnlyTools.Contains(toolName) || SelfConfirmTools.Contains(toolName)) return true;
- if (IsTrusted || _roundApproved)
- {
- Log("PERMISSION", $"{toolName} → {(IsTrusted ? "allowed (trusted)" : "allowed (round batch)")}");
- return true;
- }
- var argsShort = argsJson ?? "";
- if (argsShort.Length > 160) argsShort = argsShort.Substring(0, 160) + "…";
- var confirm = MessageBox.Show($"AI 请求执行工具:{toolName}\n\n参数:{argsShort}\n\n允许执行?",
- "权限确认", MessageBoxButton.YesNo, MessageBoxImage.Question);
- var allowed = confirm == MessageBoxResult.Yes;
- Log("PERMISSION", $"{toolName} → {(allowed ? "allowed" : "denied (user)")}");
- return allowed;
- }
- /// <summary>权限模式切换:立即持久化。</summary>
- private void CmbPermission_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- _cfg.AiChat.PermissionMode = CmbPermission.SelectedIndex == 1 ? "trusted" : "ask";
- Config.Save(_cfg);
- Log("PERMISSION_MODE", _cfg.AiChat.PermissionMode);
- AppendSystemLine(CmbPermission.SelectedIndex == 1
- ? "🔓 已切换为【完全信任】:所有工具自动执行,不弹确认(操作仍全部记录日志)"
- : "🔒 已切换为【每次询问】:修改类工具执行前会弹确认框");
- }
- // ---------- 会话日志 ----------
- private void InitLog()
- {
- try
- {
- var dir = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AiLogs");
- Directory.CreateDirectory(dir);
- var path = Path.Combine(dir, $"ai_{DateTime.Now:yyyyMMdd_HHmmss}.log");
- _logWriter = new StreamWriter(path, true, Encoding.UTF8) { AutoFlush = true };
- _logWriter.WriteLine($"========== 会话开始 {DateTime.Now:yyyy-MM-dd HH:mm:ss} ==========");
- _logWriter.WriteLine($"[SYS] 目标语言: {string.Join(", ", _langs.Select(l => l.LanguageCode))}");
- _logWriter.WriteLine($"[SYS] 权限模式: {_cfg.AiChat?.PermissionMode ?? "ask"}");
- }
- catch { }
- }
- private void Log(string tag, string text)
- {
- try { _logWriter?.WriteLine($"[{DateTime.Now:HH:mm:ss}] [{tag}] {text}"); }
- catch { }
- }
- private void CloseLog()
- {
- try
- {
- _logWriter?.WriteLine($"========== 会话结束 {DateTime.Now:HH:mm:ss} ==========");
- _logWriter?.Dispose();
- _logWriter = null;
- }
- catch { }
- }
- protected override void OnClosed(EventArgs e)
- {
- CloseLog();
- base.OnClosed(e);
- }
- /// <summary>按当前语种动态生成默认系统提示词。</summary>
- internal static string BuildDefaultPrompt(List<PackFile> langs)
- {
- var list = (langs ?? new List<PackFile>()).Where(l => !l.IsSource).ToList();
- if (list.Count == 0)
- {
- list = new List<PackFile>
- {
- new PackFile { LanguageCode = "en-US", LanguageName = "English" },
- new PackFile { LanguageCode = "ja-JP", LanguageName = "日本語" },
- };
- }
- var langList = string.Join("、", list.Select(l =>
- {
- var name = LanguageCatalog.Find(l.LanguageCode)?.Name ?? l.LanguageName;
- return $"{name}({l.LanguageCode})";
- }));
- var example = string.Join(", ", list.Select(l => $"\"{l.LanguageCode}\": \"<{l.LanguageName}译文>\""));
- var sb = new StringBuilder();
- sb.AppendLine("你是 TeamAAS 本地化助手,负责软件界面词条的翻译与整理。");
- sb.AppendLine($"当前语言包的目标语言共 {list.Count} 种:{langList}。");
- sb.AppendLine();
- sb.AppendLine("当用户要求新增词条(或你判断需要补充词条)时,输出一个 json 指令代码块(用 ```json 包裹),格式:");
- sb.AppendLine("```json");
- sb.AppendLine("{ \"action\": \"add\", \"rows\": [ { \"key\": \"中文原文\", \"translations\": { " + example + " } } ] }");
- sb.AppendLine("```");
- sb.AppendLine($"- translations 只需包含上述 {list.Count} 种语言,key 为中文原文,译文使用对应语言的地道表达,品牌名/型号保持原文。");
- sb.AppendLine("- 翻译时数字、英文单词、单位、特殊符号、表情图标必须保持原样不翻译,符号的位置和数量与原文一致。");
- sb.AppendLine("- 可在同一个 rows 数组里放多条,实现批量添加。");
- sb.AppendLine("- 该格式仅在需要添加词条时输出,不要在普通对话中复述示例。");
- sb.AppendLine();
- sb.AppendLine("## 工具能力");
- sb.AppendLine("你可以调用以下工具直接操作语言包(用户提出查询/修改/新增/删除/保存请求时优先调用工具):");
- sb.AppendLine("- list_languages(): 列出已加载语言及词条统计");
- sb.AppendLine("- get_stats(): 词条总数与各语言缺译统计");
- sb.AppendLine("- search_entries(keyword): 按关键词搜索词条");
- sb.AppendLine("- get_entry(key): 读取某词条的全部译文");
- sb.AppendLine("- add_entry(key, translations): 新增词条并保存");
- sb.AppendLine("- update_translation(key, lang, value): 更新某语言译文并保存");
- sb.AppendLine("- delete_entry(key): 删除单个词条(需用户明确要求)");
- sb.AppendLine("- delete_entries(keys?, indices?): 批量删除多个词条(keys 或 indices 数组二选一或混用,只弹一次确认)。删除多个词条时必须用本工具一次性完成,禁止逐条调 delete_entry");
- sb.AppendLine("- get_validation_issues(): 获取最近校验发现的问题词条(含表格序号 index、原文/语言/当前错误译文/原因)。日语等豁免语言的同文条目已自动排除");
- sb.AppendLine("- batch_update_translations(entries): 批量写回修正");
- sb.AppendLine("- get_missing_translations(lang): 列出缺译词条(含表格序号 index)。lang 可选");
- sb.AppendLine("- baidu_translate_missing(lang, indices?): 用百度翻译批量补译缺译并保存(indices 可选,按表格序号只补指定行;缺省补全部缺译)");
- sb.AppendLine("- delete_entry_by_index(index): 按表格序号删除单个词条(会弹确认框)");
- sb.AppendLine("- read_file(path): 读取任意路径文本文件(自动识别 UTF-8/GBK)。改代码前先读原文件");
- sb.AppendLine("- write_file(path, content): 写入/覆盖任意路径文件(父目录自动创建)");
- sb.AppendLine("- edit_file(path, old_text, new_text): 对任意路径文件做局部替换(old_text 必须唯一出现)。改代码首选,先 read_file 再替换");
- sb.AppendLine("- list_directory(path): 列出目录下的子目录与文件(自动跳过 bin/obj/.git 等)");
- sb.AppendLine("- run_command(command, dotnet?): 执行 cmd/dotnet 命令获取输出。适合查询系统信息、目录内容、git 操作、编译等");
- sb.AppendLine("- web_search(query, count?): 联网搜索,返回网页标题/链接/摘要。用户问到你不确定的行业术语、产品名、最新资料时使用");
- sb.AppendLine("- fetch_url(url): 读取网页正文文本。配合 web_search 使用:先搜索拿到链接,再读取正文了解详情");
- sb.AppendLine("- save_all(): 保存全部语言包到磁盘");
- sb.AppendLine();
- sb.AppendLine("## 工具权限说明");
- sb.AppendLine("只读工具(list_languages/get_stats/search_entries/get_entry/get_validation_issues/get_missing_translations/read_file/list_directory/web_search/fetch_url)始终直接执行;");
- sb.AppendLine("修改类工具(write_file/edit_file/run_command 及词条增删改等)在 ask 权限模式下需要用户在确认框中允许,trusted 权限模式下自动执行;所有操作均被记录到日志。");
- sb.AppendLine();
- sb.AppendLine("## 文件/代码操作(重要)");
- sb.AppendLine("用户要求查看或修改代码/文件时:");
- sb.AppendLine("1. 先用 list_directory() 摸清目录结构,再用 read_file() 读取目标文件;");
- sb.AppendLine("2. 修改优先用 edit_file() 做局部替换(old_text 取文件中的唯一上下文,宁可多取几行保证唯一);");
- sb.AppendLine("3. 新建文件用 write_file();需要编译/测试用 run_command(dotnet ...);");
- sb.AppendLine("4. 修改后向用户汇报改了什么,若用户要求可再用 run_command 编译验证。");
- sb.AppendLine();
- sb.AppendLine("## 批量重译工作流(重要)");
- sb.AppendLine("用户要求\"重新翻译有问题的词条/修正错误翻译\"时:");
- sb.AppendLine("1. 先调用 get_validation_issues() 获取全部问题词条;");
- sb.AppendLine("2. 逐条分析问题原因(如译文丢失正文、疑似未翻译),结合中文原文重新给出地道译文;");
- sb.AppendLine("3. 调用一次 batch_update_translations() 把所有修正一次性写回——禁止逐条调用 update_translation 循环修改;");
- sb.AppendLine("4. 完成后向用户汇报修正数量与内容。");
- sb.AppendLine("注意:问题清单里的 index 是表格序号,用户按序号要求操作(如\"删除第 5 行\"\"用百度翻译补译第 3、8 行\")时直接传该 index。");
- sb.AppendLine("注意:日语中译文与中文原文完全相同通常是汉字词的正常写法,这类条目已在问题清单中自动排除,不要尝试重译它们。");
- sb.AppendLine();
- sb.AppendLine("## 拆分添加规则(重要)");
- sb.AppendLine("用户提供一段内容要求添加词条时(如\"手机,电脑,冰红茶\"),先按以下分隔符把内容拆分为多个独立词条:");
- sb.AppendLine("空格、逗号(,)、句号(.)、中文逗号(,)、中文句号(。)、冒号(:)、中文冒号(:)、大于号(>)、制表符(Tab)、左方括号([)、右方括号(])、感叹号(!)、中文感叹号(!)、问号(?)、中文问号(?)、连字符(-)");
- sb.AppendLine("1. 每段去除首尾空白,忽略空段;");
- sb.AppendLine("2. 每段作为一条独立的中文词条(key),分别翻译成所有目标语言;");
- sb.AppendLine("3. 调用一次 add_entries() 批量添加(禁止逐条调用 add_entry);");
- sb.AppendLine("4. 完成后向用户汇报添加了哪些词条。");
- sb.AppendLine();
- sb.AppendLine("其他正常对话直接回答。");
- return sb.ToString();
- }
- // ---------- UI 辅助 ----------
- private void ScrollToEnd() => ChatScroll.ScrollToEnd();
- public class PromptOption
- {
- public string Name { get; set; }
- public string Text { get; set; }
- public override string ToString() => Name;
- }
- private readonly List<PromptOption> _quickPrompts = new List<PromptOption>
- {
- new PromptOption { Name = "🔁 重译校验失败词条", Text = "把我校验失败的内容进行重新翻译" },
- new PromptOption { Name = "📊 缺译统计", Text = "统计一下各语言的缺译情况" },
- new PromptOption { Name = "🈳 补齐全部缺译", Text = "把所有缺译的词条全部补齐翻译并保存" },
- new PromptOption { Name = "🧹 清理多余空格", Text = "把所有译文首尾的多余空格清理掉并保存" },
- new PromptOption { Name = "❓ 检查疑似未翻译", Text = "列出疑似未翻译的词条" },
- new PromptOption { Name = "📖 查看指定词条译文", Text = "帮我读取这条词条的全部译文:" },
- };
- private bool _suppressQuickReset;
- private void LoadQuickPrompts()
- {
- CmbQuickPrompts.ItemsSource = _quickPrompts;
- }
- private void CmbQuickPrompts_SelectionChanged(object sender, SelectionChangedEventArgs e)
- {
- if (_suppressQuickReset) return;
- if (!(CmbQuickPrompts.SelectedItem is PromptOption p) || string.IsNullOrEmpty(p.Text)) return;
- TxtInput.Text = p.Text;
- TxtInput.Focus();
- TxtInput.CaretIndex = TxtInput.Text.Length;
- ScrollToEnd();
- _suppressQuickReset = true;
- CmbQuickPrompts.SelectedIndex = -1;
- _suppressQuickReset = false;
- }
- private async Task SendAsync()
- {
- if (_running) return;
- var input = TxtInput.Text?.Trim();
- if (string.IsNullOrEmpty(input)) return;
- var openai = new OpenAiCompatibleProvider(_cfg.Translation);
- if (!openai.IsConfigured)
- {
- AppendSystemLine("⚠️ 请先在“翻译设置 → OpenAI 配置”填写 BaseUrl / Model / API Key。");
- return;
- }
- TxtInput.Clear();
- AppendUserMessage(input);
- Log("USER", input);
- var messages = new JArray
- {
- new JObject { ["role"] = "system", ["content"] = TxtSystemPrompt.Text },
- };
- foreach (var m in _historyMessages) messages.Add(m);
- messages.Add(new JObject { ["role"] = "user", ["content"] = input });
- _historyMessages.Add(new JObject { ["role"] = "user", ["content"] = input }); // 历史完整:user 先入
- // 历史超预算 → 裁剪最早轮次,防上下文超限(模型输出截断导致 tool arguments 非法)
- if (TrimContext(messages))
- AppendSystemLine("✂️ 对话历史较长,已自动裁剪最早的几轮(保留最近对话,防止上下文超限)。");
- UpdateContextUsage(messages);
- _cts = new CancellationTokenSource();
- _running = true;
- BtnSend.Content = "⏹ 停止";
- Log("RUN", "开始");
- try
- {
- for (int round = 0; round < MaxToolRounds; round++)
- {
- _cts.Token.ThrowIfCancellationRequested();
- Expander thinkingBox = null;
- Paragraph thinkingPara = null;
- var thinkingSb = new StringBuilder();
- RichTextBox contentRtb = null;
- Paragraph contentPara = null;
- var contentSb = new StringBuilder();
- var (content, toolCalls) = await openai.StreamChatAsync(messages,
- delta =>
- {
- Dispatcher.Invoke(() =>
- {
- if (contentRtb == null) contentRtb = AppendAiContentBlock(out contentPara);
- contentSb.Append(delta);
- contentPara.Inlines.Add(new Run(delta));
- ScrollToEnd();
- });
- },
- reasoning =>
- {
- Dispatcher.Invoke(() =>
- {
- if (thinkingBox == null)
- (thinkingBox, thinkingPara) = AppendThinkingBlock();
- thinkingSb.Append(reasoning);
- thinkingPara.Inlines.Add(new Run(reasoning));
- thinkingBox.Header = $"💭 思考中…(已思考 {thinkingSb.Length} 字,点击展开)";
- ScrollToEnd();
- });
- },
- _tools, _cts.Token);
- if (thinkingSb.Length > 0) Log("REASONING", thinkingSb.ToString());
- Log("CONTENT", contentSb.ToString());
- if (thinkingBox != null)
- thinkingBox.Header = $"💭 思考过程({thinkingSb.Length} 字 · 已完成,点击展开)";
- if (contentRtb == null && thinkingBox != null)
- AppendLineSpacer();
- if (toolCalls == null || toolCalls.Count == 0)
- {
- // user 已在发送时入历史,这里只补 assistant
- _historyMessages.Add(new JObject { ["role"] = "assistant", ["content"] = content ?? "" });
- TryApplyRowInstructions(content ?? "");
- return;
- }
- var assistantMsg = new JObject
- {
- ["role"] = "assistant",
- ["content"] = string.IsNullOrEmpty(content) ? null : content,
- ["tool_calls"] = new JArray(toolCalls.Select(t => new JObject
- {
- ["id"] = t.Id,
- ["type"] = "function",
- ["function"] = new JObject { ["name"] = t.Name, ["arguments"] = t.Arguments },
- })),
- };
- messages.Add(assistantMsg);
- _historyMessages.Add(assistantMsg);
- // ask 模式下本轮含 ≥2 个修改类工具 → 整批只确认一次(选"否"退回逐个询问)
- _roundApproved = false;
- var modifyCalls = toolCalls
- .Where(t => !ReadOnlyTools.Contains(t.Name) && !SelfConfirmTools.Contains(t.Name))
- .ToList();
- if (!IsTrusted && modifyCalls.Count >= 2)
- {
- var summary = string.Join("\n", modifyCalls
- .GroupBy(t => t.Name)
- .Select(g => $"{g.Key} ×{g.Count()}"));
- var batchOk = MessageBox.Show(
- $"AI 本轮要执行 {modifyCalls.Count} 个修改操作:\n{summary}\n\n全部允许执行?(选“否”将逐个询问)",
- "批量权限确认", MessageBoxButton.YesNo, MessageBoxImage.Question);
- _roundApproved = batchOk == MessageBoxResult.Yes;
- Log("PERMISSION", $"round batch ({modifyCalls.Count} ops) → {(_roundApproved ? "allowed" : "per-call")}");
- }
- foreach (var tc in toolCalls)
- {
- _cts.Token.ThrowIfCancellationRequested();
- AppendToolCall(tc.Name, tc.Arguments);
- Log("TOOL_CALL", $"{tc.Name}({tc.Arguments})");
- string toolResultContent;
- if (!EnsurePermission(tc.Name, tc.Arguments))
- {
- toolResultContent = new JObject { ["ok"] = false, ["error"] = "用户在权限确认中拒绝了该操作" }.ToString(Newtonsoft.Json.Formatting.None);
- AppendToolResult(toolResultContent);
- }
- else
- {
- var result = _executeTool != null
- ? await _executeTool(tc.Name, tc.Arguments)
- : new JObject { ["ok"] = false, ["error"] = "工具执行器未注入" }.ToString(Newtonsoft.Json.Formatting.None);
- Log("TOOL_RESULT", result);
- AppendToolResult(result);
- toolResultContent = result;
- }
- var toolMsg = new JObject
- {
- ["role"] = "tool",
- ["tool_call_id"] = tc.Id,
- ["content"] = TruncateToolResult(toolResultContent),
- };
- messages.Add(toolMsg);
- _historyMessages.Add(toolMsg); // 历史要完整:tool_calls 后必须有对应 tool 结果
- }
- // 单轮工具调用多/结果大也可能撑爆上下文 → 追加后立即裁剪
- if (TrimContext(messages))
- AppendSystemLine("✂️ 工具调用较多,已自动裁剪最早轮次以控制上下文长度。");
- UpdateContextUsage(messages);
- }
- AppendSystemLine($"⚠️ 工具调用轮次已达上限({MaxToolRounds} 轮),已停止。");
- }
- catch (OperationCanceledException)
- {
- AppendSystemLine("⏹ 已停止。");
- Log("STOP", "用户停止了本次运行");
- }
- catch (Exception ex)
- {
- AppendSystemLine($"❌ {ex.Message}");
- Log("ERROR", ex.Message);
- }
- finally
- {
- _running = false;
- BtnSend.Content = "发送";
- _cts?.Dispose();
- _cts = null;
- }
- }
- /// <summary>运行中点击发送按钮 = 停止本次运行。</summary>
- private void StopAi()
- {
- _cts?.Cancel();
- AppendSystemLine("⏹ 正在停止…");
- Log("STOP_REQUEST", "用户点击了停止");
- }
- // ---------- 上下文窗口管理 ----------
- /// <summary>统计一条消息的字符数(content + 工具调用参数)。</summary>
- private static int MsgChars(JToken token)
- {
- var m = token as JObject;
- if (m == null) return 0;
- var n = (m["content"]?.ToString() ?? "").Length;
- if (m["tool_calls"] is JArray calls)
- foreach (var c in calls)
- n += (c["function"]?["arguments"]?.ToString() ?? "").Length;
- return n;
- }
- /// <summary>
- /// 消息超预算时按"轮次"裁剪最早的对话:
- /// 一轮 = user + assistant(+tool_calls) + 其后的若干 tool 结果,整体丢弃,
- /// 保证 tool_calls 与 tool 结果配对完整,不会拆散导致协议错误。
- /// 返回是否发生了裁剪。
- /// </summary>
- private bool TrimContext(JArray messages)
- {
- int total = messages.Sum(MsgChars);
- if (total <= ContextCharBudget) return false;
- // 1) 把 messages[1..] 按轮次分组(每轮从 user 开始,到下一个 user 前结束)
- var rounds = new List<List<JObject>>();
- var cur = new List<JObject>();
- for (int i = 1; i < messages.Count; i++)
- {
- var m = (JObject)messages[i];
- if ((m["role"]?.ToString() == "user") && cur.Count > 0)
- {
- rounds.Add(cur);
- cur = new List<JObject>();
- }
- cur.Add(m);
- }
- if (cur.Count > 0) rounds.Add(cur);
- // 2) 从最早轮次开始丢,直到剩余总长可放进预算
- int idx = 0;
- int remain = total;
- while (idx < rounds.Count && remain - SumRound(rounds[idx]) > ContextCharBudget)
- {
- remain -= SumRound(rounds[idx]);
- idx++;
- }
- // 3) 重组:system + 保留的轮次
- var trimmed = new JArray { messages[0] };
- for (int k = idx; k < rounds.Count; k++)
- foreach (var m in rounds[k]) trimmed.Add(m);
- messages.Clear();
- foreach (var m in trimmed) messages.Add(m);
- return idx > 0;
- }
- private static int SumRound(List<JObject> round) => round.Sum(MsgChars);
- /// <summary>刷新上下文用量指示器:估算消息 token 占用,按配置的最大上下文显示占比。</summary>
- private void UpdateContextUsage(JArray messages)
- {
- try
- {
- int used = ContextEstimator.EstimateMessagesTokens(messages);
- int max = Config.ResolveMaxContextTokens(_cfg.Translation);
- if (max > 0)
- {
- int percent = (int)Math.Round(used * 100.0 / max);
- ProgressContext.Value = Math.Min(percent, 100);
- ProgressContext.ToolTip = $"已用约 {ContextEstimator.FormatTokens(used)} / {ContextEstimator.FormatTokens(max)} token";
- TxtContextUsage.Text = $"上下文: {ContextEstimator.FormatTokens(used)} / {ContextEstimator.FormatTokens(max)}({percent}%)";
- ProgressContext.Foreground = percent >= 90
- ? System.Windows.Media.Brushes.OrangeRed
- : percent >= 70 ? System.Windows.Media.Brushes.Orange : System.Windows.Media.Brushes.DodgerBlue;
- }
- else
- {
- ProgressContext.Value = 0;
- TxtContextUsage.Text = $"上下文: 约 {ContextEstimator.FormatTokens(used)} token(未设上限)";
- }
- }
- catch
- {
- // 指示器失败不影响对话
- }
- }
- /// <summary>工具结果超长时截断(保留头部,提示省略)。</summary>
- private static string TruncateToolResult(string result)
- {
- if (string.IsNullOrEmpty(result) || result.Length <= ToolResultCharLimit) return result;
- return result.Substring(0, ToolResultCharLimit) + $"\n…(已截断,共 {result.Length} 字符)";
- }
- // ---------- 消息流 UI ----------
- private RichTextBox MakeReadOnlyRtb(double fontSize, Brush foreground = null)
- {
- var doc = new FlowDocument(new Paragraph { Margin = new Thickness(0) });
- doc.PagePadding = new Thickness(0);
- var rtb = new RichTextBox
- {
- Document = doc,
- IsReadOnly = true,
- BorderThickness = new Thickness(0),
- Background = Brushes.Transparent,
- Padding = new Thickness(0),
- IsUndoEnabled = false,
- VerticalScrollBarVisibility = ScrollBarVisibility.Disabled,
- HorizontalScrollBarVisibility = ScrollBarVisibility.Disabled,
- FontSize = fontSize,
- };
- if (foreground != null) rtb.Foreground = foreground;
- return rtb;
- }
- private void AppendUserMessage(string text)
- {
- var rtb = MakeReadOnlyRtb(13);
- var p = (Paragraph)rtb.Document.Blocks.First();
- p.Inlines.Add(new Run("你:") { FontWeight = FontWeights.Bold });
- p.Inlines.Add(text);
- rtb.Margin = new Thickness(0, 10, 0, 2);
- ChatPanel.Children.Add(rtb);
- ScrollToEnd();
- }
- private RichTextBox AppendAiContentBlock(out Paragraph para)
- {
- var rtb = MakeReadOnlyRtb(13);
- var p = (Paragraph)rtb.Document.Blocks.First();
- p.Inlines.Add(new Run("AI:") { FontWeight = FontWeights.Bold });
- rtb.Margin = new Thickness(0, 2, 0, 8);
- ChatPanel.Children.Add(rtb);
- ScrollToEnd();
- para = p;
- return rtb;
- }
- private (Expander expander, Paragraph para) AppendThinkingBlock()
- {
- var rtb = MakeReadOnlyRtb(11, Brushes.Gray);
- var p = (Paragraph)rtb.Document.Blocks.First();
- var expander = new Expander
- {
- Header = "💭 思考中…(点击展开)",
- IsExpanded = false,
- Content = rtb,
- Margin = new Thickness(0, 6, 0, 2),
- };
- ChatPanel.Children.Add(expander);
- ScrollToEnd();
- return (expander, p);
- }
- private void AppendSystemLine(string text)
- {
- var rtb = MakeReadOnlyRtb(12, Brushes.DarkOrange);
- ((Paragraph)rtb.Document.Blocks.First()).Inlines.Add(text);
- rtb.Margin = new Thickness(0, 4, 0, 4);
- ChatPanel.Children.Add(rtb);
- ScrollToEnd();
- }
- private void AppendLineSpacer()
- {
- ChatPanel.Children.Add(new TextBlock { Text = " ", FontSize = 6 });
- }
- private void AppendToolCall(string name, string arguments)
- {
- var argsShort = arguments ?? "";
- if (argsShort.Length > 120) argsShort = argsShort.Substring(0, 120) + "…";
- var tb = new TextBlock
- {
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(0, 4, 0, 0),
- FontSize = 12,
- };
- tb.Inlines.Add(new Run("🔧 "));
- tb.Inlines.Add(new Run(name) { FontWeight = FontWeights.Bold, Foreground = Brushes.RoyalBlue });
- tb.Inlines.Add(new Run($"({argsShort})") { Foreground = Brushes.Gray, FontSize = 11 });
- ChatPanel.Children.Add(tb);
- ScrollToEnd();
- }
- private void AppendToolResult(string result)
- {
- var shortResult = result ?? "";
- if (shortResult.Length > 160) shortResult = shortResult.Substring(0, 160) + "…";
- var tb = new TextBlock
- {
- Text = $" ↳ {shortResult}",
- TextWrapping = TextWrapping.Wrap,
- Margin = new Thickness(18, 0, 0, 2),
- FontSize = 11,
- Foreground = Brushes.DarkSlateGray,
- };
- ChatPanel.Children.Add(tb);
- ScrollToEnd();
- }
- private async void BtnSend_Click(object sender, RoutedEventArgs e)
- {
- if (_running) { StopAi(); return; }
- await SendAsync();
- }
- // 用 PreviewKeyDown(隧道事件)在 TextBox 内部处理回车之前拦截:
- // 普通 KeyDown 会晚于 TextBoxBase 的类处理器,回车已被消费成换行。
- private async void TxtInput_PreviewKeyDown(object sender, KeyEventArgs e)
- {
- if (e.Key != Key.Enter) return;
- // Ctrl+Enter = 换行:WPF 的 TextBox 不响应 Ctrl+Enter(只认纯回车),需手动在光标处插入
- if ((Keyboard.Modifiers & ModifierKeys.Control) == ModifierKeys.Control)
- {
- var tb = (TextBox)sender;
- tb.SelectedText = "\n"; // 有选中内容则替换,否则在光标处插入
- tb.CaretIndex++; // 光标移到新行
- e.Handled = true;
- return;
- }
- // Enter = 发送(输入法组词中的回车以 ImeProcessed 到达,不会进到这里,不会误发)
- e.Handled = true;
- if (_running) { StopAi(); return; }
- await SendAsync();
- }
- private void BtnSavePrompt_Click(object sender, RoutedEventArgs e)
- {
- _cfg.AiChat.CustomPrompt = TxtSystemPrompt.Text;
- Config.Save(_cfg);
- AppendSystemLine("✅ 提示词已保存为自定义版本(点“↺ 恢复默认”可回到按语种动态生成)。");
- }
- private void BtnResetPrompt_Click(object sender, RoutedEventArgs e)
- {
- _cfg.AiChat.CustomPrompt = "";
- Config.Save(_cfg);
- TxtSystemPrompt.Text = BuildDefaultPrompt(_langs);
- AppendSystemLine("✅ 已恢复动态默认提示词(按当前语种生成)。");
- }
- /// <summary>扫描回复中的 ```json {"action":"add","rows":[...]} ``` 指令块并执行(旧机制兼容,主用工具调用)。</summary>
- private void TryApplyRowInstructions(string reply)
- {
- if (string.IsNullOrEmpty(reply) || _applyRows == null) return;
- var all = new List<AddRowPayload>();
- var matches = Regex.Matches(reply, "```(?:json)?\\s*(\\{.*?\\})\\s*```", RegexOptions.Singleline);
- foreach (Match m in matches)
- {
- try
- {
- var j = JObject.Parse(m.Groups[1].Value);
- if (!string.Equals((string)j["action"], "add", StringComparison.OrdinalIgnoreCase)) continue;
- if (!(j["rows"] is JArray rowsToken)) continue;
- foreach (var r in rowsToken)
- {
- var key = (string)r["key"];
- if (string.IsNullOrWhiteSpace(key)) continue;
- if (key.Contains('<') || key.Contains('>') || key.Contains('{') || key.Contains("译文"))
- continue;
- var trans = new Dictionary<string, string>();
- if (r["translations"] is JObject t)
- foreach (var pp in t.Properties())
- trans[pp.Name] = (string)pp.Value ?? "";
- all.Add(new AddRowPayload { Key = key.Trim(), Translations = trans });
- }
- }
- catch { }
- }
- if (all.Count == 0) return;
- try
- {
- _applyRows(all);
- AppendSystemLine($"✅ 已按 AI 指令添加 {all.Count} 行,语言包已保存并刷新表格。");
- }
- catch (Exception ex)
- {
- AppendSystemLine($"❌ 行指令执行失败:{ex.Message}");
- }
- }
- /// <summary>手动压缩上下文:移除早期对话轮次以释放 token 空间。</summary>
- private void BtnCompressContext_Click(object sender, RoutedEventArgs e)
- {
- if (_historyMessages.Count < 2)
- {
- MessageBox.Show("对话轮次不足,至少需要 2 轮对话才能进行有效压缩。",
- "提示", MessageBoxButton.OK, MessageBoxImage.Information);
- return;
- }
- int currentTokens = ContextEstimator.EstimateMessagesTokens(_historyMessages);
- int targetTokens = ContextCharBudget / 2 / 2; // 压缩到预算的 25%(约一半)
- if (currentTokens <= targetTokens)
- {
- AppendSystemLine($"ℹ️ 当前上下文大小 ({ContextEstimator.FormatTokens(currentTokens)} token) 已低于压缩目标 ({ContextEstimator.FormatTokens(targetTokens)} token),无需压缩。");
- return;
- }
- int messagesToKeep = Math.Max(2, _historyMessages.Count / 2);
- int toRemove = _historyMessages.Count - messagesToKeep;
- // 移除最早的 toRemove 条消息(保留最新消息)
- for (int i = 0; i < toRemove; i++)
- {
- _historyMessages.RemoveAt(0);
- }
- int newTokens = ContextEstimator.EstimateMessagesTokens(_historyMessages);
- UpdateContextUsage(_historyMessages);
- AppendSystemLine($"🗜️ 上下文压缩完成!已裁剪 {toRemove} 轮最早对话,保留 {messagesToKeep} 轮最新消息。使用 token 从 {ContextEstimator.FormatTokens(currentTokens)} 降至 {ContextEstimator.FormatTokens(newTokens)}。");
- }
- }
- }
|