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
{
///
/// AI 问答窗口:流式对话 + 工具调用(Function Calling)。
/// 此窗口交互性强(逐字渲染、折叠思考区),故保留在 code-behind。
///
public partial class AiChatWindow : Window
{
private const int MaxToolRounds = 5000;
/// 单条工具结果的最大字符数(超出截断,防单条撑爆上下文)。
private const int ToolResultCharLimit = 4000;
/// 上下文裁剪阈值:按最大上下文 token 的 60% 折算为字符预算(1 token ≈ 2 字符粗估),超出则裁剪最早轮次。
private int ContextCharBudget =>
Math.Max(20000, (int)(Config.ResolveMaxContextTokens(_cfg.Translation) * 0.6 * 2));
private readonly Config.ConfigRoot _cfg;
private readonly Action> _applyRows;
private readonly List _langs;
private readonly JArray _tools;
private readonly Func> _executeTool;
private readonly JArray _historyMessages = new JArray();
private CancellationTokenSource _cts;
private bool _running;
private StreamWriter _logWriter;
public AiChatWindow(Config.ConfigRoot cfg, List targetLangs,
JArray tools, Func> executeTool, Action> applyRows)
{
InitializeComponent();
Icon = App.AppIcon;
_cfg = cfg ?? new Config.ConfigRoot();
_langs = targetLangs ?? new List();
_tools = tools;
_executeTool = executeTool;
_applyRows = applyRows;
InitLog();
CmbPermission.ItemsSource = new List { "每次询问(修改需确认)", "完全信任(自动执行)" };
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 ReadOnlyTools = new HashSet(StringComparer.OrdinalIgnoreCase)
{
"list_languages", "get_stats", "search_entries", "get_entry",
"get_validation_issues", "get_missing_translations",
"read_file", "list_directory",
"web_search", "fetch_url",
};
/// 自带确认框的删除类工具:执行器内部会弹详细删除确认,权限层不再重复询问(避免一次删除弹两个框)。
private static readonly HashSet SelfConfirmTools = new HashSet(StringComparer.OrdinalIgnoreCase)
{
"delete_entry", "delete_entry_by_index", "delete_entries",
};
/// 本轮工具调用已整批放行(ask 模式下多操作批量确认一次)。
private bool _roundApproved;
/// 只读工具直接放行;修改类工具在 ask 模式下需用户确认(删除类自带确认框、或本轮已整批放行的除外)。
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;
}
/// 权限模式切换:立即持久化。
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);
}
/// 按当前语种动态生成默认系统提示词。
internal static string BuildDefaultPrompt(List langs)
{
var list = (langs ?? new List()).Where(l => !l.IsSource).ToList();
if (list.Count == 0)
{
list = new List
{
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 _quickPrompts = new List
{
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;
}
}
/// 运行中点击发送按钮 = 停止本次运行。
private void StopAi()
{
_cts?.Cancel();
AppendSystemLine("⏹ 正在停止…");
Log("STOP_REQUEST", "用户点击了停止");
}
// ---------- 上下文窗口管理 ----------
/// 统计一条消息的字符数(content + 工具调用参数)。
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;
}
///
/// 消息超预算时按"轮次"裁剪最早的对话:
/// 一轮 = user + assistant(+tool_calls) + 其后的若干 tool 结果,整体丢弃,
/// 保证 tool_calls 与 tool 结果配对完整,不会拆散导致协议错误。
/// 返回是否发生了裁剪。
///
private bool TrimContext(JArray messages)
{
int total = messages.Sum(MsgChars);
if (total <= ContextCharBudget) return false;
// 1) 把 messages[1..] 按轮次分组(每轮从 user 开始,到下一个 user 前结束)
var rounds = new List>();
var cur = new List();
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();
}
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 round) => round.Sum(MsgChars);
/// 刷新上下文用量指示器:估算消息 token 占用,按配置的最大上下文显示占比。
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
{
// 指示器失败不影响对话
}
}
/// 工具结果超长时截断(保留头部,提示省略)。
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("✅ 已恢复动态默认提示词(按当前语种生成)。");
}
/// 扫描回复中的 ```json {"action":"add","rows":[...]} ``` 指令块并执行(旧机制兼容,主用工具调用)。
private void TryApplyRowInstructions(string reply)
{
if (string.IsNullOrEmpty(reply) || _applyRows == null) return;
var all = new List();
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();
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}");
}
}
/// 手动压缩上下文:移除早期对话轮次以释放 token 空间。
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)}。");
}
}
}