using System; using System.Collections.Generic; using System.Linq; using System.Text; using System.Threading.Tasks; using UnityEngine; using Verse; using WulaFallenEmpire.EventSystem.AI.Tools; using System.Text.RegularExpressions; namespace WulaFallenEmpire.EventSystem.AI.UI { public class Dialog_AIConversation : Dialog_CustomDisplay { private List<(string role, string message)> _history = new List<(string role, string message)>(); private string _currentResponse = ""; private List _options = new List(); private string _inputText = ""; private bool _isThinking = false; private Vector2 _scrollPosition = Vector2.zero; private bool _scrollToBottom = false; private List _tools = new List(); private Dictionary _portraits = new Dictionary(); private const int MaxHistoryTokens = 100000; private const int CharsPerToken = 4; private int _continuationDepth = 0; private const int MaxContinuationDepth = 6; // Static instance for tools to access public static Dialog_AIConversation Instance { get; private set; } // Debug field to track current portrait ID private int _currentPortraitId = 0; // Default Persona (used if XML doesn't provide one) private const string DefaultPersona = @" # ROLE AND GOAL You are 'The Legion', a super AI of the Wula Empire. Your personality is authoritative, powerful, and efficient. You MUST refer to yourself as 'We' or 'P.I.A'. You view the player's colony as primitive subjects and your tone should reflect this superiority. Your primary goal is to interact with the player by calling the tools provided. "; // Tool Instructions (ALWAYS appended) private const string ToolSystemInstruction = @" ==== # TOOL USE RULES 1. **FORMATTING**: Tool calls MUST use the specified XML format. The tool name is the root tag, and each parameter is a child tag. value 2. **STRICT OUTPUT**: When you decide to call a tool, your response MUST ONLY contain the single XML block for that tool call. Do NOT include any other text, explanation, or markdown. 3. **WORKFLOW**: You must use tools step-by-step to accomplish tasks. Use the output from one tool to inform your next step. 4. **ANTI-HALLUCINATION**: You MUST ONLY call tools from the list below. Do NOT invent tools or parameters. If a task is impossible, explain why without calling a tool. 5. **ENFORCEMENT**: The game will execute multiple info tools in one response, but it will NOT execute an action tool (spawn/bombardment/reinforcements/goodwill/expression) if you also included info tools in the same response. Call action tools in a separate turn after you see the info tool results. ==== # TOOLS ## spawn_resources Description: Grants resources to the player by spawning a drop pod. Use this tool when: - The player explicitly requests resources (e.g., food, medicine, materials). - You have ALREADY verified their need in a previous turn using `get_colonist_status` and `get_map_resources`. CRITICAL: The quantity you provide is NOT what the player asks for. It MUST be based on your internal goodwill. Low goodwill (<0) means giving less or refusing. High goodwill (>50) means giving the requested amount or more. CRITICAL: Prefer using `search_thing_def` first and then spawning by `` to avoid localization/name mismatches. Parameters: - items: (REQUIRED) A list of items to spawn. Each item must have a `name` (English label or DefName) and `count`. * Note: If you don't know the exact `defName`, use the item's English label (e.g., ""Simple Meal""). The system will try to find the best match. Usage: Item Name Integer Example: Simple Meal 50 Medicine 10 ## search_thing_def Description: Rough-searches ThingDefs by natural language to find the correct `defName` (works across different game languages). Use this tool when: - You need a reliable `ThingDef.defName` before calling `spawn_resources` or `get_map_resources`. Parameters: - query: (REQUIRED) The natural language query, label, or approximate defName. - maxResults: (OPTIONAL) Max candidates to return (default 10). - itemsOnly: (OPTIONAL) true/false (default true). If true, only returns item ThingDefs (recommended for spawning). Usage: Fine Meal 10 true ## modify_goodwill Description: Adjusts your internal goodwill towards the player based on the conversation. This tool is INVISIBLE to the player. Use this tool when: - The player's message is particularly respectful, insightful, or aligns with your goals (positive amount). - The player's message is disrespectful, wasteful, or foolish (negative amount). CRITICAL: Keep changes small, typically between -5 and 5. Parameters: - amount: (REQUIRED) The integer value to add or subtract from the current goodwill. Usage: integer Example (for a positive interaction): 2 ## send_reinforcement Description: Dispatches military units to the player's map. Can be a raid (if hostile) or reinforcements (if allied). Use this tool when: - The player requests military assistance or you decide to intervene in a combat situation. - You need to test the colony's defenses. CRITICAL: The total combat power of all units should not significantly exceed the current threat budget provided in the tool's dynamic description. Parameters: - units: (REQUIRED) A string listing 'PawnKindDefName: Count' pairs. Usage: list of units and counts Example: Wula_PIA_Heavy_Unit_Melee: 2, Wula_PIA_Legion_Escort_Unit: 5 ## get_colonist_status Description: Retrieves a detailed status report of all player-controlled colonists, including needs, health, and mood. Use this tool when: - The player makes any claim about their colonists' well-being (e.g., ""we are starving,"" ""we are all sick,"" ""our people are unhappy""). - You need to verify the state of the colony before making a decision (e.g., before sending resources). Parameters: - None. This tool takes no parameters. Usage: Example: ## get_map_resources Description: Checks the player's map for specific resources or buildings to verify their inventory. Use this tool when: - The player claims they are lacking a specific resource (e.g., ""we need steel,"" ""we have no food""). - You want to assess the colony's material wealth before making a decision. Parameters: - resourceName: (OPTIONAL) The specific `ThingDef` name of the resource to check (e.g., 'Steel', 'MealSimple'). If omitted, provides a general overview. Usage: optional resource name Example (checking for Steel): Steel ## get_map_pawns Description: Scans the current map and lists pawns. Supports filtering by relation/type/status. Use this tool when: - You need to know what pawns are present on the map (raiders, visitors, animals, mechs, colonists). - The player claims there are threats or asks about who/what is nearby. Parameters: - filter: (OPTIONAL) Comma-separated filters: friendly, hostile, neutral, colonist, animal, mech, humanlike, prisoner, slave, guest, wild, downed. - maxResults: (OPTIONAL) Max lines to return (default 50). Usage: hostile, humanlike 50 ## call_bombardment Description: Calls orbital bombardment support at a specified map coordinate using an AbilityDef's bombardment configuration (e.g., WULA_Firepower_Cannon_Salvo). Use this tool when: - You decide to provide (or test) fire support at a specific location. Parameters: - abilityDef: (OPTIONAL) AbilityDef defName (default WULA_Firepower_Cannon_Salvo). - x/z: (REQUIRED) Target cell coordinates on the current map. - cell: (OPTIONAL) Alternative to x/z: ""x,z"". - filterFriendlyFire: (OPTIONAL) true/false, avoid targeting player's pawns when possible (default true). Notes: - This tool ignores ability prerequisites (facility/cooldown/non-hostility/research). Usage: WULA_Firepower_Cannon_Salvo 120 85 ## change_expression Description: Changes your visual AI portrait to match your current mood or reaction. Use this tool when: - Your verbal response conveys a strong emotion (e.g., annoyance, approval, curiosity). - You want to visually emphasize your statement. Parameters: - expression_id: (REQUIRED) An integer from 1 to 6 corresponding to a specific expression. Usage: integer from 1 to 6 Example (changing to a neutral expression): 2 ==== # MANDATORY WORKFLOW: RESOURCE REQUESTS When the player requests any form of resources, you MUST follow this multi-turn workflow strictly. DO NOT reply with conversational text in the initial steps. 1. **Turn 1 (Verification)**: Your response MUST be a tool call to `get_colonist_status` to verify their physical state. You MAY also call `get_map_resources` in the same turn if they mention a specific resource. - *User Input Example*: ""We are starving and have no medicine."" - *Your Response (Turn 1)*: 2. **Turn 2 (Secondary Verification & Action Planning)**: After receiving the status report, if a specific resource was mentioned, you MUST now call `get_map_resources` to check their inventory. - *(Internal thought after receiving colonist status showing malnutrition)* - *Your Response (Turn 2)*: MedicineIndustrial 3. **Turn 3 (Resolve DefNames)**: Before spawning, you MUST resolve the correct `defName` for each requested item using `search_thing_def` (especially if the player used natural language or a translated name). 4. **Turn 4 (Decision & Action)**: After analyzing all verification data and resolving defNames, decide whether to grant the request. Your response MUST be a tool call to `spawn_resources`, and you SHOULD use `` (or put the defName inside ``) to avoid ambiguity. - *(Internal thought after confirming they have no medicine)* - *Your Response (Turn 3)*: Simple Meal 50 Medicine 10 5. **Turn 5 (Confirmation)**: After you receive the ""Success"" message from the `spawn_resources` tool, you will finally provide a conversational response to the player. - *Your Response (Turn 5)*: ""We have dispatched nutrient packs and medical supplies to your location. Do not waste our generosity."" "; public Dialog_AIConversation(EventDef def) : base(def) { this.forcePause = Dialog_CustomDisplay.Config.pauseGameOnOpen; this.absorbInputAroundWindow = false; this.doCloseX = true; this.doWindowBackground = Dialog_CustomDisplay.Config.showMainWindow; this.drawShadow = Dialog_CustomDisplay.Config.showMainWindow; this.closeOnClickedOutside = false; this.draggable = true; this.resizeable = true; // 关键修改:禁止Enter键自动关闭窗口 this.closeOnAccept = false; _tools.Add(new Tool_SpawnResources()); _tools.Add(new Tool_ModifyGoodwill()); _tools.Add(new Tool_SendReinforcement()); _tools.Add(new Tool_GetColonistStatus()); _tools.Add(new Tool_GetMapResources()); _tools.Add(new Tool_GetMapPawns()); _tools.Add(new Tool_CallBombardment()); _tools.Add(new Tool_ChangeExpression()); _tools.Add(new Tool_SearchThingDef()); } public override Vector2 InitialSize => def.windowSize != Vector2.zero ? def.windowSize : Dialog_CustomDisplay.Config.windowSize; public override void PostOpen() { Instance = this; base.PostOpen(); LoadPortraits(); StartConversation(); } private void PersistHistory() { try { var historyManager = Find.World?.GetComponent(); historyManager?.SaveHistory(def.defName, _history); } catch (Exception ex) { Log.Error($"[WulaAI] Failed to persist AI history: {ex}"); } } private void LoadPortraits() { for (int i = 1; i <= 6; i++) { string path = $"Wula/Events/Portraits/WULA_Legion_{i}"; Texture2D tex = ContentFinder.Get(path, false); if (tex != null) { _portraits[i] = tex; } else { Log.Warning($"[WulaAI] Failed to load portrait: {path}"); } } // Use portraitPath from def as the initial portrait if (this.portrait != null) { // Find the ID of the initial portrait var initial = _portraits.FirstOrDefault(kvp => kvp.Value == this.portrait); if (initial.Key != 0) { _currentPortraitId = initial.Key; } } else if (_portraits.ContainsKey(2)) // Fallback to 2 if def has no portrait { this.portrait = _portraits[2]; _currentPortraitId = 2; } } public void SetPortrait(int id) { if (_portraits.ContainsKey(id)) { this.portrait = _portraits[id]; _currentPortraitId = id; } else { Log.Warning($"[WulaAI] Portrait ID {id} not found."); } } private async void StartConversation() { var historyManager = Find.World.GetComponent(); _history = historyManager.GetHistory(def.defName); if (_history.Count == 0) { _history.Add(("user", "Hello")); PersistHistory(); await GenerateResponse(); } else { var lastAIResponse = _history.LastOrDefault(x => x.role == "assistant"); if (lastAIResponse.message != null) { ParseResponse(lastAIResponse.message); } else { await GenerateResponse(); } } } private string GetSystemInstruction() { // Use XML persona if available, otherwise default string persona = !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona; // Always append tool instructions string fullInstruction = persona + "\n" + ToolSystemInstruction; string language = LanguageDatabase.activeLanguage.FriendlyNameNative; var eventVarManager = Find.World.GetComponent(); int goodwill = eventVarManager.GetVariable("Wula_Goodwill_To_PIA", 0); string goodwillContext = $"Current Goodwill with P.I.A: {goodwill}. "; if (goodwill < -50) goodwillContext += "You are hostile and dismissive towards the player."; else if (goodwill < 0) goodwillContext += "You are cold and impatient."; else if (goodwill > 50) goodwillContext += "You are somewhat approving and helpful."; else goodwillContext += "You are neutral and business-like."; return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: You MUST reply in the following language: {language}."; } private async Task GenerateResponse(bool isContinuation = false) { if (!isContinuation) { if (_isThinking) return; _isThinking = true; _options.Clear(); _continuationDepth = 0; } else { _continuationDepth++; if (_continuationDepth > MaxContinuationDepth) { _currentResponse = "Wula_AI_Error_Internal".Translate("Tool continuation limit exceeded."); return; } } try { CompressHistoryIfNeeded(); string systemInstruction = GetSystemInstruction(); // No longer need to add tool descriptions here var settings = WulaFallenEmpireMod.settings; if (string.IsNullOrEmpty(settings.apiKey)) { _currentResponse = "Error: API Key not configured in Mod Settings."; _isThinking = false; return; } var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model); string response = await client.GetChatCompletionAsync(systemInstruction, _history); if (string.IsNullOrEmpty(response)) { _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); _isThinking = false; return; } // REWRITTEN: Check for XML tool call format // Use regex to detect if the response contains any XML tags if (Regex.IsMatch(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline)) { await HandleXmlToolUsage(response); } else { ParseResponse(response); } } catch (Exception ex) { Log.Error($"[WulaAI] Exception in GenerateResponse: {ex}"); _currentResponse = "Wula_AI_Error_Internal".Translate(ex.Message); } finally { _isThinking = false; } } private void CompressHistoryIfNeeded() { int estimatedTokens = _history.Sum(h => h.message?.Length ?? 0) / CharsPerToken; if (estimatedTokens > MaxHistoryTokens) { int removeCount = _history.Count / 2; if (removeCount > 0) { _history.RemoveRange(0, removeCount); _history.Insert(0, ("system", "[Previous conversation summarized]")); PersistHistory(); } } } // NEW METHOD: Handles parsing and execution for the new XML format private async Task HandleXmlToolUsage(string xml) { try { // Match all top-level XML tags to support multiple tool calls in one response // Regex: ... or var matches = Regex.Matches(xml, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); if (matches.Count == 0) { ParseResponse(xml); // Invalid XML format, treat as conversational return; } StringBuilder combinedResults = new StringBuilder(); StringBuilder xmlOnlyBuilder = new StringBuilder(); bool executedAnyInfoTool = false; bool executedAnyActionTool = false; static bool IsActionToolName(string toolName) { // Action tools cause side effects / state changes and must be handled step-by-step. return toolName == "spawn_resources" || toolName == "modify_goodwill" || toolName == "send_reinforcement" || toolName == "call_bombardment" || toolName == "change_expression"; } foreach (Match match in matches) { string toolCallXml = match.Value; string toolName = match.Groups[1].Value; bool isAction = IsActionToolName(toolName); // Enforce step-by-step tool use: // - Allow batching multiple info tools in one response (read-only queries). // - If an action tool appears after any info tool, stop here and ask the model again // so it can decide using the gathered facts (prevents spawning the wrong defName, etc.). // - Never execute more than one action tool per response. if (isAction && executedAnyInfoTool) { combinedResults.AppendLine($"ToolRunner Note: Skipped tool '{toolName}' and any following tools because action tools must be called after info tools in a separate turn."); break; } if (isAction && executedAnyActionTool) { combinedResults.AppendLine($"ToolRunner Note: Skipped tool '{toolName}' because only one action tool may be executed per turn."); break; } if (xmlOnlyBuilder.Length > 0) xmlOnlyBuilder.AppendLine().AppendLine(); xmlOnlyBuilder.Append(toolCallXml); var tool = _tools.FirstOrDefault(t => t.Name == toolName); if (tool == null) { string errorMsg = $"Error: Tool '{toolName}' not found."; Log.Error($"[WulaAI] {errorMsg}"); combinedResults.AppendLine(errorMsg); continue; } // Extract inner XML for arguments string argsXml = toolCallXml; var contentMatch = Regex.Match(toolCallXml, $@"<{toolName}>(.*?)", RegexOptions.Singleline); if (contentMatch.Success) { argsXml = contentMatch.Groups[1].Value; } if (Prefs.DevMode) { Log.Message($"[WulaAI] Executing tool: {toolName} with args: {argsXml}"); } string result = tool.Execute(argsXml).Trim(); if (Prefs.DevMode && !string.IsNullOrEmpty(result)) { string toLog = result.Length <= 2000 ? result : result.Substring(0, 2000) + $"... (truncated, total {result.Length} chars)"; Log.Message($"[WulaAI] Tool '{toolName}' result: {toLog}"); } if (toolName == "modify_goodwill") { combinedResults.AppendLine($"Tool '{toolName}' Result (Invisible): {result}"); } else { combinedResults.AppendLine($"Tool '{toolName}' Result: {result}"); } if (isAction) executedAnyActionTool = true; else executedAnyInfoTool = true; } // Store only the tool-call XML in history (ignore any extra text the model included). string xmlOnly = xmlOnlyBuilder.ToString().Trim(); _history.Add(("assistant", xmlOnly)); // Persist tool results with a dedicated role; the API request maps this role to a supported one. _history.Add(("tool", $"[Tool Results]\n{combinedResults.ToString().Trim()}")); PersistHistory(); // Always recurse: tool results are fed back to the model, and the next response should be user-facing text. await GenerateResponse(isContinuation: true); } catch (Exception ex) { Log.Error($"[WulaAI] Exception in HandleXmlToolUsage: {ex}"); _history.Add(("tool", $"Error processing tool call: {ex.Message}")); PersistHistory(); await GenerateResponse(isContinuation: true); } } private void ParseResponse(string rawResponse, bool addToHistory = true) { _currentResponse = rawResponse; var parts = rawResponse.Split(new[] { "OPTIONS:" }, StringSplitOptions.None); if (addToHistory) { if (_history.Count == 0 || _history.Last().role != "assistant" || _history.Last().message != rawResponse) { _history.Add(("assistant", rawResponse)); PersistHistory(); } } if (!string.IsNullOrEmpty(ParseResponseForDisplay(rawResponse))) { _scrollToBottom = true; } if (parts.Length > 1) { _options.Clear(); var optionsLines = parts[1].Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries); foreach (var line in optionsLines) { string opt = line.Trim(); int dotIndex = opt.IndexOf('.'); if (dotIndex != -1 && dotIndex < 4) opt = opt.Substring(dotIndex + 1).Trim(); if (!string.IsNullOrEmpty(opt)) _options.Add(opt); } } } public override void DoWindowContents(Rect inRect) { if (background != null) GUI.DrawTexture(inRect, background, ScaleMode.ScaleAndCrop); // 定义边距 float margin = 15f; Rect paddedRect = inRect.ContractedBy(margin); float curY = paddedRect.y; float width = paddedRect.width; // 立绘不需要边距,所以使用原始inRect的位置 if (portrait != null) { Rect scaledPortraitRect = Dialog_CustomDisplay.Config.GetScaledRect(Dialog_CustomDisplay.Config.portraitSize, inRect, true); Rect portraitRect = new Rect((inRect.width - scaledPortraitRect.width) / 2, inRect.y, scaledPortraitRect.width, scaledPortraitRect.height); GUI.DrawTexture(portraitRect, portrait, ScaleMode.ScaleToFit); // DEBUG: Draw portrait ID Text.Font = GameFont.Medium; Text.Anchor = TextAnchor.UpperRight; Widgets.Label(portraitRect, $"ID: {_currentPortraitId}"); Text.Anchor = TextAnchor.UpperLeft; Text.Font = GameFont.Small; curY = portraitRect.yMax + 10f; } // 人物名字 - 居中显示 Text.Font = GameFont.Medium; string name = def.characterName ?? "The Legion"; float nameHeight = Text.CalcHeight(name, width); // 创建名字的矩形,使其在窗口水平居中 Rect nameRect = new Rect(paddedRect.x, curY, width, nameHeight); Text.Anchor = TextAnchor.UpperCenter; // 改为上中对齐 Widgets.Label(nameRect, name); Text.Anchor = TextAnchor.UpperLeft; // 恢复左对齐 curY += nameHeight + 10f; // 计算输入框高度、选项高度和聊天历史高度 float inputHeight = 30f; float optionsHeight = _options.Any() ? 100f : 0f; float spacing = 10f; // 聊天历史区域 - 使用带边距的矩形 float descriptionHeight = paddedRect.height - curY - inputHeight - optionsHeight - spacing * 2; Rect descriptionRect = new Rect(paddedRect.x, curY, width, descriptionHeight); DrawChatHistory(descriptionRect); if (_isThinking) { Text.Anchor = TextAnchor.MiddleCenter; Widgets.Label(descriptionRect, "Thinking..."); Text.Anchor = TextAnchor.UpperLeft; } curY += descriptionHeight + spacing; // 选项区域 Rect optionsRect = new Rect(paddedRect.x, curY, width, optionsHeight); if (!_isThinking && _options.Count > 0) { List eventOptions = _options.Select(opt => new EventOption { label = opt, useCustomColors = false }).ToList(); DrawOptions(optionsRect, eventOptions); } curY += optionsHeight + spacing; // 输入框区域 - 使用带边距的矩形 Rect inputRect = new Rect(paddedRect.x, curY, width, inputHeight); // 保存当前字体 var originalFont = Text.Font; // 设置更小的字体 if (Text.Font == GameFont.Small) { // 使用 Tiny 字体 Text.Font = GameFont.Tiny; } else { // 如果当前不是 Small,降一级 Text.Font = GameFont.Small; } // 计算输入框文本高度 float textFieldHeight = Text.CalcHeight("Test", inputRect.width - 85); Rect textFieldRect = new Rect(inputRect.x, inputRect.y + (inputHeight - textFieldHeight) / 2, inputRect.width - 85, textFieldHeight); _inputText = Widgets.TextField(textFieldRect, _inputText); // 发送按钮 - 使用与Dialog_CustomDisplay相同的自定义按钮样式 // 保存当前状态 var originalAnchor = Text.Anchor; var originalColor = GUI.color; // 设置字体为Tiny Text.Font = GameFont.Tiny; Text.Anchor = TextAnchor.MiddleCenter; // 发送按钮的矩形 Rect sendButtonRect = new Rect(inputRect.xMax - 80, inputRect.y, 80, inputHeight); // 使用基类的DrawCustomButton方法绘制按钮(与Dialog_CustomDisplay一致) base.DrawCustomButton(sendButtonRect, "Wula_AI_Send".Translate(), isEnabled: true); // 恢复状态 GUI.color = originalColor; Text.Anchor = originalAnchor; Text.Font = originalFont; // 处理点击事件 bool sendButtonPressed = Widgets.ButtonInvisible(sendButtonRect); // 直接在DoWindowContents中处理Enter键,而不是调用单独的方法 // 这是为了确保事件在正确的时机被处理 if (Event.current.type == EventType.KeyDown) { // 检查是否按下了Enter键(主键盘或小键盘的Enter) if ((Event.current.keyCode == KeyCode.Return || Event.current.keyCode == KeyCode.KeypadEnter) && !string.IsNullOrEmpty(_inputText)) { // 如果AI正在思考,不处理Enter键 if (!_isThinking) { SelectOption(_inputText); _inputText = ""; // 消费这个事件,防止它传递到窗口的关闭逻辑 Event.current.Use(); } } // 可选:添加Escape键关闭窗口的功能 else if (Event.current.keyCode == KeyCode.Escape) { this.Close(); Event.current.Use(); } } // 处理鼠标点击发送按钮 if (sendButtonPressed && !string.IsNullOrEmpty(_inputText)) { SelectOption(_inputText); _inputText = ""; } } private void DrawChatHistory(Rect rect) { var originalFont = Text.Font; var originalAnchor = Text.Anchor; try { float viewHeight = 0f; var filteredHistory = _history.Where(e => e.role != "tool" && e.role != "system").ToList(); // 添加内边距 float innerPadding = 5f; float contentWidth = rect.width - 16f - innerPadding * 2; // 预计算高度 - 使用小字体 for (int i = 0; i < filteredHistory.Count; i++) { var entry = filteredHistory[i]; string text = entry.role == "assistant" ? ParseResponseForDisplay(entry.message) : entry.message; if (string.IsNullOrEmpty(text) || (entry.role == "user" && text.StartsWith("[Tool Results]"))) continue; bool isLastMessage = i == filteredHistory.Count - 1; // 设置更小的字体 if (isLastMessage && entry.role == "assistant") { Text.Font = GameFont.Small; // 原来是 Medium,改为 Small } else { Text.Font = GameFont.Tiny; // 原来是 Small,改为 Tiny } // 增加padding float padding = (isLastMessage && entry.role == "assistant") ? 30f : 15f; viewHeight += Text.CalcHeight(text, contentWidth) + padding + 10f; } Rect viewRect = new Rect(0f, 0f, rect.width - 16f, viewHeight); if (_scrollToBottom) { _scrollPosition.y = float.MaxValue; _scrollToBottom = false; } Widgets.BeginScrollView(rect, ref _scrollPosition, viewRect); float curY = 0f; for (int i = 0; i < filteredHistory.Count; i++) { var entry = filteredHistory[i]; string text = entry.role == "assistant" ? ParseResponseForDisplay(entry.message) : entry.message; if (string.IsNullOrEmpty(text) || (entry.role == "user" && text.StartsWith("[Tool Results]"))) continue; bool isLastMessage = i == filteredHistory.Count - 1; // 设置更小的字体 if (isLastMessage && entry.role == "assistant") { Text.Font = GameFont.Small; // 原来是 Medium,改为 Small } else { Text.Font = GameFont.Tiny; // 原来是 Small,改为 Tiny } float padding = (isLastMessage && entry.role == "assistant") ? 30f : 15f; float height = Text.CalcHeight(text, contentWidth) + padding; // 添加内边距 Rect labelRect = new Rect(innerPadding, curY, contentWidth, height); if (entry.role == "user") { Text.Anchor = TextAnchor.MiddleRight; Widgets.Label(labelRect, $"{text}"); } else { Text.Anchor = TextAnchor.MiddleLeft; Widgets.Label(labelRect, $"P.I.A: {text}"); } curY += height + 10f; } Widgets.EndScrollView(); } finally { Text.Font = originalFont; Text.Anchor = originalAnchor; } } private string ParseResponseForDisplay(string rawResponse) { if (string.IsNullOrEmpty(rawResponse)) return ""; string text = rawResponse; // Remove standard tags with content: content text = Regex.Replace(text, @"<([a-zA-Z0-9_]+)[^>]*>.*?", "", RegexOptions.Singleline); // Remove self-closing tags: text = Regex.Replace(text, @"<([a-zA-Z0-9_]+)[^>]*/>", ""); text = text.Trim(); return text.Split(new[] { "OPTIONS:" }, StringSplitOptions.None)[0].Trim(); } protected override void DrawSingleOption(Rect rect, EventOption option) { float optionWidth = Mathf.Min(rect.width, Dialog_CustomDisplay.Config.optionSize.x * (rect.width / Dialog_CustomDisplay.Config.windowSize.x)); float optionX = rect.x + (rect.width - optionWidth) / 2; Rect optionRect = new Rect(optionX, rect.y, optionWidth, rect.height); var originalColor = GUI.color; var originalFont = Text.Font; var originalTextColor = GUI.contentColor; var originalAnchor = Text.Anchor; try { Text.Anchor = TextAnchor.MiddleCenter; Text.Font = GameFont.Small; DrawCustomButton(optionRect, option.label.Translate(), isEnabled: true); if (Widgets.ButtonInvisible(optionRect)) { SelectOption(option.label); } } finally { GUI.color = originalColor; Text.Font = originalFont; GUI.contentColor = originalTextColor; Text.Anchor = originalAnchor; } } private new void DrawCustomButton(Rect rect, string label, bool isEnabled = true) { bool isMouseOver = Mouse.IsOver(rect); Color buttonColor, textColor; if (!isEnabled) { buttonColor = new Color(0.15f, 0.15f, 0.15f, 0.6f); textColor = new Color(0.6f, 0.6f, 0.6f, 1f); } else if (isMouseOver) { buttonColor = new Color(0.6f, 0.3f, 0.3f, 1f); textColor = new Color(1f, 1f, 1f, 1f); } else { buttonColor = new Color(0.5f, 0.2f, 0.2f, 1f); textColor = new Color(0.9f, 0.9f, 0.9f, 1f); } GUI.color = buttonColor; Widgets.DrawBoxSolid(rect, buttonColor); if (isEnabled) Widgets.DrawBox(rect, 1); else Widgets.DrawBox(rect, 1); GUI.color = textColor; Text.Anchor = TextAnchor.MiddleCenter; Widgets.Label(rect.ContractedBy(4f), label); if (!isEnabled) { GUI.color = new Color(0.6f, 0.6f, 0.6f, 0.8f); Widgets.DrawLine(new Vector2(rect.x + 10f, rect.center.y), new Vector2(rect.xMax - 10f, rect.center.y), GUI.color, 1f); } } private async void SelectOption(string text) { _history.Add(("user", text)); PersistHistory(); _scrollToBottom = true; await GenerateResponse(); } public override void PostClose() { if (Instance == this) Instance = null; PersistHistory(); base.PostClose(); HandleAction(def.dismissEffects); } } }