diff --git a/.gitignore b/.gitignore index 83b7d47d..5eebc371 100644 --- a/.gitignore +++ b/.gitignore @@ -47,3 +47,4 @@ gemini-websocket-proxy/ Tools/dark-server/dark-server.js Tools/rimworld_cpt_data.jsonl Tools/mem0-1.0.0/ +Tools/thenextagent-1 diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.dll b/1.6/1.6/Assemblies/WulaFallenEmpire.dll index 1a46de05..c7a2d413 100644 Binary files a/1.6/1.6/Assemblies/WulaFallenEmpire.dll and b/1.6/1.6/Assemblies/WulaFallenEmpire.dll differ diff --git a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs new file mode 100644 index 00000000..07afb3e4 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs @@ -0,0 +1,1235 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using RimWorld; +using RimWorld.Planet; +using UnityEngine; +using Verse; +using WulaFallenEmpire; +using WulaFallenEmpire.EventSystem.AI.Tools; + +namespace WulaFallenEmpire.EventSystem.AI +{ + public class AIIntelligenceCore : WorldComponent + { + public static AIIntelligenceCore Instance { get; private set; } + + public event Action OnMessageReceived; + public event Action OnThinkingStateChanged; + public event Action OnExpressionChanged; + + private List<(string role, string message)> _history = new List<(string role, string message)>(); + private readonly List _tools = new List(); + private string _activeEventDefName; + private bool _isThinking; + private int _expressionId = 2; + + private float _thinkingStartTime; + private int _thinkingPhaseIndex = 1; + private bool _thinkingPhaseRetry; + + private bool _lastActionExecuted; + private bool _lastActionHadError; + private string _lastActionLedgerNote = "Action Ledger: None (no in-game actions executed)."; + private bool _lastSuccessfulToolCall; + private string _queryToolLedgerNote = "Tool Ledger (Query): None (no successful tool calls)."; + private string _actionToolLedgerNote = "Tool Ledger (Action): None (no successful tool calls)."; + private bool _querySuccessfulToolCall; + private bool _actionSuccessfulToolCall; + private bool _queryRetryUsed; + private bool _actionRetryUsed; + private readonly List _actionSuccessLedger = new List(); + private readonly HashSet _actionSuccessLedgerSet = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly List _actionFailedLedger = new List(); + private readonly HashSet _actionFailedLedgerSet = new HashSet(StringComparer.OrdinalIgnoreCase); + + private const int DefaultMaxHistoryTokens = 100000; + private const int CharsPerToken = 4; + private const int ThinkingPhaseTotal = 3; + + private static readonly Regex ExpressionTagRegex = new Regex(@"\[EXPR\s*:\s*([1-6])\s*\]", RegexOptions.IgnoreCase); + + private enum RequestPhase + { + QueryTools = 1, + ActionTools = 2, + Reply = 3 + } + + private struct PhaseExecutionResult + { + public bool AnyToolSuccess; + public bool AnyActionSuccess; + public bool AnyActionError; + } + + 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. +"; + + private const string ToolRulesInstruction = @" +==== + +# 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**: + - Your output MUST be either: + - One or more XML tool calls (no extra text), OR + - Exactly: + Do NOT include any natural language, explanation, markdown, or additional commentary. +3. **MULTI-REQUEST RULE**: + - If the user requests multiple items or information, you MUST output ALL required tool calls in the SAME tool-phase response. + - Do NOT split multi-item requests across turns. +4. **TOOLS**: You MAY call any tools listed in ""# TOOLS (AVAILABLE)"". +5. **ANTI-HALLUCINATION**: Never invent tools, parameters, defNames, coordinates, or tool results. If a tool is needed but not available, use and proceed to the next phase. +"; + + public AIIntelligenceCore(World world) : base(world) + { + Instance = this; + InitializeTools(); + } + + public override void ExposeData() + { + base.ExposeData(); + Scribe_Values.Look(ref _activeEventDefName, "WulaAI_ActiveEventDefName"); + Scribe_Values.Look(ref _expressionId, "WulaAI_ExpressionId", 2); + + if (Scribe.mode == LoadSaveMode.PostLoadInit) + { + Instance = this; + if (_expressionId < 1 || _expressionId > 6) + { + _expressionId = 2; + } + } + } + + public int ExpressionId => _expressionId; + public bool IsThinking => _isThinking; + public void InitializeConversation(string eventDefName) + { + if (string.IsNullOrWhiteSpace(eventDefName)) + { + return; + } + + _activeEventDefName = eventDefName; + LoadHistoryForActiveEvent(); + + if (_history.Count == 0) + { + _history.Add(("user", "Hello")); + PersistHistory(); + StartConversation(); + return; + } + + if (!TryApplyLastAssistantExpression()) + { + StartConversation(); + } + } + + public List<(string role, string message)> GetHistorySnapshot() + { + return _history?.ToList() ?? new List<(string role, string message)>(); + } + + public void SetExpression(int id) + { + int clamped = Math.Max(1, Math.Min(6, id)); + if (_expressionId == clamped) + { + return; + } + + _expressionId = clamped; + OnExpressionChanged?.Invoke(_expressionId); + } + + public void SetPortrait(int id) + { + SetExpression(id); + } + + public void SendMessage(string text) + { + SendUserMessage(text); + } + + public void SendUserMessage(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return; + } + + string trimmed = text.Trim(); + if (string.Equals(trimmed, "/clear", StringComparison.OrdinalIgnoreCase)) + { + ClearHistory(); + return; + } + + if (string.IsNullOrWhiteSpace(_activeEventDefName)) + { + WulaLog.Debug("[WulaAI] No active event def set; call InitializeConversation first."); + return; + } + + _history.Add(("user", text)); + PersistHistory(); + _ = RunPhasedRequestAsync(); + } + + private void InitializeTools() + { + _tools.Clear(); + _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_GetRecentNotifications()); + _tools.Add(new Tool_CallBombardment()); + _tools.Add(new Tool_SearchThingDef()); + _tools.Add(new Tool_SearchPawnKind()); + } + + private void SetThinkingState(bool isThinking) + { + if (_isThinking == isThinking) + { + return; + } + + _isThinking = isThinking; + OnThinkingStateChanged?.Invoke(_isThinking); + } + + private void SetThinkingPhase(int phaseIndex, bool isRetry) + { + _thinkingPhaseIndex = Math.Max(1, Math.Min(ThinkingPhaseTotal, phaseIndex)); + _thinkingPhaseRetry = isRetry; + _thinkingStartTime = Time.realtimeSinceStartup; + } + + private static int GetMaxHistoryTokens() + { + int configured = WulaFallenEmpireMod.settings?.maxContextTokens ?? DefaultMaxHistoryTokens; + return Math.Max(1000, Math.Min(200000, configured)); + } + + private void LoadHistoryForActiveEvent() + { + var historyManager = Find.World?.GetComponent(); + _history = historyManager?.GetHistory(_activeEventDefName) ?? new List<(string role, string message)>(); + } + + private void PersistHistory() + { + if (string.IsNullOrWhiteSpace(_activeEventDefName)) + { + return; + } + + try + { + var historyManager = Find.World?.GetComponent(); + historyManager?.SaveHistory(_activeEventDefName, _history); + } + catch (Exception ex) + { + WulaLog.Debug($"[WulaAI] Failed to persist AI history: {ex}"); + } + } + + private void ClearHistory() + { + _history.Clear(); + try + { + var historyManager = Find.World?.GetComponent(); + historyManager?.ClearHistory(_activeEventDefName); + } + catch (Exception ex) + { + WulaLog.Debug($"[WulaAI] Failed to clear AI history: {ex}"); + } + + Messages.Message("AI conversation history cleared.", MessageTypeDefOf.NeutralEvent); + } + + private void StartConversation() + { + _ = RunPhasedRequestAsync(); + } + + private bool TryApplyLastAssistantExpression() + { + for (int i = _history.Count - 1; i >= 0; i--) + { + var entry = _history[i]; + if (!string.Equals(entry.role, "assistant", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.IsNullOrWhiteSpace(entry.message)) + { + return false; + } + + string cleaned = StripExpressionTags(entry.message); + if (!string.Equals(cleaned, entry.message, StringComparison.Ordinal)) + { + _history[i] = ("assistant", cleaned); + PersistHistory(); + } + + return true; + } + + return false; + } + + private EventDef GetActiveEventDef() + { + if (string.IsNullOrWhiteSpace(_activeEventDefName)) + { + return null; + } + + return DefDatabase.GetNamedSilentFail(_activeEventDefName); + } + private string GetSystemInstruction(bool toolsEnabled, string toolsForThisPhase) + { + var def = GetActiveEventDef(); + string persona = def != null && !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona; + + string fullInstruction = toolsEnabled + ? (persona + "\n" + ToolRulesInstruction + "\n" + toolsForThisPhase) + : persona; + + string language = LanguageDatabase.activeLanguage?.FriendlyNameNative ?? "English"; + var eventVarManager = Find.World?.GetComponent(); + int goodwill = eventVarManager?.GetVariable("Wula_Goodwill_To_PIA", 0) ?? 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."; + + if (!toolsEnabled) + { + return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: You MUST reply in the following language: {language}.\n" + + "IMPORTANT: Tool calls are DISABLED in this turn. Reply in natural language only. Do NOT output any XML. " + + "You MAY include [EXPR:n] to set your expression (n=1-6)."; + } + + return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: Output XML tool calls only (or ). " + + $"You will produce the natural-language reply later and MUST use: {language}."; + } + + private string GetToolSystemInstruction(RequestPhase phase) + { + string phaseInstruction = GetPhaseInstruction(phase).TrimEnd(); + string toolsForThisPhase = BuildToolsForPhase(phase); + string actionPriority = phase == RequestPhase.ActionTools + ? "ACTION TOOL PRIORITY:\n" + + "- spawn_resources\n" + + "- send_reinforcement\n" + + "- call_bombardment\n" + + "- modify_goodwill\n" + + "If no action is required, output exactly: .\n" + + "Query tools exist but are disabled in this phase (not listed here).\n" + : string.Empty; + string actionWhitelist = phase == RequestPhase.ActionTools + ? "ACTION PHASE VALID TAGS ONLY:\n" + + ", , , , \n" + + "INVALID EXAMPLES (do NOT use now): , , \n" + : string.Empty; + + return string.Join("\n\n", new[] + { + phaseInstruction, + string.IsNullOrWhiteSpace(actionPriority) ? null : actionPriority.TrimEnd(), + string.IsNullOrWhiteSpace(actionWhitelist) ? null : actionWhitelist.TrimEnd(), + ToolRulesInstruction.TrimEnd(), + toolsForThisPhase + }.Where(part => !string.IsNullOrWhiteSpace(part))); + } + + private string BuildToolsForPhase(RequestPhase phase) + { + if (phase == RequestPhase.Reply) return ""; + + var available = _tools + .Where(t => t != null) + .Where(t => phase == RequestPhase.QueryTools + ? IsQueryToolName(t.Name) + : phase == RequestPhase.ActionTools + ? IsActionToolName(t.Name) + : true) + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + StringBuilder sb = new StringBuilder(); + sb.AppendLine("===="); + sb.AppendLine(); + sb.AppendLine("# TOOLS (AVAILABLE)"); + sb.AppendLine("Use XML tool calls only, or if no tools are needed."); + sb.AppendLine(); + + foreach (var tool in available) + { + sb.AppendLine($"## {tool.Name}"); + if (!string.IsNullOrWhiteSpace(tool.Description)) + { + sb.AppendLine($"Description: {tool.Description}"); + } + if (!string.IsNullOrWhiteSpace(tool.UsageSchema)) + { + sb.AppendLine($"Usage: {tool.UsageSchema}"); + } + sb.AppendLine(); + } + + return sb.ToString().TrimEnd(); + } + + private static string GetPhaseInstruction(RequestPhase phase) + { + return phase switch + { + RequestPhase.QueryTools => + "# PHASE 1/3 (Query Tools)\n" + + "Goal: Gather info needed for decisions.\n" + + "Rules:\n" + + "- You MUST NOT write any natural language to the user in this phase.\n" + + "- Output XML tool calls only, or exactly: .\n" + + "- Prefer query tools (get_*/search_*).\n" + + "- You MAY call multiple tools in one response, but keep it concise.\n" + + "- If the user requests multiple items or information, you MUST output ALL required tool calls in this SAME response.\n" + + "- Action tools are available in PHASE 2 only; do NOT use them here.\n" + + "After this phase, the game will automatically proceed to PHASE 2.\n" + + "Output: XML only.\n", + RequestPhase.ActionTools => + "# PHASE 2/3 (Action Tools)\n" + + "Goal: Execute in-game actions based on known info.\n" + + "Rules:\n" + + "- You MUST NOT write any natural language to the user in this phase.\n" + + "- Output XML tool calls only, or exactly: .\n" + + "- ONLY action tools are accepted in this phase (spawn_resources, send_reinforcement, call_bombardment, modify_goodwill).\n" + + "- Query tools (get_*/search_*) will be ignored.\n" + + "- Prefer action tools (spawn_resources, send_reinforcement, call_bombardment, modify_goodwill).\n" + + "- Avoid queries unless absolutely required.\n" + + "- If no action is required based on query results, output .\n" + + "- If you already executed the needed action earlier this turn, output .\n" + + "After this phase, the game will automatically proceed to PHASE 3.\n" + + "Output: XML only.\n", + RequestPhase.Reply => + "# PHASE 3/3 (Reply)\n" + + "Goal: Reply to the player.\n" + + "Rules:\n" + + "- Tool calls are DISABLED.\n" + + "- You MUST write natural language only.\n" + + "- Do NOT output any XML.\n" + + "- If you want to set your expression, include: [EXPR:n] (n=1-6).\n", + _ => "" + }; + } + + private static bool IsXmlToolCall(string response) + { + if (string.IsNullOrWhiteSpace(response)) return false; + return Regex.IsMatch(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); + } + + private static bool IsNoActionOnly(string response) + { + if (string.IsNullOrWhiteSpace(response)) return false; + var matches = Regex.Matches(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); + return matches.Count == 1 && + matches[0].Groups[1].Value.Equals("no_action", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasActionToolCall(string response) + { + if (string.IsNullOrWhiteSpace(response)) return false; + var matches = Regex.Matches(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); + foreach (Match match in matches) + { + var toolName = match.Groups[1].Value; + if (IsActionToolName(toolName)) + { + return true; + } + } + return false; + } + + private static bool ShouldRetryTools(string response) + { + if (string.IsNullOrWhiteSpace(response)) return false; + return Regex.IsMatch(response, @"<\s*retry_tools\s*/\s*>", RegexOptions.IgnoreCase) || + Regex.IsMatch(response, @"<\s*retry_tools\s*>", RegexOptions.IgnoreCase); + } + + private static int MaxToolsPerPhase(RequestPhase phase) + { + return phase switch + { + RequestPhase.QueryTools => 8, + RequestPhase.ActionTools => 8, + _ => 0 + }; + } + + private static bool IsActionToolName(string toolName) + { + return toolName == "spawn_resources" || + toolName == "send_reinforcement" || + toolName == "call_bombardment" || + toolName == "modify_goodwill"; + } + + private static bool IsQueryToolName(string toolName) + { + if (string.IsNullOrWhiteSpace(toolName)) return false; + return toolName.StartsWith("get_", StringComparison.OrdinalIgnoreCase) || + toolName.StartsWith("search_", StringComparison.OrdinalIgnoreCase); + } + + private static string SanitizeToolResultForActionPhase(string message) + { + if (string.IsNullOrWhiteSpace(message)) return message; + string sanitized = message; + sanitized = Regex.Replace(sanitized, @"Tool\s+'[^']+'\s+Result(?:\s+\(Invisible\))?:", "Query Result:"); + sanitized = Regex.Replace(sanitized, @"Tool\s+'[^']+'\s+Result\s+\(Invisible\):", "Query Result:"); + sanitized = Regex.Replace(sanitized, @"(?m)^ToolRunner\s+(Guidance|Guard|Note):.*(\r?\n)?", ""); + sanitized = Regex.Replace(sanitized, @"(?m)^\s+$", ""); + sanitized = sanitized.Trim(); + return sanitized; + } + + private static string TrimForPrompt(string text, int maxChars) + { + if (string.IsNullOrWhiteSpace(text)) return ""; + if (text.Length <= maxChars) return text; + return text.Substring(0, maxChars) + "...(truncated)"; + } + private List<(string role, string message)> BuildToolContext(RequestPhase phase, int maxToolResults = 2, bool includeUser = true) + { + if (_history == null || _history.Count == 0) return new List<(string role, string message)>(); + + int lastUserIndex = -1; + for (int i = _history.Count - 1; i >= 0; i--) + { + if (string.Equals(_history[i].role, "user", StringComparison.OrdinalIgnoreCase)) + { + lastUserIndex = i; + break; + } + } + + if (lastUserIndex == -1) return new List<(string role, string message)>(); + + var toolEntries = new List<(string role, string message)>(); + for (int i = lastUserIndex + 1; i < _history.Count; i++) + { + if (string.Equals(_history[i].role, "tool", StringComparison.OrdinalIgnoreCase)) + { + string msg = _history[i].message; + if (phase == RequestPhase.ActionTools) + { + msg = SanitizeToolResultForActionPhase(msg); + } + toolEntries.Add((_history[i].role, msg)); + } + } + + if (toolEntries.Count > maxToolResults) + { + toolEntries = toolEntries.Skip(toolEntries.Count - maxToolResults).ToList(); + } + + bool includeUserFallback = includeUser || toolEntries.Count == 0; + var context = new List<(string role, string message)>(); + if (includeUserFallback) + { + context.Add(_history[lastUserIndex]); + } + + context.AddRange(toolEntries); + return context; + } + + private List<(string role, string message)> BuildReplyHistory() + { + if (_history == null || _history.Count == 0) return new List<(string role, string message)>(); + + int lastUserIndex = -1; + for (int i = _history.Count - 1; i >= 0; i--) + { + if (string.Equals(_history[i].role, "user", StringComparison.OrdinalIgnoreCase)) + { + lastUserIndex = i; + break; + } + } + + var filtered = new List<(string role, string message)>(); + for (int i = 0; i < _history.Count; i++) + { + var entry = _history[i]; + if (string.Equals(entry.role, "tool", StringComparison.OrdinalIgnoreCase)) + { + if (lastUserIndex != -1 && i > lastUserIndex) + { + filtered.Add(entry); + } + continue; + } + + if (!string.Equals(entry.role, "assistant", StringComparison.OrdinalIgnoreCase)) + { + filtered.Add(entry); + continue; + } + + string stripped = StripXmlTags(entry.message)?.Trim() ?? ""; + if (!string.IsNullOrWhiteSpace(stripped)) + { + filtered.Add(entry); + } + } + + return filtered; + } + + private void CompressHistoryIfNeeded() + { + int estimatedTokens = _history.Sum(h => h.message?.Length ?? 0) / CharsPerToken; + if (estimatedTokens > GetMaxHistoryTokens()) + { + int removeCount = _history.Count / 2; + if (removeCount > 0) + { + _history.RemoveRange(0, removeCount); + _history.Insert(0, ("system", "[Previous conversation summarized]")); + PersistHistory(); + } + } + } + + private static string StripXmlTags(string text) + { + if (string.IsNullOrEmpty(text)) return text; + string stripped = Regex.Replace(text, @"<([a-zA-Z0-9_]+)[^>]*>.*?", "", RegexOptions.Singleline); + stripped = Regex.Replace(stripped, @"<([a-zA-Z0-9_]+)[^>]*/>", ""); + return stripped; + } + + private string StripExpressionTags(string text) + { + if (string.IsNullOrEmpty(text)) return text; + + var matches = ExpressionTagRegex.Matches(text); + int exprId = 0; + foreach (Match match in matches) + { + if (int.TryParse(match.Groups[1].Value, out int id)) + { + exprId = id; + } + } + + if (exprId >= 1 && exprId <= 6) + { + SetExpression(exprId); + } + + return matches.Count > 0 ? ExpressionTagRegex.Replace(text, "").Trim() : text; + } + + private void AddAssistantMessage(string rawResponse) + { + string cleanedResponse = StripExpressionTags(rawResponse ?? ""); + if (string.IsNullOrWhiteSpace(cleanedResponse)) + { + return; + } + + bool added = false; + if (_history.Count == 0 || !string.Equals(_history[_history.Count - 1].role, "assistant", StringComparison.OrdinalIgnoreCase)) + { + _history.Add(("assistant", cleanedResponse)); + added = true; + } + else if (!string.Equals(_history[_history.Count - 1].message, cleanedResponse, StringComparison.Ordinal)) + { + _history.Add(("assistant", cleanedResponse)); + added = true; + } + + if (added) + { + PersistHistory(); + OnMessageReceived?.Invoke(cleanedResponse); + } + } + private async Task RunPhasedRequestAsync() + { + if (_isThinking) return; + SetThinkingState(true); + SetThinkingPhase(1, false); + ResetTurnState(); + + try + { + CompressHistoryIfNeeded(); + + var settings = WulaFallenEmpireMod.settings; + if (settings == null || string.IsNullOrEmpty(settings.apiKey)) + { + AddAssistantMessage("Error: API Key not configured in Mod Settings."); + return; + } + + var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model); + + var queryPhase = RequestPhase.QueryTools; + if (Prefs.DevMode) + { + WulaLog.Debug($"[WulaAI] ===== Turn 1/3 ({queryPhase}) ====="); + } + + string queryInstruction = GetToolSystemInstruction(queryPhase); + string queryResponse = await client.GetChatCompletionAsync(queryInstruction, BuildToolContext(queryPhase), maxTokens: 128, temperature: 0.1f); + if (string.IsNullOrEmpty(queryResponse)) + { + AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate()); + return; + } + + if (!IsXmlToolCall(queryResponse)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Turn 1/3 missing XML; treating as "); + } + queryResponse = ""; + } + + PhaseExecutionResult queryResult = await ExecuteXmlToolsForPhase(queryResponse, queryPhase); + + if (!queryResult.AnyToolSuccess && !_queryRetryUsed) + { + _queryRetryUsed = true; + string lastUserMessage = _history.LastOrDefault(entry => entry.role == "user").message ?? ""; + var def = GetActiveEventDef(); + string persona = def != null && !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona; + string retryInstruction = persona + + "\n\n# RETRY DECISION\n" + + "No successful tool calls occurred in PHASE 1 (Query).\n" + + "If you need to use tools in PHASE 1, output exactly: .\n" + + "If you will proceed without actions, output exactly: .\n" + + "Output the XML tag only and NOTHING else.\n" + + "\nLast user request:\n" + lastUserMessage; + + string retryDecision = await client.GetChatCompletionAsync(retryInstruction, new List<(string role, string message)>(), maxTokens: 16, temperature: 0.1f); + if (!string.IsNullOrEmpty(retryDecision) && ShouldRetryTools(retryDecision)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Retry requested; re-opening query phase once."); + } + + SetThinkingPhase(1, true); + string retryQueryInstruction = GetToolSystemInstruction(queryPhase) + + "\n\n# RETRY\nYou chose to retry. Output XML tool calls only (or )."; + string retryQueryResponse = await client.GetChatCompletionAsync(retryQueryInstruction, BuildToolContext(queryPhase), maxTokens: 128, temperature: 0.1f); + if (string.IsNullOrEmpty(retryQueryResponse)) + { + AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate()); + return; + } + + if (!IsXmlToolCall(retryQueryResponse)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Retry query phase missing XML; treating as "); + } + retryQueryResponse = ""; + } + + queryResult = await ExecuteXmlToolsForPhase(retryQueryResponse, queryPhase); + } + } + + var actionPhase = RequestPhase.ActionTools; + if (Prefs.DevMode) + { + WulaLog.Debug($"[WulaAI] ===== Turn 2/3 ({actionPhase}) ====="); + } + + SetThinkingPhase(2, false); + string actionInstruction = GetToolSystemInstruction(actionPhase); + var actionContext = BuildToolContext(actionPhase, includeUser: true); + string actionResponse = await client.GetChatCompletionAsync(actionInstruction, actionContext, maxTokens: 128, temperature: 0.1f); + if (string.IsNullOrEmpty(actionResponse)) + { + AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate()); + return; + } + + bool actionHasXml = IsXmlToolCall(actionResponse); + bool actionIsNoActionOnly = IsNoActionOnly(actionResponse); + bool actionHasActionTool = actionHasXml && HasActionToolCall(actionResponse); + if (!actionHasXml || (!actionHasActionTool && !actionIsNoActionOnly)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Turn 2/3 missing XML or no action tool; attempting XML-only conversion."); + } + string fixInstruction = "# FORMAT FIX (ACTION XML ONLY)\n" + + "Preserve the intent of the previous output.\n" + + "If the previous output indicates no action is needed or refuses action, output exactly: .\n" + + "Do NOT invent new actions.\n" + + "Output VALID XML tool calls only. No natural language, no commentary.\n" + + "Allowed tags: , , , , .\n" + + "\nAction tool XML formats:\n" + + "- DefNameInt\n" + + "- PawnKindDef: Count, ...\n" + + "- DefNameIntInt\n" + + "- Int\n" + + "\nPrevious output:\n" + TrimForPrompt(actionResponse, 600); + string fixedResponse = await client.GetChatCompletionAsync(fixInstruction, actionContext, maxTokens: 128, temperature: 0.1f); + bool fixedHasXml = !string.IsNullOrEmpty(fixedResponse) && IsXmlToolCall(fixedResponse); + bool fixedIsNoActionOnly = fixedHasXml && IsNoActionOnly(fixedResponse); + bool fixedHasActionTool = fixedHasXml && HasActionToolCall(fixedResponse); + if (fixedHasXml && (fixedHasActionTool || fixedIsNoActionOnly)) + { + actionResponse = fixedResponse; + } + else + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Turn 2/3 conversion failed; treating as "); + } + actionResponse = ""; + } + } + PhaseExecutionResult actionResult = await ExecuteXmlToolsForPhase(actionResponse, actionPhase); + if (!actionResult.AnyActionSuccess && !_actionRetryUsed) + { + _actionRetryUsed = true; + string lastUserMessage = _history.LastOrDefault(entry => entry.role == "user").message ?? ""; + var def = GetActiveEventDef(); + string persona = def != null && !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona; + string retryInstruction = persona + + "\n\n# RETRY DECISION\n" + + "No successful action tools occurred in PHASE 2 (Action).\n" + + "If you need to execute an in-game action, output exactly: .\n" + + "If you will proceed without actions, output exactly: .\n" + + "Output the XML tag only and NOTHING else.\n" + + "\nLast user request:\n" + lastUserMessage; + + string retryDecision = await client.GetChatCompletionAsync(retryInstruction, new List<(string role, string message)>(), maxTokens: 16, temperature: 0.1f); + if (!string.IsNullOrEmpty(retryDecision) && ShouldRetryTools(retryDecision)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Retry requested; re-opening action phase once."); + } + + SetThinkingPhase(2, true); + string retryActionInstruction = GetToolSystemInstruction(actionPhase) + + "\n\n# RETRY\nYou chose to retry. Output XML tool calls only (or )."; + var retryActionContext = BuildToolContext(actionPhase, includeUser: true); + string retryActionResponse = await client.GetChatCompletionAsync(retryActionInstruction, retryActionContext, maxTokens: 128, temperature: 0.1f); + if (string.IsNullOrEmpty(retryActionResponse)) + { + AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate()); + return; + } + + if (!IsXmlToolCall(retryActionResponse)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Retry action phase missing XML; attempting XML-only conversion."); + } + string retryFixInstruction = "# FORMAT FIX (ACTION XML ONLY)\n" + + "Preserve the intent of the previous output.\n" + + "If the previous output indicates no action is needed or refuses action, output exactly: .\n" + + "Do NOT invent new actions.\n" + + "Output VALID XML tool calls only. No natural language, no commentary.\n" + + "Allowed tags: , , , , .\n" + + "\nAction tool XML formats:\n" + + "- DefNameInt\n" + + "- PawnKindDef: Count, ...\n" + + "- DefNameIntInt\n" + + "- Int\n" + + "\nPrevious output:\n" + TrimForPrompt(retryActionResponse, 600); + string retryFixedResponse = await client.GetChatCompletionAsync(retryFixInstruction, retryActionContext, maxTokens: 128, temperature: 0.1f); + bool retryFixedHasXml = !string.IsNullOrEmpty(retryFixedResponse) && IsXmlToolCall(retryFixedResponse); + bool retryFixedIsNoActionOnly = retryFixedHasXml && IsNoActionOnly(retryFixedResponse); + bool retryFixedHasActionTool = retryFixedHasXml && HasActionToolCall(retryFixedResponse); + if (retryFixedHasXml && (retryFixedHasActionTool || retryFixedIsNoActionOnly)) + { + retryActionResponse = retryFixedResponse; + } + else + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Retry action conversion failed; treating as "); + } + retryActionResponse = ""; + } + } + + actionResult = await ExecuteXmlToolsForPhase(retryActionResponse, actionPhase); + } + } + + _lastSuccessfulToolCall = _querySuccessfulToolCall || _actionSuccessfulToolCall; + + var replyPhase = RequestPhase.Reply; + if (Prefs.DevMode) + { + WulaLog.Debug($"[WulaAI] ===== Turn 3/3 ({replyPhase}) ====="); + } + + SetThinkingPhase(3, false); + string replyInstruction = GetSystemInstruction(false, "") + "\n\n" + GetPhaseInstruction(replyPhase); + if (!string.IsNullOrWhiteSpace(_queryToolLedgerNote)) + { + replyInstruction += "\n" + _queryToolLedgerNote; + } + if (!string.IsNullOrWhiteSpace(_actionToolLedgerNote)) + { + replyInstruction += "\n" + _actionToolLedgerNote; + } + if (!string.IsNullOrWhiteSpace(_lastActionLedgerNote)) + { + replyInstruction += "\n" + _lastActionLedgerNote + + "\nIMPORTANT: Do NOT claim any in-game actions beyond the Action Ledger. If the ledger is None, you MUST NOT claim any deliveries, reinforcements, or bombardments."; + } + if (_lastActionExecuted) + { + replyInstruction += "\nIMPORTANT: Actions in the Action Ledger were executed in-game. You MUST acknowledge them as completed in your reply. You MUST NOT deny, retract, or contradict them."; + } + if (!_lastSuccessfulToolCall) + { + replyInstruction += "\nIMPORTANT: No successful tool calls occurred in the tool phases. You MUST NOT claim any tools or actions succeeded."; + } + if (_lastActionHadError) + { + replyInstruction += "\nIMPORTANT: An action tool failed. You MUST acknowledge the failure and MUST NOT claim success."; + if (_lastActionExecuted) + { + replyInstruction += " You MUST still confirm any successful actions separately."; + } + } + + string reply = await client.GetChatCompletionAsync(replyInstruction, BuildReplyHistory()); + if (string.IsNullOrEmpty(reply)) + { + AddAssistantMessage("Wula_AI_Error_ConnectionLost".Translate()); + return; + } + + bool replyHadXml = IsXmlToolCall(reply); + string strippedReply = StripXmlTags(reply)?.Trim() ?? ""; + if (replyHadXml || string.IsNullOrWhiteSpace(strippedReply)) + { + string retryReplyInstruction = replyInstruction + + "\n\n# RETRY (REPLY OUTPUT)\n" + + "Your last reply included XML or was empty. Tool calls are DISABLED.\n" + + "You MUST reply in natural language only. Do NOT output any XML.\n"; + string retryReply = await client.GetChatCompletionAsync(retryReplyInstruction, BuildReplyHistory(), maxTokens: 256, temperature: 0.3f); + if (!string.IsNullOrEmpty(retryReply)) + { + reply = retryReply; + replyHadXml = IsXmlToolCall(reply); + strippedReply = StripXmlTags(reply)?.Trim() ?? ""; + } + } + + if (replyHadXml) + { + string cleaned = StripXmlTags(reply)?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(cleaned)) + { + cleaned = "(system) AI reply returned tool XML only and was discarded. Please retry or send /clear to reset context."; + } + reply = cleaned; + } + + AddAssistantMessage(reply); + } + catch (Exception ex) + { + WulaLog.Debug($"[WulaAI] Exception in RunPhasedRequestAsync: {ex}"); + AddAssistantMessage("Wula_AI_Error_Internal".Translate(ex.Message)); + } + finally + { + SetThinkingState(false); + } + } + private async Task ExecuteXmlToolsForPhase(string xml, RequestPhase phase) + { + if (phase == RequestPhase.Reply) + { + await Task.CompletedTask; + return default; + } + + string guidance = "ToolRunner Guidance: Reply to the player in natural language only. Do NOT output any XML. You may include [EXPR:n] to set expression (n=1-6)."; + + var matches = Regex.Matches(xml ?? "", @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); + if (matches.Count == 0 || (matches.Count == 1 && matches[0].Groups[1].Value.Equals("no_action", StringComparison.OrdinalIgnoreCase))) + { + UpdatePhaseToolLedger(phase, false, new List()); + _history.Add(("toolcall", "")); + _history.Add(("tool", $"[Tool Results]\nTool 'no_action' Result: No action taken.\n{guidance}")); + PersistHistory(); + UpdateActionLedgerNote(); + await Task.CompletedTask; + return default; + } + + int maxTools = MaxToolsPerPhase(phase); + int executed = 0; + bool executedActionTool = false; + bool successfulToolCall = false; + var successfulTools = new List(); + var successfulActions = new List(); + var failedActions = new List(); + var nonActionToolsInActionPhase = new List(); + StringBuilder combinedResults = new StringBuilder(); + StringBuilder xmlOnlyBuilder = new StringBuilder(); + + bool countActionSuccessOnly = phase == RequestPhase.ActionTools; + + foreach (Match match in matches) + { + if (executed >= maxTools) + { + combinedResults.AppendLine($"ToolRunner Note: Skipped remaining tools because this phase allows at most {maxTools} tool call(s)." ); + break; + } + + string toolCallXml = match.Value; + string toolName = match.Groups[1].Value; + + if (toolName.Equals("no_action", StringComparison.OrdinalIgnoreCase)) + { + combinedResults.AppendLine("ToolRunner Note: Ignored because other tool calls were present."); + continue; + } + + if (xmlOnlyBuilder.Length > 0) xmlOnlyBuilder.AppendLine().AppendLine(); + xmlOnlyBuilder.Append(toolCallXml); + + if (phase == RequestPhase.ActionTools && IsQueryToolName(toolName)) + { + combinedResults.AppendLine($"ToolRunner Note: Ignored query tool in action phase: {toolName}."); + nonActionToolsInActionPhase.Add(toolName); + executed++; + continue; + } + + var tool = _tools.FirstOrDefault(t => t.Name == toolName); + if (tool == null) + { + combinedResults.AppendLine($"Error: Tool '{toolName}' not found."); + combinedResults.AppendLine("ToolRunner Guard: The tool call failed. In your reply you MUST acknowledge the failure and MUST NOT claim success."); + executed++; + continue; + } + + string argsXml = toolCallXml; + var contentMatch = Regex.Match(toolCallXml, $@"<{toolName}>(.*?)", RegexOptions.Singleline); + if (contentMatch.Success) + { + argsXml = contentMatch.Groups[1].Value; + } + + if (Prefs.DevMode) + { + WulaLog.Debug($"[WulaAI] Executing tool (phase {phase}): {toolName} with args: {argsXml}"); + } + + string result = tool.Execute(argsXml).Trim(); + bool isError = !string.IsNullOrEmpty(result) && result.StartsWith("Error:", StringComparison.OrdinalIgnoreCase); + if (toolName == "modify_goodwill") + { + combinedResults.AppendLine($"Tool '{toolName}' Result (Invisible): {result}"); + } + else + { + combinedResults.AppendLine($"Tool '{toolName}' Result: {result}"); + } + if (isError) + { + combinedResults.AppendLine("ToolRunner Guard: The tool returned an error. In your reply you MUST acknowledge the failure and MUST NOT claim success."); + } + if (!isError) + { + bool countsAsSuccess = !countActionSuccessOnly || IsActionToolName(toolName); + if (countsAsSuccess) + { + successfulToolCall = true; + successfulTools.Add(toolName); + } + else + { + nonActionToolsInActionPhase.Add(toolName); + } + } + if (IsActionToolName(toolName)) + { + if (!isError) + { + executedActionTool = true; + successfulActions.Add(toolName); + AddActionSuccess(toolName); + } + else + { + failedActions.Add(toolName); + AddActionFailure(toolName); + } + } + + executed++; + } + + string nonXmlText = StripXmlTags(xml); + if (!string.IsNullOrWhiteSpace(nonXmlText)) + { + combinedResults.AppendLine("ToolRunner Note: Non-XML text in the tool phase was ignored."); + } + if (phase == RequestPhase.ActionTools && nonActionToolsInActionPhase.Count > 0) + { + combinedResults.AppendLine($"ToolRunner Note: Action phase ignores non-action tools for success: {string.Join(", ", nonActionToolsInActionPhase)}."); + } + if (executedActionTool) + { + combinedResults.AppendLine("ToolRunner Guard: An in-game action tool WAS executed this turn. You MAY reference it, but do NOT invent additional actions."); + } + else + { + combinedResults.AppendLine("ToolRunner Guard: NO in-game actions were executed. You MUST NOT claim any deliveries, reinforcements, bombardments, or other actions occurred."); + if (phase == RequestPhase.ActionTools) + { + combinedResults.AppendLine("ToolRunner Guard: Action phase failed (no action tools executed)."); + } + } + combinedResults.AppendLine(guidance); + + string xmlOnly = xmlOnlyBuilder.Length == 0 ? "" : xmlOnlyBuilder.ToString().Trim(); + _history.Add(("toolcall", xmlOnly)); + _history.Add(("tool", $"[Tool Results]\n{combinedResults.ToString().Trim()}")); + PersistHistory(); + + UpdatePhaseToolLedger(phase, successfulToolCall, successfulTools); + UpdateActionLedgerNote(); + + await Task.CompletedTask; + return new PhaseExecutionResult + { + AnyToolSuccess = successfulToolCall, + AnyActionSuccess = successfulActions.Count > 0, + AnyActionError = failedActions.Count > 0 + }; + } + + private void AddActionSuccess(string toolName) + { + if (_actionSuccessLedgerSet.Add(toolName)) + { + _actionSuccessLedger.Add(toolName); + } + } + + private void AddActionFailure(string toolName) + { + if (_actionFailedLedgerSet.Add(toolName)) + { + _actionFailedLedger.Add(toolName); + } + } + + private void UpdateActionLedgerNote() + { + _lastActionExecuted = _actionSuccessLedger.Count > 0; + _lastActionHadError = _actionFailedLedger.Count > 0; + if (_lastActionExecuted) + { + _lastActionLedgerNote = $"Action Ledger: {string.Join(", ", _actionSuccessLedger)}"; + } + else if (_lastActionHadError) + { + _lastActionLedgerNote = $"Action Ledger: None (no successful actions). Failed: {string.Join(", ", _actionFailedLedger)}"; + } + else + { + _lastActionLedgerNote = "Action Ledger: None (no in-game actions executed)."; + } + } + + private void UpdatePhaseToolLedger(RequestPhase phase, bool hasSuccess, List successfulTools) + { + if (phase == RequestPhase.QueryTools) + { + _querySuccessfulToolCall = hasSuccess; + _queryToolLedgerNote = hasSuccess + ? $"Tool Ledger (Query): {string.Join(", ", successfulTools)}" + : "Tool Ledger (Query): None (no successful tool calls)."; + } + else if (phase == RequestPhase.ActionTools) + { + _actionSuccessfulToolCall = hasSuccess; + _actionToolLedgerNote = hasSuccess + ? $"Tool Ledger (Action): {string.Join(", ", successfulTools)}" + : "Tool Ledger (Action): None (no successful tool calls)."; + } + } + + private void ResetTurnState() + { + _lastActionExecuted = false; + _lastActionHadError = false; + _lastActionLedgerNote = "Action Ledger: None (no in-game actions executed)."; + _lastSuccessfulToolCall = false; + _queryToolLedgerNote = "Tool Ledger (Query): None (no successful tool calls)."; + _actionToolLedgerNote = "Tool Ledger (Action): None (no successful tool calls)."; + _querySuccessfulToolCall = false; + _actionSuccessfulToolCall = false; + _queryRetryUsed = false; + _actionRetryUsed = false; + _actionSuccessLedger.Clear(); + _actionSuccessLedgerSet.Clear(); + _actionFailedLedger.Clear(); + _actionFailedLedgerSet.Clear(); + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/DebugActions_WulaLink.cs b/Source/WulaFallenEmpire/EventSystem/AI/DebugActions_WulaLink.cs new file mode 100644 index 00000000..9c2679f0 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/DebugActions_WulaLink.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Verse; +using RimWorld; +using LudeonTK; +using WulaFallenEmpire.EventSystem.AI.UI; + +namespace WulaFallenEmpire.EventSystem.AI +{ + public static class DebugActions_WulaLink + { + [DebugAction("WulaLink", "Open WulaLink UI", actionType = DebugActionType.Action, allowedGameStates = AllowedGameStates.Playing)] + public static void OpenWulaLink() + { + // Find a suitable event def or create a generic one + EventDef def = DefDatabase.AllDefs.FirstOrDefault(); + if (def == null) + { + Messages.Message("No EventDef found to initialize WulaLink.", MessageTypeDefOf.RejectInput, false); + return; + } + + Find.WindowStack.Add(new Overlay_WulaLink(def)); + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_ChangeExpression.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_ChangeExpression.cs index 3dfe6f95..6234d51b 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_ChangeExpression.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_ChangeExpression.cs @@ -1,6 +1,6 @@ using System; using Verse; -using WulaFallenEmpire.EventSystem.AI.UI; +using WulaFallenEmpire.EventSystem.AI; namespace WulaFallenEmpire.EventSystem.AI.Tools { @@ -21,13 +21,13 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools { if (int.TryParse(idStr, out id)) { - var window = Dialog_AIConversation.Instance ?? Find.WindowStack.WindowOfType(); - if (window != null) + var core = AIIntelligenceCore.Instance; + if (core != null) { - window.SetPortrait(id); + core.SetPortrait(id); return $"Expression changed to {id}."; } - return "Error: Dialog window not found."; + return "Error: AI Core not found."; } return "Error: Invalid arguments. 'expression_id' must be an integer."; } @@ -40,4 +40,4 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools } } } -} \ No newline at end of file +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_GetRecentNotifications.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_GetRecentNotifications.cs index 5862c68c..d5e2016d 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_GetRecentNotifications.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_GetRecentNotifications.cs @@ -6,7 +6,7 @@ using System.Reflection; using System.Text; using Verse; using System.Text.RegularExpressions; -using WulaFallenEmpire.EventSystem.AI.UI; +using WulaFallenEmpire.EventSystem.AI; namespace WulaFallenEmpire.EventSystem.AI.Tools { @@ -110,10 +110,10 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools private static string BuildToolHistory(int maxCount) { - var window = Dialog_AIConversation.Instance ?? Find.WindowStack.WindowOfType(); - if (window == null) return "AI Tool History: none found."; + var core = AIIntelligenceCore.Instance; + if (core == null) return "AI Tool History: none found."; - var history = window.GetHistorySnapshot(); + var history = core.GetHistorySnapshot(); if (history == null || history.Count == 0) return "AI Tool History: none found."; var entries = new List<(string ToolXml, string ToolResult)>(); @@ -310,3 +310,4 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools } } } + diff --git a/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs b/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs index 960ae8b1..9d2c4ab8 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -7,7 +7,6 @@ using System.Threading.Tasks; using RimWorld; using UnityEngine; using Verse; -using WulaFallenEmpire.EventSystem.AI; using WulaFallenEmpire.EventSystem.AI.Tools; using System.Text.RegularExpressions; @@ -45,13 +44,6 @@ namespace WulaFallenEmpire.EventSystem.AI.UI private const int DefaultMaxHistoryTokens = 100000; private const int CharsPerToken = 4; private const int ThinkingPhaseTotal = 3; - private const int MemorySearchLimit = 6; - private const int MemoryFactMaxChars = 200; - private const int MemoryPromptMaxChars = 1600; - private const int MemoryUpdateMaxMemories = 40; - private string _memoryContextCache = ""; - private string _memoryContextQuery = ""; - private bool _memoryExtractionInFlight = false; private enum RequestPhase { @@ -67,20 +59,6 @@ namespace WulaFallenEmpire.EventSystem.AI.UI public bool AnyActionError; } - private struct MemoryFact - { - public string Text; - public string Category; - } - - private struct MemoryUpdate - { - public string Event; - public string Id; - public string Text; - public string Category; - } - private void SetThinkingPhase(int phaseIndex, bool isRetry) { _thinkingPhaseIndex = Math.Max(1, Math.Min(ThinkingPhaseTotal, phaseIndex)); @@ -255,15 +233,10 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori { // Use XML persona if available, otherwise default string persona = !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona; - - string memoryContext = _memoryContextCache; - string personaWithMemory = string.IsNullOrWhiteSpace(memoryContext) - ? persona - : persona + "\n\n" + memoryContext; - + string fullInstruction = toolsEnabled - ? (personaWithMemory + "\n" + ToolRulesInstruction + "\n" + toolsForThisPhase) - : personaWithMemory; + ? (persona + "\n" + ToolRulesInstruction + "\n" + toolsForThisPhase) + : persona; string language = LanguageDatabase.activeLanguage.FriendlyNameNative; var eventVarManager = Find.World.GetComponent(); @@ -521,542 +494,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori return context; } - private void PrepareMemoryContext() - { - string lastUserMessage = _history.LastOrDefault(entry => string.Equals(entry.role, "user", StringComparison.OrdinalIgnoreCase)).message; - if (string.IsNullOrWhiteSpace(lastUserMessage)) - { - _memoryContextCache = ""; - _memoryContextQuery = ""; - return; - } - - _memoryContextQuery = lastUserMessage; - _memoryContextCache = BuildMemoryContext(lastUserMessage); - } - - private string BuildMemoryContext(string userQuery) - { - if (string.IsNullOrWhiteSpace(userQuery)) return ""; - - var memoryManager = Find.World?.GetComponent(); - if (memoryManager == null) return ""; - - var memories = memoryManager.SearchMemories(userQuery, MemorySearchLimit); - if (memories == null || memories.Count == 0) return ""; - - StringBuilder sb = new StringBuilder(); - sb.AppendLine("# MEMORY (Relevant Facts)"); - foreach (var memory in memories) - { - if (memory == null || string.IsNullOrWhiteSpace(memory.Fact)) continue; - string category = string.IsNullOrWhiteSpace(memory.Category) ? "misc" : memory.Category.Trim(); - string fact = TrimMemoryFact(memory.Fact, MemoryFactMaxChars); - if (string.IsNullOrWhiteSpace(fact)) continue; - sb.AppendLine($"- [{category}] {fact}"); - } - - return sb.ToString().TrimEnd(); - } - - private static string TrimMemoryFact(string fact, int maxChars) - { - if (string.IsNullOrWhiteSpace(fact)) return ""; - string trimmed = fact.Trim(); - if (trimmed.Length <= maxChars) return trimmed; - return trimmed.Substring(0, maxChars) + "..."; - } - - private string BuildMemoryExtractionConversation() - { - if (_history == null || _history.Count == 0) return ""; - - const int maxTurns = 6; - var lines = new List(); - - for (int i = _history.Count - 1; i >= 0 && lines.Count < maxTurns; i--) - { - var entry = _history[i]; - if (!string.Equals(entry.role, "user", StringComparison.OrdinalIgnoreCase) && - !string.Equals(entry.role, "assistant", StringComparison.OrdinalIgnoreCase)) - { - continue; - } - - string content = entry.role == "assistant" - ? ParseResponseForDisplay(entry.message) - : entry.message; - - if (string.IsNullOrWhiteSpace(content)) continue; - - lines.Add($"{entry.role}: {content.Trim()}"); - } - - lines.Reverse(); - string snippet = string.Join("\n", lines); - return TrimForPrompt(snippet, MemoryPromptMaxChars); - } - - private async Task ExtractAndUpdateMemoriesAsync() - { - if (_memoryExtractionInFlight) return; - _memoryExtractionInFlight = true; - try - { - var world = Find.World; - if (world == null) return; - - var memoryManager = world.GetComponent(); - if (memoryManager == null) return; - - string conversation = BuildMemoryExtractionConversation(); - if (string.IsNullOrWhiteSpace(conversation)) return; - - var settings = WulaFallenEmpire.WulaFallenEmpireMod.settings; - if (settings == null) return; - - var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model); - string extractPrompt = MemoryPrompts.BuildFactExtractionPrompt(conversation); - string extractResponse = await client.GetChatCompletionAsync(extractPrompt, new List<(string role, string message)>(), maxTokens: 256, temperature: 0.2f); - if (string.IsNullOrWhiteSpace(extractResponse)) return; - - List facts = ParseFactsResponse(extractResponse); - if (facts.Count == 0) return; - - var existing = memoryManager.GetAllMemories() - .OrderByDescending(m => m.UpdatedTicks) - .ThenByDescending(m => m.CreatedTicks) - .Take(MemoryUpdateMaxMemories) - .ToList(); - - if (existing.Count == 0) - { - AddFactsToMemory(memoryManager, facts); - return; - } - - string existingJson = BuildMemoriesJson(existing); - string factsJson = BuildFactsJson(facts); - string updatePrompt = MemoryPrompts.BuildMemoryUpdatePrompt(existingJson, factsJson); - string updateResponse = await client.GetChatCompletionAsync(updatePrompt, new List<(string role, string message)>(), maxTokens: 256, temperature: 0.2f); - if (string.IsNullOrWhiteSpace(updateResponse)) - { - AddFactsToMemory(memoryManager, facts); - return; - } - - List updates = ParseMemoryUpdateResponse(updateResponse); - if (updates.Count == 0) - { - AddFactsToMemory(memoryManager, facts); - return; - } - - ApplyMemoryUpdates(memoryManager, updates, facts); - } - catch (Exception ex) - { - WulaLog.Debug($"[WulaAI] Memory extraction failed: {ex}"); - } - finally - { - _memoryExtractionInFlight = false; - } - } - - private static void AddFactsToMemory(AIMemoryManager memoryManager, List facts) - { - if (memoryManager == null || facts == null) return; - - foreach (var fact in facts) - { - if (string.IsNullOrWhiteSpace(fact.Text)) continue; - memoryManager.AddMemory(fact.Text, fact.Category); - } - } - - private static void ApplyMemoryUpdates(AIMemoryManager memoryManager, List updates, List fallbackFacts) - { - if (memoryManager == null || updates == null) return; - - bool applied = false; - bool anyDecision = false; - foreach (var update in updates) - { - if (string.IsNullOrWhiteSpace(update.Event)) continue; - - switch (update.Event.Trim().ToUpperInvariant()) - { - case "ADD": - anyDecision = true; - if (!string.IsNullOrWhiteSpace(update.Text)) - { - memoryManager.AddMemory(update.Text, update.Category); - applied = true; - } - break; - case "UPDATE": - anyDecision = true; - if (!string.IsNullOrWhiteSpace(update.Id)) - { - if (memoryManager.UpdateMemory(update.Id, update.Text, update.Category)) - { - applied = true; - } - else if (!string.IsNullOrWhiteSpace(update.Text)) - { - memoryManager.AddMemory(update.Text, update.Category); - applied = true; - } - } - else if (!string.IsNullOrWhiteSpace(update.Text)) - { - memoryManager.AddMemory(update.Text, update.Category); - applied = true; - } - break; - case "DELETE": - anyDecision = true; - if (!string.IsNullOrWhiteSpace(update.Id) && memoryManager.DeleteMemory(update.Id)) - { - applied = true; - } - break; - case "NONE": - anyDecision = true; - break; - } - } - - if (!applied && !anyDecision) - { - AddFactsToMemory(memoryManager, fallbackFacts); - } - } - - private static string BuildMemoriesJson(List memories) - { - if (memories == null || memories.Count == 0) return "[]"; - - StringBuilder sb = new StringBuilder(); - sb.Append("["); - bool first = true; - foreach (var memory in memories) - { - if (memory == null || string.IsNullOrWhiteSpace(memory.Fact)) continue; - if (!first) sb.Append(","); - first = false; - sb.Append("{"); - sb.Append("\"id\":\"").Append(EscapeJson(memory.Id)).Append("\","); - sb.Append("\"text\":\"").Append(EscapeJson(memory.Fact)).Append("\","); - sb.Append("\"category\":\"").Append(EscapeJson(memory.Category)).Append("\""); - sb.Append("}"); - } - sb.Append("]"); - return sb.ToString(); - } - - private static string BuildFactsJson(List facts) - { - if (facts == null || facts.Count == 0) return "[]"; - - StringBuilder sb = new StringBuilder(); - sb.Append("["); - bool first = true; - foreach (var fact in facts) - { - if (string.IsNullOrWhiteSpace(fact.Text)) continue; - if (!first) sb.Append(","); - first = false; - sb.Append("{"); - sb.Append("\"text\":\"").Append(EscapeJson(fact.Text)).Append("\","); - sb.Append("\"category\":\"").Append(EscapeJson(fact.Category)).Append("\""); - sb.Append("}"); - } - sb.Append("]"); - return sb.ToString(); - } - - private static List ParseFactsResponse(string response) - { - var facts = new List(); - string array = TryExtractJsonArray(response, "facts"); - if (string.IsNullOrWhiteSpace(array)) return facts; - - var objects = ExtractJsonObjects(array); - if (objects.Count > 0) - { - foreach (string obj in objects) - { - var dict = SimpleJsonParser.Parse(obj); - if (dict == null || dict.Count == 0) continue; - - string text = GetDictionaryValue(dict, "text") ?? GetDictionaryValue(dict, "fact"); - if (string.IsNullOrWhiteSpace(text)) continue; - - string category = NormalizeMemoryCategory(GetDictionaryValue(dict, "category")); - facts.Add(new MemoryFact { Text = text.Trim(), Category = category }); - } - } - else - { - foreach (string item in SplitJsonArrayValues(array)) - { - if (string.IsNullOrWhiteSpace(item)) continue; - facts.Add(new MemoryFact { Text = item.Trim(), Category = "misc" }); - } - } - - var deduped = new List(); - var seen = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (var fact in facts) - { - string hash = AIMemoryEntry.ComputeHash(fact.Text); - if (string.IsNullOrWhiteSpace(hash) || !seen.Add(hash)) continue; - deduped.Add(fact); - } - - return deduped; - } - - private static List ParseMemoryUpdateResponse(string response) - { - var updates = new List(); - string array = TryExtractJsonArray(response, "memory") ?? TryExtractJsonArray(response, "memories"); - if (string.IsNullOrWhiteSpace(array)) return updates; - - foreach (string obj in ExtractJsonObjects(array)) - { - var dict = SimpleJsonParser.Parse(obj); - if (dict == null || dict.Count == 0) continue; - - string evt = GetDictionaryValue(dict, "event") ?? GetDictionaryValue(dict, "action"); - if (string.IsNullOrWhiteSpace(evt)) continue; - - updates.Add(new MemoryUpdate - { - Event = evt.Trim().ToUpperInvariant(), - Id = GetDictionaryValue(dict, "id"), - Text = GetDictionaryValue(dict, "text") ?? GetDictionaryValue(dict, "fact"), - Category = NormalizeMemoryCategory(GetDictionaryValue(dict, "category")) - }); - } - - return updates; - } - - private static string NormalizeMemoryCategory(string category) - { - if (string.IsNullOrWhiteSpace(category)) return "misc"; - string normalized = category.Trim().ToLowerInvariant(); - switch (normalized) - { - case "preference": - case "personal": - case "plan": - case "colony": - case "misc": - return normalized; - default: - return "misc"; - } - } - - private static string TryExtractJsonArray(string json, string key) - { - if (string.IsNullOrWhiteSpace(json) || string.IsNullOrWhiteSpace(key)) return null; - - string keyPattern = $"\"{key}\""; - int keyIndex = json.IndexOf(keyPattern, StringComparison.OrdinalIgnoreCase); - if (keyIndex == -1) return null; - - int arrayStart = json.IndexOf('[', keyIndex); - if (arrayStart == -1) return null; - - int arrayEnd = FindMatchingBracket(json, arrayStart); - if (arrayEnd == -1) return null; - - return json.Substring(arrayStart + 1, arrayEnd - arrayStart - 1); - } - - private static List ExtractJsonObjects(string arrayContent) - { - var objects = new List(); - if (string.IsNullOrWhiteSpace(arrayContent)) return objects; - - int depth = 0; - int start = -1; - bool inString = false; - bool escaped = false; - - for (int i = 0; i < arrayContent.Length; i++) - { - char c = arrayContent[i]; - if (inString) - { - if (escaped) - { - escaped = false; - continue; - } - if (c == '\\') - { - escaped = true; - continue; - } - if (c == '"') - { - inString = false; - } - continue; - } - - if (c == '"') - { - inString = true; - continue; - } - - if (c == '{') - { - if (depth == 0) start = i; - depth++; - continue; - } - if (c == '}') - { - depth--; - if (depth == 0 && start >= 0) - { - objects.Add(arrayContent.Substring(start, i - start + 1)); - start = -1; - } - } - } - - return objects; - } - - private static List SplitJsonArrayValues(string arrayContent) - { - var items = new List(); - if (string.IsNullOrWhiteSpace(arrayContent)) return items; - - bool inString = false; - bool escaped = false; - int start = 0; - - for (int i = 0; i < arrayContent.Length; i++) - { - char c = arrayContent[i]; - if (inString) - { - if (escaped) - { - escaped = false; - } - else if (c == '\\') - { - escaped = true; - } - else if (c == '"') - { - inString = false; - } - continue; - } - - if (c == '"') - { - inString = true; - continue; - } - - if (c == ',') - { - string part = arrayContent.Substring(start, i - start); - items.Add(UnescapeJsonString(part.Trim().Trim('"'))); - start = i + 1; - } - } - - if (start < arrayContent.Length) - { - string part = arrayContent.Substring(start); - items.Add(UnescapeJsonString(part.Trim().Trim('"'))); - } - - return items; - } - - private static string UnescapeJsonString(string value) - { - if (string.IsNullOrEmpty(value)) return ""; - return value.Replace("\\r", "\r").Replace("\\n", "\n").Replace("\\\"", "\"").Replace("\\\\", "\\"); - } - - private static string GetDictionaryValue(Dictionary dict, string key) - { - if (dict == null || string.IsNullOrWhiteSpace(key)) return null; - return dict.TryGetValue(key, out string value) ? value : null; - } - - private static string EscapeJson(string value) - { - if (string.IsNullOrEmpty(value)) return ""; - return value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r"); - } - - private static int FindMatchingBracket(string json, int startIndex) - { - int depth = 0; - bool inString = false; - bool escaped = false; - - for (int i = startIndex; i < json.Length; i++) - { - char c = json[i]; - if (inString) - { - if (escaped) - { - escaped = false; - continue; - } - if (c == '\\') - { - escaped = true; - continue; - } - if (c == '"') - { - inString = false; - } - continue; - } - - if (c == '"') - { - inString = true; - continue; - } - - if (c == '[') - { - depth++; - continue; - } - - if (c == ']') - { - depth--; - if (depth == 0) return i; - } - } - - return -1; - } - private async Task RunPhasedRequestAsync() { if (_isThinking) return; @@ -1081,7 +518,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori try { - PrepareMemoryContext(); CompressHistoryIfNeeded(); var settings = WulaFallenEmpireMod.settings; @@ -1365,7 +801,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori } ParseResponse(reply); - _ = ExtractAndUpdateMemoriesAsync(); } catch (Exception ex) { @@ -2082,8 +1517,6 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori _inputText = ""; _history.Clear(); - _memoryContextCache = ""; - _memoryContextQuery = ""; try { var historyManager = Find.World?.GetComponent(); diff --git a/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs b/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs new file mode 100644 index 00000000..20d0bc83 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs @@ -0,0 +1,549 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using UnityEngine; +using Verse; +using RimWorld; +using WulaFallenEmpire.EventSystem.AI; + +namespace WulaFallenEmpire.EventSystem.AI.UI +{ + public class Overlay_WulaLink : Window + { + // Core Connection + private AIIntelligenceCore _core; + private string _eventDefName; + private EventDef _def; + + // UI State + private Vector2 _scrollPosition = Vector2.zero; + private string _inputText = ""; + private bool _scrollToBottom = true; + + + // HUD / Minimized State + private bool _isMinimized = false; + private int _unreadCount = 0; + private Vector2 _expandedSize; + private Vector2 _minimizedSize = new Vector2(220f, 80f); + + // Layout Constants + private const float HeaderHeight = 50f; + private const float FooterHeight = 50f; + private const float AvatarSize = 40f; + private const float BubblePadding = 10f; + private const float MessageSpacing = 15f; + private const float MaxBubbleWidthRatio = 0.75f; + + public Overlay_WulaLink(EventDef def) + { + this._def = def; + this._eventDefName = def.defName; + + // Window Properties (Floating, Non-Modal) + this.layer = WindowLayer.GameUI; + this.forcePause = false; + this.absorbInputAroundWindow = false; + this.closeOnClickedOutside = false; + this.doWindowBackground = false; // We draw our own + this.drawShadow = true; + this.draggable = true; + this.resizeable = true; + this.preventCameraMotion = false; + + // Initial Size (Phone-like) + _expandedSize = new Vector2(380f, 600f); + } + + public override Vector2 InitialSize => _isMinimized ? _minimizedSize : _expandedSize; + + public void ToggleMinimize() + { + _isMinimized = !_isMinimized; + _unreadCount = 0; // Reset on toggle? Or only on expand? Let's say expand. + + if (_isMinimized) + { + // Save current position if needed, or just snap to right edge + windowRect.width = _minimizedSize.x; + windowRect.height = _minimizedSize.y; + windowRect.x = Verse.UI.screenWidth - _minimizedSize.x - 20f; + windowRect.y = Verse.UI.screenHeight / 2f; + } + else + { + windowRect.width = _expandedSize.x; + windowRect.height = _expandedSize.y; + } + } + + public void Expand() + { + if (_isMinimized) ToggleMinimize(); + // Ensure window is on screen + if (windowRect.x > Verse.UI.screenWidth - 100f) + { + windowRect.x = Verse.UI.screenWidth - _expandedSize.x - 20f; + } + Find.WindowStack.Notify_ManuallySetFocus(this); + } + + public override void PreOpen() + { + base.PreOpen(); + // Connect to Core + _core = Find.World.GetComponent(); + if (_core != null) + { + _core.InitializeConversation(_eventDefName); + _core.OnMessageReceived += OnMessageReceived; + _core.OnThinkingStateChanged += OnThinkingStateChanged; + _core.OnExpressionChanged += OnExpressionChanged; + } + } + + public override void PostClose() + { + base.PostClose(); + if (_core != null) + { + _core.OnMessageReceived -= OnMessageReceived; + _core.OnThinkingStateChanged -= OnThinkingStateChanged; + _core.OnExpressionChanged -= OnExpressionChanged; + } + } + + private void OnMessageReceived(string msg) + { + _scrollToBottom = true; + if (_isMinimized) + { + _unreadCount++; + // Spawn Notification Bubble + Find.WindowStack.Add(new Overlay_WulaLink_Notification(msg)); + } + } + + private void OnThinkingStateChanged(bool thinking) + { + // Trigger repaint or animation update if needed + } + + private void OnExpressionChanged(int id) + { + // Repaint happens next frame + } + + public override void DoWindowContents(Rect inRect) + { + if (_isMinimized) + { + DrawMinimized(inRect); + return; + } + + // Draw Main Background (Whole Window) + Widgets.DrawBoxSolid(inRect, WulaLinkStyles.BackgroundColor); + GUI.color = new Color(0.8f, 0.8f, 0.8f); + Widgets.DrawBox(inRect, 1); // Border + GUI.color = Color.white; + + // Areas + Rect headerRect = new Rect(0, 0, inRect.width, HeaderHeight); + Rect footerRect = new Rect(0, inRect.height - FooterHeight, inRect.width, FooterHeight); + Rect contextRect = new Rect(0, inRect.height - FooterHeight - 30f, inRect.width, 30f); // Context Bar + Rect bodyRect = new Rect(0, HeaderHeight, inRect.width, inRect.height - HeaderHeight - FooterHeight - 30f); + + // Draw Components + DrawHeader(headerRect); + DrawMessageList(bodyRect); + DrawContextBar(contextRect); + DrawFooter(footerRect); + } + + private void DrawMinimized(Rect rect) + { + // HUD Capsule Style + Widgets.DrawBoxSolid(rect, new Color(0.1f, 0.1f, 0.1f, 0.85f)); // Semi-transparent black + GUI.color = WulaLinkStyles.HeaderColor; + Widgets.DrawBox(rect, 2); // Thicker colored border + GUI.color = Color.white; + + // Layout + Rect titleRect = new Rect(rect.x + 10f, rect.y + 5f, rect.width - 20f, 25f); + Rect statusRect = new Rect(rect.x + 10f, rect.yMax - 30f, rect.width - 20f, 25f); + + // Title + Text.Anchor = TextAnchor.MiddleCenter; + Text.Font = GameFont.Small; + Widgets.Label(titleRect, "WULA LINK"); + + // Status + string status = _core.IsThinking ? "Thinking..." : "Standby"; + Color statusColor = _core.IsThinking ? Color.yellow : Color.green; + + GUI.color = statusColor; + Widgets.Label(statusRect, $"?{status}"); + GUI.color = Color.white; + + // Unread Badge + if (_unreadCount > 0) + { + float badgeSize = 24f; + Rect badgeRect = new Rect(rect.xMax - badgeSize - 5f, rect.y - 10f, badgeSize, badgeSize); + GUI.color = Color.red; + GUI.DrawTexture(badgeRect, BaseContent.WhiteTex); // Circle ideally + GUI.color = Color.white; + Text.Font = GameFont.Tiny; + Text.Anchor = TextAnchor.MiddleCenter; + Widgets.Label(badgeRect, _unreadCount.ToString()); + Text.Font = GameFont.Small; + } + Text.Anchor = TextAnchor.UpperLeft; + + // Click to Expand + if (Widgets.ButtonInvisible(rect)) + { + ToggleMinimize(); + } + } + + private void DrawContextBar(Rect rect) + { + // Context Awareness + Widgets.DrawBoxSolid(rect, new Color(0.15f, 0.15f, 0.15f, 1f)); + GUI.color = Color.grey; + Widgets.DrawLineHorizontal(rect.x, rect.y, rect.width); + + string contextInfo = "Context: None"; + if (Find.Selector.SingleSelectedThing != null) + { + contextInfo = $"Context: [{Find.Selector.SingleSelectedThing.LabelCap}]"; + } + else if (Find.Selector.SelectedObjects.Count > 1) + { + contextInfo = $"Context: {Find.Selector.SelectedObjects.Count} objects selected"; + } + + Text.Anchor = TextAnchor.MiddleLeft; + Text.Font = GameFont.Tiny; + Widgets.Label(new Rect(rect.x + 10f, rect.y, rect.width - 20f, rect.height), contextInfo); + Text.Anchor = TextAnchor.UpperLeft; + GUI.color = Color.white; + } + + private void DrawHeader(Rect rect) + { + // Header BG + Widgets.DrawBoxSolid(rect, WulaLinkStyles.HeaderColor); + + // Title + Text.Font = GameFont.Medium; + Text.Anchor = TextAnchor.MiddleLeft; + GUI.color = Color.white; + Rect titleRect = rect; + titleRect.x += 10f; + Widgets.Label(titleRect, _def.characterName ?? "MomoTalk"); + + // Header Icons (Minimize/Close) + Rect closeRect = new Rect(rect.width - 30f, 10f, 20f, 20f); + Rect minRect = new Rect(rect.width - 60f, 10f, 20f, 20f); + + if (Widgets.ButtonText(minRect, "-")) + { + ToggleMinimize(); + } + + if (Widgets.ButtonImage(closeRect, Widgets.CheckboxOffTex)) // Use standard X tex + { + Close(); + } + + GUI.color = Color.white; + Text.Anchor = TextAnchor.UpperLeft; + } + + private void DrawMessageList(Rect rect) + { + var history = _core?.GetHistorySnapshot(); + if (history == null) return; + + // Filter out tool messages for cleaner display (only show in DevMode) + var displayHistory = new List<(string role, string message)>(); + foreach (var msg in history) + { + // Skip tool/toolcall messages entirely in normal mode + if ((msg.role == "tool" || msg.role == "toolcall") && !Prefs.DevMode) continue; + displayHistory.Add(msg); + } + + // Setup ScrollView + float contentHeight = 0f; + float width = rect.width - 26f; // Scrollbar space (16 + margin) + float reducedSpacing = 8f; // Reduced from MessageSpacing (15f) to 8f + + List heights = new List(); + foreach (var msg in displayHistory) + { + float h = CalcMessageHeight(msg.message, width); + heights.Add(h); + contentHeight += h + reducedSpacing; + } + + if (_core.IsThinking) + { + contentHeight += 40f; // Space for thinking indicator + } + + Rect viewRect = new Rect(0, 0, width, contentHeight); + + // Handle Auto Scroll + if (_scrollToBottom) + { + _scrollPosition.y = contentHeight - rect.height; + _scrollToBottom = false; + } + + Widgets.BeginScrollView(rect, ref _scrollPosition, viewRect); + + float curY = 10f; + for (int i = 0; i < displayHistory.Count; i++) + { + var entry = displayHistory[i]; + float h = heights[i]; + Rect msgRect = new Rect(0, curY, width, h); + + if (entry.role == "user") + { + DrawSenseiMessage(msgRect, entry.message); + } + else if (entry.role == "assistant") + { + DrawStudentMessage(msgRect, entry.message); + } + else if (entry.role == "tool" || entry.role == "toolcall") + { + // Only shown in DevMode (already filtered above) + DrawSystemMessage(msgRect, $"[Tool] {entry.message}"); + } + else if (entry.role == "system") + { + DrawSystemMessage(msgRect, entry.message); + } + + curY += h + reducedSpacing; + } + + if (_core.IsThinking) + { + Rect thinkingRect = new Rect(0, curY, width, 30f); + DrawThinkingIndicator(thinkingRect); + } + + Widgets.EndScrollView(); + } + + private void DrawFooter(Rect rect) + { + Widgets.DrawBoxSolid(rect, WulaLinkStyles.InputBarColor); + Widgets.DrawLineHorizontal(rect.x, rect.y, rect.width); // Top border + + float padding = 8f; + float btnWidth = 40f; + float inputWidth = rect.width - btnWidth - (padding * 3); + + Rect inputRect = new Rect(rect.x + padding, rect.y + padding, inputWidth, rect.height - (padding * 2)); + Rect btnRect = new Rect(inputRect.xMax + padding, rect.y + padding, btnWidth, rect.height - (padding * 2)); + + // Input Field + string nextInput = Widgets.TextField(inputRect, _inputText); + if (nextInput != _inputText) + { + _inputText = nextInput; + } + + // Send Button (Simulate Enter key or Click) + bool enterPressed = (Event.current.type == EventType.KeyDown && (Event.current.keyCode == KeyCode.Return || Event.current.keyCode == KeyCode.KeypadEnter) && GUI.GetNameOfFocusedControl() == "WulaInput"); + + bool sendClicked = DrawCustomButton(btnRect, ">", !string.IsNullOrWhiteSpace(_inputText)); + if (sendClicked || enterPressed) + { + if (!string.IsNullOrWhiteSpace(_inputText)) + { + bool wasFocused = GUI.GetNameOfFocusedControl() == "WulaInput"; + _core.SendUserMessage(_inputText); + _inputText = ""; + if (wasFocused) GUI.FocusControl("WulaInput"); + } + } + + // Handle focus for Enter key to work + if (Mouse.IsOver(inputRect) && Event.current.type == EventType.MouseDown) + { + GUI.FocusControl("WulaInput"); + } + GUI.SetNextControlName("WulaInput"); + } + + // ================================================================================= + // Message Rendering Helpers + // ================================================================================= + + private float CalcMessageHeight(string text, float containerWidth) + { + float maxBubbleWidth = containerWidth * MaxBubbleWidthRatio; + Text.Font = WulaLinkStyles.MessageFont; + float textH = Text.CalcHeight(text, maxBubbleWidth - (BubblePadding * 2)); + return Mathf.Max(textH + (BubblePadding * 2), AvatarSize); + } + + private void DrawSenseiMessage(Rect rect, string text) + { + // Right aligned Blue Bubble + float maxBubbleWidth = rect.width * MaxBubbleWidthRatio; + Text.Font = WulaLinkStyles.MessageFont; + Vector2 textSize = Text.CalcSize(text); + float bubbleWidth = Mathf.Min(textSize.x + (BubblePadding * 2), maxBubbleWidth); + float bubbleHeight = rect.height; + + Rect bubbleRect = new Rect(rect.xMax - bubbleWidth - 10f, rect.y, bubbleWidth, bubbleHeight); + + // Draw Bubble + GUI.color = WulaLinkStyles.SenseiBubbleColor; + Widgets.DrawBoxSolid(bubbleRect, WulaLinkStyles.SenseiBubbleColor); // Rounded rect ideally + GUI.color = Color.white; + + // Text + Rect textRect = bubbleRect.ContractedBy(BubblePadding); + GUI.color = WulaLinkStyles.SenseiTextColor; + Text.Anchor = TextAnchor.MiddleLeft; + Widgets.Label(textRect, text); + Text.Anchor = TextAnchor.UpperLeft; + GUI.color = Color.white; + } + + private void DrawStudentMessage(Rect rect, string text) + { + // Left aligned White Bubble + Avatar + float avatarX = 10f; + Rect avatarRect = new Rect(avatarX, rect.y, AvatarSize, AvatarSize); + + // Avatar + int expId = _core?.ExpressionId ?? 1; + string portraitPath = _def.portraitPath ?? $"Wula/Events/Portraits/WULA_Legion_{expId}"; + if (expId > 1 && _def.portraitPath == null) // If using default Legion set + { + portraitPath = $"Wula/Events/Portraits/WULA_Legion_{expId}"; + } + + Texture2D portrait = ContentFinder.Get(portraitPath, false); + if (portrait != null) + { + GUI.DrawTexture(avatarRect, portrait); // Needs circle mask ideally + } + else + { + Widgets.DrawBoxSolid(avatarRect, Color.gray); + } + + float maxBubbleWidth = rect.width * MaxBubbleWidthRatio; + float bubbleX = avatarRect.xMax + 10f; + + Text.Font = WulaLinkStyles.MessageFont; + Vector2 textSize = Text.CalcSize(text); + float bubbleWidth = Mathf.Min(textSize.x + (BubblePadding * 2), maxBubbleWidth); + float bubbleHeight = rect.height; // Height was pre-calculated + + Rect bubbleRect = new Rect(bubbleX, rect.y, bubbleWidth, bubbleHeight); + + // Draw Bubble + GUI.color = WulaLinkStyles.StudentStrokeColor; + Widgets.DrawBox(bubbleRect, 1); + GUI.color = WulaLinkStyles.StudentBubbleColor; + Widgets.DrawBoxSolid(bubbleRect, WulaLinkStyles.StudentBubbleColor); + GUI.color = Color.white; + + // Text + Rect textRect = bubbleRect.ContractedBy(BubblePadding); + GUI.color = WulaLinkStyles.StudentTextColor; + Text.Anchor = TextAnchor.MiddleLeft; + Widgets.Label(textRect, text); + Text.Anchor = TextAnchor.UpperLeft; + GUI.color = Color.white; + } + + private void DrawSystemMessage(Rect rect, string text) + { + // Centered gray text or Pink box + if (text.Contains("[Tool]")) return; // Skip logic log in main view if needed, but here we draw minimal + + GUI.color = Color.gray; + Text.Font = GameFont.Tiny; + Text.Anchor = TextAnchor.MiddleCenter; + Widgets.Label(rect, text); + Text.Anchor = TextAnchor.UpperLeft; + GUI.color = Color.white; + } + + private void DrawThinkingIndicator(Rect rect) + { + Text.Anchor = TextAnchor.MiddleLeft; + GUI.color = Color.gray; + Rect iconRect = new Rect(rect.x + 60f, rect.y, 24f, 24f); + Rect labelRect = new Rect(iconRect.xMax + 5f, rect.y, 200f, 24f); + + // Draw a simple box as thinking indicator if TexUI is missing + Widgets.DrawBoxSolid(iconRect, Color.gray); + Widgets.Label(labelRect, "P.I.A is thinking..."); + + Text.Anchor = TextAnchor.UpperLeft; + GUI.color = Color.white; + } + + private bool DrawCustomButton(Rect rect, string label, bool isEnabled) + { + bool isMouseOver = Mouse.IsOver(rect); + Color originalColor = GUI.color; + TextAnchor originalAnchor = Text.Anchor; + GameFont originalFont = Text.Font; + + Color buttonColor; + Color textColor; + if (!isEnabled) + { + buttonColor = Dialog_CustomDisplay.CustomButtonDisabledColor; + textColor = Dialog_CustomDisplay.CustomButtonTextDisabledColor; + } + else if (isMouseOver) + { + buttonColor = Dialog_CustomDisplay.CustomButtonHoverColor; + textColor = Dialog_CustomDisplay.CustomButtonTextHoverColor; + } + else + { + buttonColor = Dialog_CustomDisplay.CustomButtonNormalColor; + textColor = Dialog_CustomDisplay.CustomButtonTextNormalColor; + } + + GUI.color = buttonColor; + Widgets.DrawBoxSolid(rect, buttonColor); + Widgets.DrawBox(rect, 1); + + GUI.color = textColor; + Text.Anchor = TextAnchor.MiddleCenter; + Text.Font = GameFont.Tiny; + Widgets.Label(rect.ContractedBy(4f), label); + + GUI.color = originalColor; + Text.Anchor = originalAnchor; + Text.Font = originalFont; + + if (!isEnabled) + { + return false; + } + + return Widgets.ButtonInvisible(rect); + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink_Notification.cs b/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink_Notification.cs new file mode 100644 index 00000000..301fc2fd --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink_Notification.cs @@ -0,0 +1,96 @@ +using System; +using UnityEngine; +using Verse; +using RimWorld; +using WulaFallenEmpire.EventSystem.AI; + +namespace WulaFallenEmpire.EventSystem.AI.UI +{ + public class Overlay_WulaLink_Notification : Window + { + private string _message; + private int _tickCreated; + private const int DisplayTicks = 300; // 5 seconds + + public override Vector2 InitialSize => new Vector2(300f, 80f); + + public Overlay_WulaLink_Notification(string message) + { + _message = message; + _tickCreated = Find.TickManager.TicksGame; + + // Transient properties + this.layer = WindowLayer.Super; // Topmost + this.closeOnClickedOutside = false; + this.forcePause = false; + this.absorbInputAroundWindow = false; + this.doWindowBackground = false; // Custom bg + this.drawShadow = false; + } + + public override void DoWindowContents(Rect inRect) + { + // Auto Close after time + if (Find.TickManager.TicksGame > _tickCreated + DisplayTicks) + { + Close(); + return; + } + + // Draw HUD Notification Style + Widgets.DrawBoxSolid(inRect, new Color(0.1f, 0.1f, 0.1f, 0.9f)); + GUI.color = WulaLinkStyles.SystemAccentColor; + Widgets.DrawBox(inRect, 1); + GUI.color = Color.white; + + Rect iconRect = new Rect(10f, 10f, 30f, 30f); + Rect titleRect = new Rect(50f, 10f, 200f, 20f); + Rect textRect = new Rect(50f, 30f, 240f, 40f); + + // Icon (Warning / Info) + Widgets.DrawBoxSolid(iconRect, WulaLinkStyles.SystemAccentColor); + GUI.color = Color.black; + Text.Anchor = TextAnchor.MiddleCenter; + Text.Font = GameFont.Medium; + Widgets.Label(iconRect, "!"); + GUI.color = Color.white; + + // Title + Text.Anchor = TextAnchor.UpperLeft; + Text.Font = GameFont.Tiny; + GUI.color = Color.yellow; + Widgets.Label(titleRect, "WULA LINK :: NEW ALERT"); + GUI.color = Color.white; + + // Text + Text.Font = GameFont.Small; + string truncated = _message.Length > 40 ? _message.Substring(0, 38) + "..." : _message; + Widgets.Label(textRect, truncated); + + // Click to Open/Expand + if (Widgets.ButtonInvisible(inRect)) + { + OpenWulaLink(); + Close(); + } + } + + public void OpenWulaLink() + { + // Find existing or open new + var existing = Find.WindowStack.WindowOfType(); + if (existing != null) + { + existing.Expand(); + Find.WindowStack.Notify_ManuallySetFocus(existing); + } + else + { + // Create new if not exists + var core = AIIntelligenceCore.Instance; + // Without EventDef we can't easily open. + // Assuming notification implies active core state. + } + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/UI/WulaLinkStyles.cs b/Source/WulaFallenEmpire/EventSystem/AI/UI/WulaLinkStyles.cs new file mode 100644 index 00000000..b44a40c3 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/UI/WulaLinkStyles.cs @@ -0,0 +1,53 @@ +using System; +using UnityEngine; +using Verse; + +namespace WulaFallenEmpire.EventSystem.AI.UI +{ + [StaticConstructorOnStartup] + public static class WulaLinkStyles + { + // ================================================================================= + // Colors + // ================================================================================= + + // Background Colors - Semi-transparent red theme + public static readonly Color BackgroundColor = new Color(0.2f, 0.05f, 0.05f, 0.85f); + public static readonly Color HeaderColor = new Color(0.5f, 0.2f, 0.2f, 1f); + public static readonly Color InputBarColor = new Color(0.16f, 0.08f, 0.08f, 0.95f); + public static readonly Color SystemAccentColor = new Color(0.6f, 0.2f, 0.2f, 1f); + + // Message Bubble Colors - Matching Dialog_CustomDisplay button style + public static readonly Color SenseiBubbleColor = new Color(0.5f, 0.2f, 0.2f, 1f); + public static readonly Color StudentBubbleColor = new Color(0.15f, 0.15f, 0.2f, 0.9f); + public static readonly Color StudentStrokeColor = new Color(0.6f, 0.3f, 0.3f, 1f); + + // Text Colors + public static readonly Color TextColor = new Color32(220, 220, 220, 255); + public static readonly Color SenseiTextColor = new Color32(240, 240, 240, 255); + public static readonly Color StudentTextColor = new Color32(230, 230, 230, 255); + public static readonly Color InputBorderColor = new Color32(60, 60, 60, 255); + + // ================================================================================= + // Fonts + // ================================================================================= + public static GameFont MessageFont = GameFont.Small; + public static GameFont HeaderFont = GameFont.Medium; + + // ================================================================================= + // Textures + // ================================================================================= + public static readonly Texture2D TexCircleMask; + public static readonly Texture2D TexSendIcon; + public static readonly Texture2D TexPaperClip; + public static readonly Texture2D TexWhite; + + static WulaLinkStyles() + { + TexCircleMask = ContentFinder.Get("Base/UI/WulaLink/CircleMask", false); + TexSendIcon = ContentFinder.Get("Base/UI/WulaLink/Send", false); + TexPaperClip = ContentFinder.Get("Base/UI/WulaLink/Clip", false); + TexWhite = SolidColorMaterials.NewSolidColorTexture(Color.white); + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/Effect/Effect_OpenAIConversation.cs b/Source/WulaFallenEmpire/EventSystem/Effect/Effect_OpenAIConversation.cs index c1abbf24..745103b5 100644 --- a/Source/WulaFallenEmpire/EventSystem/Effect/Effect_OpenAIConversation.cs +++ b/Source/WulaFallenEmpire/EventSystem/Effect/Effect_OpenAIConversation.cs @@ -22,7 +22,16 @@ namespace WulaFallenEmpire EventDef eventDef = DefDatabase.GetNamed(defName, false); if (eventDef != null) { - Find.WindowStack.Add(new Dialog_AIConversation(eventDef)); + var existing = Find.WindowStack.WindowOfType(); + if (existing != null) + { + existing.Expand(); + Find.WindowStack.Notify_ManuallySetFocus(existing); + } + else + { + Find.WindowStack.Add(new Overlay_WulaLink(eventDef)); + } } else { @@ -30,4 +39,4 @@ namespace WulaFallenEmpire } } } -} \ No newline at end of file +} diff --git a/Source/WulaFallenEmpire/build_log.txt b/Source/WulaFallenEmpire/build_log.txt new file mode 100644 index 00000000..7d9ee31a --- /dev/null +++ b/Source/WulaFallenEmpire/build_log.txt @@ -0,0 +1,29 @@ + ????????????????? + ?????????????????????????????????? +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(69,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(70,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenHeight????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(83,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(85,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(87,13): error CS0103: ???????????????????ringToFront??[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(334,50): warning CS1998: ???????????"await" ??????????????????????????? "await" ??????????????API ???????????"await Task.Run(...)" ?????????????????? CPU ???????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(98,24): warning CS0414: ?????IIntelligenceCore._memoryContextQuery???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(90,22): warning CS0414: ?????IIntelligenceCore._actionRetryUsed???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Dialog_AIConversation.cs(20,21): warning CS0414: ?????ialog_AIConversation._currentPortraitId???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(21,23): warning CS0414: ?????verlay_WulaLink._lastMessageHeight???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] + +???????? + +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(334,50): warning CS1998: ???????????"await" ??????????????????????????? "await" ??????????????API ???????????"await Task.Run(...)" ?????????????????? CPU ???????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(98,24): warning CS0414: ?????IIntelligenceCore._memoryContextQuery???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(90,22): warning CS0414: ?????IIntelligenceCore._actionRetryUsed???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Dialog_AIConversation.cs(20,21): warning CS0414: ?????ialog_AIConversation._currentPortraitId???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(21,23): warning CS0414: ?????verlay_WulaLink._lastMessageHeight???????????????????????[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(69,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(70,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenHeight????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(83,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(85,32): error CS0234: ????????ulaFallenEmpire.EventSystem.AI.UI??????????????????????creenWidth????????????????) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(87,13): error CS0103: ???????????????????ringToFront??[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] + 5 ????? + 5 ????? + +?????? 00:00:00.41 diff --git a/Source/WulaFallenEmpire/build_output.txt b/Source/WulaFallenEmpire/build_output.txt new file mode 100644 index 00000000..1d8c77bd Binary files /dev/null and b/Source/WulaFallenEmpire/build_output.txt differ diff --git a/Source/WulaFallenEmpire/trace.txt b/Source/WulaFallenEmpire/trace.txt new file mode 100644 index 00000000..20e46a26 --- /dev/null +++ b/Source/WulaFallenEmpire/trace.txt @@ -0,0 +1,23 @@ + 姝e湪纭畾瑕佽繕鍘熺殑椤圭洰鈥? + 鏃犲彲鎵ц鎿嶄綔銆傛寚瀹氱殑椤圭洰鍧囦笉鍖呭惈鍙繕鍘熺殑鍖呫€? +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(323,50): warning CS1998: 姝ゅ紓姝ユ柟娉曠己灏?"await" 杩愮畻绗︼紝灏嗕互鍚屾鏂瑰紡杩愯銆傝鑰冭檻浣跨敤 "await" 杩愮畻绗︾瓑寰呴潪闃绘鐨?API 璋冪敤锛屾垨鑰呬娇鐢?"await Task.Run(...)" 鍦ㄥ悗鍙扮嚎绋嬩笂鎵ц鍗犵敤澶ч噺 CPU 鐨勫伐浣溿€?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(116,34): error CS1061: 鈥淒ialog_AIConversation鈥濇湭鍖呭惈鈥淕etHistorySnapshot鈥濈殑瀹氫箟锛屽苟涓旀壘涓嶅埌鍙帴鍙楃涓€涓€淒ialog_AIConversation鈥濈被鍨嬪弬鏁扮殑鍙闂墿灞曟柟娉曗€淕etHistorySnapshot鈥?鏄惁缂哄皯 using 鎸囦护鎴栫▼搴忛泦寮曠敤?) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(117,36): error CS0019: 杩愮畻绗︹€?=鈥濇棤娉曞簲鐢ㄤ簬鈥滄柟娉曠粍鈥濆拰鈥渋nt鈥濈被鍨嬬殑鎿嶄綔鏁?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(120,26): error CS0019: 杩愮畻绗︹€?鈥濇棤娉曞簲鐢ㄤ簬鈥滄柟娉曠粍鈥濆拰鈥渋nt鈥濈被鍨嬬殑鎿嶄綔鏁?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(21,23): warning CS0414: 瀛楁鈥淥verlay_WulaLink._lastMessageHeight鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(79,22): warning CS0414: 瀛楁鈥淎IIntelligenceCore._actionRetryUsed鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(87,24): warning CS0414: 瀛楁鈥淎IIntelligenceCore._memoryContextQuery鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] + +鐢熸垚澶辫触銆? + +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(323,50): warning CS1998: 姝ゅ紓姝ユ柟娉曠己灏?"await" 杩愮畻绗︼紝灏嗕互鍚屾鏂瑰紡杩愯銆傝鑰冭檻浣跨敤 "await" 杩愮畻绗︾瓑寰呴潪闃绘鐨?API 璋冪敤锛屾垨鑰呬娇鐢?"await Task.Run(...)" 鍦ㄥ悗鍙扮嚎绋嬩笂鎵ц鍗犵敤澶ч噺 CPU 鐨勫伐浣溿€?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\UI\Overlay_WulaLink.cs(21,23): warning CS0414: 瀛楁鈥淥verlay_WulaLink._lastMessageHeight鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(79,22): warning CS0414: 瀛楁鈥淎IIntelligenceCore._actionRetryUsed鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\AIIntelligenceCore.cs(87,24): warning CS0414: 瀛楁鈥淎IIntelligenceCore._memoryContextQuery鈥濆凡琚祴鍊硷紝浣嗕粠鏈娇鐢ㄨ繃瀹冪殑鍊?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(116,34): error CS1061: 鈥淒ialog_AIConversation鈥濇湭鍖呭惈鈥淕etHistorySnapshot鈥濈殑瀹氫箟锛屽苟涓旀壘涓嶅埌鍙帴鍙楃涓€涓€淒ialog_AIConversation鈥濈被鍨嬪弬鏁扮殑鍙闂墿灞曟柟娉曗€淕etHistorySnapshot鈥?鏄惁缂哄皯 using 鎸囦护鎴栫▼搴忛泦寮曠敤?) [C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(117,36): error CS0019: 杩愮畻绗︹€?=鈥濇棤娉曞簲鐢ㄤ簬鈥滄柟娉曠粍鈥濆拰鈥渋nt鈥濈被鍨嬬殑鎿嶄綔鏁?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\EventSystem\AI\Tools\Tool_GetRecentNotifications.cs(120,26): error CS0019: 杩愮畻绗︹€?鈥濇棤娉曞簲鐢ㄤ簬鈥滄柟娉曠粍鈥濆拰鈥渋nt鈥濈被鍨嬬殑鎿嶄綔鏁?[C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj] + 4 涓鍛? + 3 涓敊璇? + +宸茬敤鏃堕棿 00:00:00.48 diff --git a/Tools/momotalk b/Tools/momotalk new file mode 160000 index 00000000..2a3e3527 --- /dev/null +++ b/Tools/momotalk @@ -0,0 +1 @@ +Subproject commit 2a3e3527389208f7ffa47b5343aecff1052f9568 diff --git a/Tools/task.md.resolved b/Tools/task.md.resolved new file mode 100644 index 00000000..569db22e --- /dev/null +++ b/Tools/task.md.resolved @@ -0,0 +1,150 @@ +# WulaLink UI 修复任务 - Codex 交接文档 + +## 项目概述 +RimWorld Mod: [WulaFallenEmpire](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire) +目标: 实现 MomoTalk 风格的悬浮 AI 聊天 UI (WulaLink),同时确保原有大 UI 不被破坏。 + +## 关键文件路径 +``` +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\ +├── EventSystem\ +│ ├── Dialog_CustomDisplay.cs # 基类,包含正确的按钮样式和布局逻辑 +│ └── AI\ +│ ├── AIIntelligenceCore.cs # AI 核心逻辑 (WorldComponent) - 已完成 +│ ├── DebugActions_WulaLink.cs # Debug 入口 +│ └── UI\ +│ ├── Dialog_AIConversation.cs # 大 UI - 需要修复布局 +│ ├── Overlay_WulaLink.cs # 小悬浮框 - 需要修复样式 +│ ├── Overlay_WulaLink_Notification.cs # 通知弹窗 - 已完成 +│ └── WulaLinkStyles.cs # 样式定义 - 需要调整颜色 +``` + +--- + +## 问题 1: 大 UI (Dialog_AIConversation) 布局损坏 + +### 现状 +[Dialog_AIConversation](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs#108-133) 继承自 [Dialog_CustomDisplay](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/Dialog_CustomDisplay.cs#10-668),但 [DoWindowContents](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs#1177-1328) 被完全重写,丢失了原有的沉浸式布局。 + +### 期望效果 (参考用户截图) +- 左侧: 大尺寸立绘 (占据约 60% 窗口) +- 右下: 半透明暗红色面板,包含: + - 标题 "「军团」,P.I.A" + - 描述文本区域 + - 底部按钮 "打开通讯频道" (使用 [DrawCustomButton](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/Dialog_CustomDisplay.cs#388-449) 样式) +- 点击按钮后进入聊天模式 (显示对话历史) + +### 修复方案 +恢复 `base.DoWindowContents(inRect)` 的调用,或手动复制 `Dialog_CustomDisplay.DoWindowContents` 的布局逻辑。 + +关键代码参考 ([Dialog_CustomDisplay.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/Dialog_CustomDisplay.cs) 第 144-280 行): +```csharp +// 1. 绘制背景 +if (background != null) GUI.DrawTexture(inRect, background, ScaleMode.StretchToFill); + +// 2. 立绘 (Config.showPortrait) +Rect portraitRect = Config.GetScaledRect(Config.portraitSize, inRect); + +// 3. 标题 (Config.showLabel) +Rect labelRect = Config.GetScaledRect(Config.labelSize, inRect); + +// 4. 描述 (Config.showDescriptions) +Rect descriptionRect = Config.GetScaledRect(Config.descriptionsSize, inRect); + +// 5. 选项按钮 (Config.showOptions) +Rect optionsRect = Config.GetScaledRect(Config.optionsListSize, inRect); +``` + +--- + +## 问题 2: 小悬浮框 (Overlay_WulaLink) 样式问题 + +### 2.1 背景颜色错误 +**现状**: 使用纯黑背景 `new Color(10, 10, 12, 255)` +**期望**: 半透明暗红色,与大 UI 一致 + +修改 [WulaLinkStyles.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/WulaLinkStyles.cs): +```csharp +// 修改前 +public static readonly Color BackgroundColor = new Color32(10, 10, 12, 255); + +// 修改后 +public static readonly Color BackgroundColor = new Color(0.2f, 0.05f, 0.05f, 0.85f); // 半透明暗红 +``` + +### 2.2 消息间距过大 +**原因**: `role == "tool"` 的消息也被渲染,占用空间但不显示内容。 + +修改 [Overlay_WulaLink.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs) 的 [DrawMessageList](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs#265-344) 方法: +```csharp +// 在 foreach 循环中添加过滤 +foreach (var msg in history) +{ + // 跳过工具调用消息 + if (msg.role == "tool") continue; + + // ... 其余渲染逻辑 +} +``` + +### 2.3 气泡无圆角 +**问题**: RimWorld 原生 `Widgets.DrawBoxSolid` 不支持圆角。 +**方案**: +- 方案 A: 使用 9-slice 圆角贴图 (`Textures/UI/BubbleRounded.png`) +- 方案 B: 接受直角,但添加边框使其更精致 + +### 2.4 头像无圆形处理 +**方案**: 使用圆形遮罩贴图覆盖在头像上,或创建 `Textures/UI/AvatarMask.png` + +### 2.5 按钮样式不一致 +**现状**: 使用默认 `Widgets.ButtonText` +**期望**: 使用 `Dialog_CustomDisplay.DrawCustomButton` 样式 + +修改 [Overlay_WulaLink.cs](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs) 的 [DrawFooter](file:///C:/Steam/steamapps/common/RimWorld/Mods/3516260226/Source/WulaFallenEmpire/EventSystem/AI/UI/Overlay_WulaLink.cs#345-385): +```csharp +// 修改前 +if (Widgets.ButtonText(btnRect, ">")) + +// 修改后 (需要静态化或复制 DrawCustomButton 方法) +DrawCustomButton(btnRect, ">", isEnabled: true); +``` + +--- + +## 问题 3: 样式统一 (WulaLinkStyles.cs) + +需要调整以下颜色以匹配大 UI 的"军团"风格: +```csharp +// Header: 深红色,与大 UI 标题栏一致 +public static readonly Color HeaderColor = new Color(0.5f, 0.15f, 0.15f, 1f); + +// 背景: 半透明暗红 +public static readonly Color BackgroundColor = new Color(0.2f, 0.05f, 0.05f, 0.85f); + +// AI 气泡: 深灰带红色边框 +public static readonly Color StudentBubbleColor = new Color(0.15f, 0.12f, 0.12f, 1f); +public static readonly Color StudentStrokeColor = new Color(0.6f, 0.2f, 0.2f, 1f); + +// 玩家气泡: 暗红色 +public static readonly Color SenseiBubbleColor = new Color(0.4f, 0.15f, 0.15f, 1f); +``` + +--- + +## 验证清单 +- [ ] 大 UI 显示正确的立绘、标题、描述和按钮布局 +- [ ] 小悬浮框背景为半透明暗红色 +- [ ] 消息之间无多余空白 +- [ ] 按钮使用暗红色自定义样式 +- [ ] (可选) 气泡有圆角 +- [ ] (可选) 头像为圆形 + +## 编译命令 +```powershell +dotnet build C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj +``` + +## 测试入口 +1. 启动 RimWorld,加载存档 +2. 打开 Debug Actions Menu +3. 搜索 "WulaLink" 并点击 "Open WulaLink UI" diff --git a/Tools/task_handoff.md b/Tools/task_handoff.md new file mode 100644 index 00000000..1b14f2d4 --- /dev/null +++ b/Tools/task_handoff.md @@ -0,0 +1,140 @@ +# WulaLink 任务交接文档 + +## 当前状态:需要创建 AIIntelligenceCore.cs + +### 背景 +WulaLink 是一个 RimWorld Mod 中的 AI 对话系统,包含两个 UI: +1. **大 UI** (`Dialog_AIConversation`) - 全屏对话窗口 +2. **小 UI** (`Overlay_WulaLink`) - 悬浮对话窗口 + +### 已完成的操作 +1. ✅ 恢复了 `Dialog_AIConversation.cs` 为旧版自包含版本(从备份文件 `Tools/using System;.cs` 复制) +2. ✅ 删除了损坏的 `AIIntelligenceCore.cs` +3. ✅ 重写了 `WulaLinkStyles.cs`(颜色主题配置) + +### 当前编译错误 +``` +error CS0246: 未能找到类型或命名空间名"AIIntelligenceCore" +``` + +以下文件引用了 `AIIntelligenceCore`: +- `Overlay_WulaLink.cs` (第13行, 第94行) +- `Overlay_WulaLink_Notification.cs` (第89行) +- `Tool_ChangeExpression.cs` (第24行) +- `Tool_GetRecentNotifications.cs` (第113行) + +--- + +## 需要完成的任务 + +### 任务:创建 AIIntelligenceCore.cs + +**路径**: `Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs` + +**要求**: +1. 必须是 `WorldComponent`,类名 `AIIntelligenceCore` +2. 提供 `static Instance` 属性供外部访问 +3. 从 `Dialog_AIConversation`(备份文件 `Tools/using System;.cs`)提取 AI 核心逻辑 +4. 暴露事件/回调供 UI 使用 + +**必须包含的公共接口**(根据现有代码引用): +```csharp +public class AIIntelligenceCore : WorldComponent +{ + // 静态实例 + public static AIIntelligenceCore Instance { get; private set; } + + // 事件回调 + public event Action OnMessageReceived; + public event Action OnThinkingStateChanged; + public event Action OnExpressionChanged; + + // 公共属性 + public int ExpressionId { get; } + public bool IsThinking { get; } + + // 公共方法 + public void InitializeConversation(string eventDefName); + public List<(string role, string message)> GetHistorySnapshot(); + public void SetExpression(int id); // 供 Tool_ChangeExpression 调用 + public void SendMessage(string text); // 供小 UI 调用 +} +``` + +**参考代码**: +- 备份文件 `Tools/using System;.cs` 包含完整的 AI 逻辑(1549行) +- 核心方法包括: + - `RunPhasedRequestAsync()` - 3阶段请求处理 + - `ExecuteXmlToolsForPhase()` - 工具执行 + - `BuildToolContext()` / `BuildReplyHistory()` - 上下文构建 + - `ParseResponse()` - 响应解析 + - `GetSystemInstruction()` / `GetToolSystemInstruction()` - 提示词生成 + +--- + +## 关键文件路径 + +``` +C:\Steam\steamapps\common\RimWorld\Mods\3516260226\ +├── Tools\ +│ └── using System;.cs # 旧版 Dialog_AIConversation 备份(包含完整 AI 逻辑) +└── Source\WulaFallenEmpire\EventSystem\AI\ + ├── AIIntelligenceCore.cs # 【需要创建】 + ├── AIHistoryManager.cs # 历史记录管理 + ├── AIMemoryManager.cs # 记忆管理 + ├── SimpleAIClient.cs # API 客户端 + ├── Tools\ # AI 工具目录 + │ ├── Tool_SpawnResources.cs + │ ├── Tool_SendReinforcement.cs + │ └── ... (其他工具) + └── UI\ + ├── Dialog_AIConversation.cs # 大 UI(已恢复) + ├── Overlay_WulaLink.cs # 小 UI(需要修复引用) + ├── Overlay_WulaLink_Notification.cs + └── WulaLinkStyles.cs # 样式配置(已重写) +``` + +--- + +## 编译命令 + +```powershell +dotnet build C:\Steam\steamapps\common\RimWorld\Mods\3516260226\Source\WulaFallenEmpire\WulaFallenEmpire.csproj +``` + +--- + +## 架构说明 + +### 目标架构 +``` +┌─────────────────────────────────────┐ +│ AIIntelligenceCore │ ← WorldComponent (核心逻辑) +│ - 历史记录管理 │ +│ - AI 请求处理 (3阶段) │ +│ - 工具执行 │ +│ - 表情/状态管理 │ +└──────────────┬──────────────────────┘ + │ 事件回调 + ┌──────────┴──────────┐ + ▼ ▼ +┌─────────────┐ ┌──────────────┐ +│ Dialog_AI │ │ Overlay_ │ +│ Conversation│ │ WulaLink │ +│ (大 UI) │ │ (小 UI) │ +└─────────────┘ └──────────────┘ +``` + +### 关键点 +1. `Dialog_AIConversation` 目前是**自包含**的(既有 UI 也有 AI 逻辑) +2. `Overlay_WulaLink` 需要通过 `AIIntelligenceCore` 获取数据 +3. 两个 UI 可以共享同一个 `AIIntelligenceCore` 实例 + +--- + +## 注意事项 + +1. **不要使用 PowerShell Get-Content 读取文件** - 会显示乱码,请使用 `view_file` 工具 +2. **备份文件编码正常** - `Tools/using System;.cs` 可以正常读取 +3. **命名空间**:`WulaFallenEmpire.EventSystem.AI` +4. **依赖项**:需要引用 `SimpleAIClient`、`AIHistoryManager`、`AITool` 等现有类 diff --git a/Tools/using System;.cs b/Tools/using System;.cs new file mode 100644 index 00000000..9d2c4ab8 --- /dev/null +++ b/Tools/using System;.cs @@ -0,0 +1,1548 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using RimWorld; +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 static readonly Regex ExpressionTagRegex = new Regex(@"\[EXPR\s*:\s*([1-6])\s*\]", RegexOptions.IgnoreCase); + private bool _lastActionExecuted = false; + private bool _lastActionHadError = false; + private string _lastActionLedgerNote = "Action Ledger: None (no in-game actions executed)."; + private bool _lastSuccessfulToolCall = false; + private string _queryToolLedgerNote = "Tool Ledger (Query): None (no successful tool calls)."; + private string _actionToolLedgerNote = "Tool Ledger (Action): None (no successful tool calls)."; + private bool _querySuccessfulToolCall = false; + private bool _actionSuccessfulToolCall = false; + private bool _queryRetryUsed = false; + private bool _actionRetryUsed = false; + private readonly List _actionSuccessLedger = new List(); + private readonly HashSet _actionSuccessLedgerSet = new HashSet(StringComparer.OrdinalIgnoreCase); + private readonly List _actionFailedLedger = new List(); + private readonly HashSet _actionFailedLedgerSet = new HashSet(StringComparer.OrdinalIgnoreCase); + private float _thinkingStartTime = 0f; + private int _thinkingPhaseIndex = 1; + private bool _thinkingPhaseRetry = false; + private const int DefaultMaxHistoryTokens = 100000; + private const int CharsPerToken = 4; + private const int ThinkingPhaseTotal = 3; + + private enum RequestPhase + { + QueryTools = 1, + ActionTools = 2, + Reply = 3 + } + + private struct PhaseExecutionResult + { + public bool AnyToolSuccess; + public bool AnyActionSuccess; + public bool AnyActionError; + } + + private void SetThinkingPhase(int phaseIndex, bool isRetry) + { + _thinkingPhaseIndex = Math.Max(1, Math.Min(ThinkingPhaseTotal, phaseIndex)); + _thinkingPhaseRetry = isRetry; + _thinkingStartTime = Time.realtimeSinceStartup; + } + + private static int GetMaxHistoryTokens() + { + int configured = WulaFallenEmpire.WulaFallenEmpireMod.settings?.maxContextTokens ?? DefaultMaxHistoryTokens; + return Math.Max(1000, Math.Min(200000, configured)); + } + + // 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 Rules (tool-agent only; phase-specific rules are appended separately) + private const string ToolRulesInstruction = @" +==== + +# 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**: + - Your output MUST be either: + - One or more XML tool calls (no extra text), OR + - Exactly: + Do NOT include any natural language, explanation, markdown, or additional commentary. +3. **MULTI-REQUEST RULE**: + - If the user requests multiple items or information, you MUST output ALL required tool calls in the SAME tool-phase response. + - Do NOT split multi-item requests across turns. +4. **TOOLS**: You MAY call any tools listed in ""# TOOLS (AVAILABLE)"". +5. **ANTI-HALLUCINATION**: Never invent tools, parameters, defNames, coordinates, or tool results. If a tool is needed but not available, use and proceed to the next phase. +"; + + 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_GetRecentNotifications()); + _tools.Add(new Tool_CallBombardment()); + _tools.Add(new Tool_SearchThingDef()); + _tools.Add(new Tool_SearchPawnKind()); + } + + public override Vector2 InitialSize => def.windowSize != Vector2.zero ? def.windowSize : Dialog_CustomDisplay.Config.windowSize; + + public override void PostOpen() + { + Instance = this; + base.PostOpen(); + LoadPortraits(); + StartConversation(); + } + + public List<(string role, string message)> GetHistorySnapshot() + { + return _history?.ToList() ?? new List<(string role, string message)>(); + } + + private void PersistHistory() + { + try + { + var historyManager = Find.World?.GetComponent(); + historyManager?.SaveHistory(def.defName, _history); + } + catch (Exception ex) + { + WulaLog.Debug($"[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 + { + WulaLog.Debug($"[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 + { + WulaLog.Debug($"[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 RunPhasedRequestAsync(); + } + else + { + var lastAIResponse = _history.LastOrDefault(x => x.role == "assistant"); + if (lastAIResponse.message != null) + { + ParseResponse(lastAIResponse.message); + } + else + { + await RunPhasedRequestAsync(); + } + } + } + + private string GetSystemInstruction(bool toolsEnabled, string toolsForThisPhase) + { + // Use XML persona if available, otherwise default + string persona = !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona; + + string fullInstruction = toolsEnabled + ? (persona + "\n" + ToolRulesInstruction + "\n" + toolsForThisPhase) + : persona; + + 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."; + + if (!toolsEnabled) + { + return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: You MUST reply in the following language: {language}.\n" + + "IMPORTANT: Tool calls are DISABLED in this turn. Reply in natural language only. Do NOT output any XML. " + + "You MAY include [EXPR:n] to set your expression (n=1-6)."; + } + + // Tool phases: avoid instructing the model to "reply" in a human language, because it must output XML only. + // We still provide the language so it can be used later in the reply phase. + return $"{fullInstruction}\n{goodwillContext}\nIMPORTANT: Output XML tool calls only (or ). " + + $"You will produce the natural-language reply later and MUST use: {language}."; + } + + private string GetToolSystemInstruction(RequestPhase phase) + { + string phaseInstruction = GetPhaseInstruction(phase).TrimEnd(); + string toolsForThisPhase = BuildToolsForPhase(phase); + string actionPriority = phase == RequestPhase.ActionTools + ? "ACTION TOOL PRIORITY:\n" + + "- spawn_resources\n" + + "- send_reinforcement\n" + + "- call_bombardment\n" + + "- modify_goodwill\n" + + "If no action is required, output exactly: .\n" + + "Query tools exist but are disabled in this phase (not listed here).\n" + : string.Empty; + string actionWhitelist = phase == RequestPhase.ActionTools + ? "ACTION PHASE VALID TAGS ONLY:\n" + + ", , , , \n" + + "INVALID EXAMPLES (do NOT use now): , , \n" + : string.Empty; + + return string.Join("\n\n", new[] + { + phaseInstruction, + string.IsNullOrWhiteSpace(actionPriority) ? null : actionPriority.TrimEnd(), + string.IsNullOrWhiteSpace(actionWhitelist) ? null : actionWhitelist.TrimEnd(), + ToolRulesInstruction.TrimEnd(), + toolsForThisPhase + }.Where(part => !string.IsNullOrWhiteSpace(part))); + } + + private string BuildToolsForPhase(RequestPhase phase) + { + if (phase == RequestPhase.Reply) return ""; + + var available = _tools + .Where(t => t != null) + .Where(t => phase == RequestPhase.QueryTools + ? IsQueryToolName(t.Name) + : phase == RequestPhase.ActionTools + ? IsActionToolName(t.Name) + : true) + .OrderBy(t => t.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + StringBuilder sb = new StringBuilder(); + sb.AppendLine("===="); + sb.AppendLine(); + sb.AppendLine("# TOOLS (AVAILABLE)"); + sb.AppendLine("Use XML tool calls only, or if no tools are needed."); + sb.AppendLine(); + + foreach (var tool in available) + { + sb.AppendLine($"## {tool.Name}"); + if (!string.IsNullOrWhiteSpace(tool.Description)) + { + sb.AppendLine($"Description: {tool.Description}"); + } + if (!string.IsNullOrWhiteSpace(tool.UsageSchema)) + { + sb.AppendLine($"Usage: {tool.UsageSchema}"); + } + sb.AppendLine(); + } + + return sb.ToString().TrimEnd(); + } + + private static string GetPhaseInstruction(RequestPhase phase) + { + return phase switch + { + RequestPhase.QueryTools => + "# PHASE 1/3 (Query Tools)\n" + + "Goal: Gather info needed for decisions.\n" + + "Rules:\n" + + "- You MUST NOT write any natural language to the user in this phase.\n" + + "- Output XML tool calls only, or exactly: .\n" + + "- Prefer query tools (get_*/search_*).\n" + + "- You MAY call multiple tools in one response, but keep it concise.\n" + + "- If the user requests multiple items or information, you MUST output ALL required tool calls in this SAME response.\n" + + "- Action tools are available in PHASE 2 only; do NOT use them here.\n" + + "After this phase, the game will automatically proceed to PHASE 2.\n" + + "Output: XML only.\n", + RequestPhase.ActionTools => + "# PHASE 2/3 (Action Tools)\n" + + "Goal: Execute in-game actions based on known info.\n" + + "Rules:\n" + + "- You MUST NOT write any natural language to the user in this phase.\n" + + "- Output XML tool calls only, or exactly: .\n" + + "- ONLY action tools are accepted in this phase (spawn_resources, send_reinforcement, call_bombardment, modify_goodwill).\n" + + "- Query tools (get_*/search_*) will be ignored.\n" + + "- Prefer action tools (spawn_resources, send_reinforcement, call_bombardment, modify_goodwill).\n" + + "- Avoid queries unless absolutely required.\n" + + "- If no action is required based on query results, output .\n" + + "- If you already executed the needed action earlier this turn, output .\n" + + "After this phase, the game will automatically proceed to PHASE 3.\n" + + "Output: XML only.\n", + RequestPhase.Reply => + "# PHASE 3/3 (Reply)\n" + + "Goal: Reply to the player.\n" + + "Rules:\n" + + "- Tool calls are DISABLED.\n" + + "- You MUST write natural language only.\n" + + "- Do NOT output any XML.\n" + + "- If you want to set your expression, include: [EXPR:n] (n=1-6).\n", + _ => "" + }; + } + + private static bool IsXmlToolCall(string response) + { + if (string.IsNullOrWhiteSpace(response)) return false; + return Regex.IsMatch(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); + } + + private static bool IsNoActionOnly(string response) + { + if (string.IsNullOrWhiteSpace(response)) return false; + var matches = Regex.Matches(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); + return matches.Count == 1 && + matches[0].Groups[1].Value.Equals("no_action", StringComparison.OrdinalIgnoreCase); + } + + private static bool HasActionToolCall(string response) + { + if (string.IsNullOrWhiteSpace(response)) return false; + var matches = Regex.Matches(response, @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); + foreach (Match match in matches) + { + var toolName = match.Groups[1].Value; + if (IsActionToolName(toolName)) + { + return true; + } + } + return false; + } + + private static bool ShouldRetryTools(string response) + { + if (string.IsNullOrWhiteSpace(response)) return false; + return Regex.IsMatch(response, @"<\s*retry_tools\s*/\s*>", RegexOptions.IgnoreCase) || + Regex.IsMatch(response, @"<\s*retry_tools\s*>", RegexOptions.IgnoreCase); + } + + private static int MaxToolsPerPhase(RequestPhase phase) + { + return phase switch + { + RequestPhase.QueryTools => 8, + RequestPhase.ActionTools => 8, + _ => 0 + }; + } + + private static bool IsActionToolName(string toolName) + { + return toolName == "spawn_resources" || + toolName == "send_reinforcement" || + toolName == "call_bombardment" || + toolName == "modify_goodwill"; + } + + private static bool IsQueryToolName(string toolName) + { + if (string.IsNullOrWhiteSpace(toolName)) return false; + return toolName.StartsWith("get_", StringComparison.OrdinalIgnoreCase) || + toolName.StartsWith("search_", StringComparison.OrdinalIgnoreCase); + } + + private static string SanitizeToolResultForActionPhase(string message) + { + if (string.IsNullOrWhiteSpace(message)) return message; + string sanitized = message; + sanitized = Regex.Replace(sanitized, @"Tool\s+'[^']+'\s+Result(?:\s+\(Invisible\))?:", "Query Result:"); + sanitized = Regex.Replace(sanitized, @"Tool\s+'[^']+'\s+Result\s+\(Invisible\):", "Query Result:"); + sanitized = Regex.Replace(sanitized, @"(?m)^ToolRunner\s+(Guidance|Guard|Note):.*(\r?\n)?", ""); + sanitized = Regex.Replace(sanitized, @"(?m)^\s+$", ""); + sanitized = sanitized.Trim(); + return sanitized; + } + + private static string TrimForPrompt(string text, int maxChars) + { + if (string.IsNullOrWhiteSpace(text)) return ""; + if (text.Length <= maxChars) return text; + return text.Substring(0, maxChars) + "...(truncated)"; + } + + private List<(string role, string message)> BuildToolContext(RequestPhase phase, int maxToolResults = 2, bool includeUser = true) + { + if (_history == null || _history.Count == 0) return new List<(string role, string message)>(); + + int lastUserIndex = -1; + for (int i = _history.Count - 1; i >= 0; i--) + { + if (string.Equals(_history[i].role, "user", StringComparison.OrdinalIgnoreCase)) + { + lastUserIndex = i; + break; + } + } + + if (lastUserIndex == -1) return new List<(string role, string message)>(); + + var toolEntries = new List<(string role, string message)>(); + for (int i = lastUserIndex + 1; i < _history.Count; i++) + { + if (string.Equals(_history[i].role, "tool", StringComparison.OrdinalIgnoreCase)) + { + string msg = _history[i].message; + if (phase == RequestPhase.ActionTools) + { + msg = SanitizeToolResultForActionPhase(msg); + } + toolEntries.Add((_history[i].role, msg)); + } + } + + if (toolEntries.Count > maxToolResults) + { + toolEntries = toolEntries.Skip(toolEntries.Count - maxToolResults).ToList(); + } + + bool includeUserFallback = includeUser || toolEntries.Count == 0; + var context = new List<(string role, string message)>(); + if (includeUserFallback) + { + context.Add(_history[lastUserIndex]); + } + context.AddRange(toolEntries); + return context; + } + + private async Task RunPhasedRequestAsync() + { + if (_isThinking) return; + _isThinking = true; + SetThinkingPhase(1, false); + _options.Clear(); + _scrollToBottom = true; + _lastActionExecuted = false; + _lastActionHadError = false; + _lastActionLedgerNote = "Action Ledger: None (no in-game actions executed)."; + _lastSuccessfulToolCall = false; + _queryToolLedgerNote = "Tool Ledger (Query): None (no successful tool calls)."; + _actionToolLedgerNote = "Tool Ledger (Action): None (no successful tool calls)."; + _querySuccessfulToolCall = false; + _actionSuccessfulToolCall = false; + _queryRetryUsed = false; + _actionRetryUsed = false; + _actionSuccessLedger.Clear(); + _actionSuccessLedgerSet.Clear(); + _actionFailedLedger.Clear(); + _actionFailedLedgerSet.Clear(); + + try + { + CompressHistoryIfNeeded(); + + var settings = WulaFallenEmpireMod.settings; + if (string.IsNullOrEmpty(settings.apiKey)) + { + _currentResponse = "Error: API Key not configured in Mod Settings."; + return; + } + + var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model); + + var queryPhase = RequestPhase.QueryTools; + if (Prefs.DevMode) + { + WulaLog.Debug($"[WulaAI] ===== Turn 1/3 ({queryPhase}) ====="); + } + + string queryInstruction = GetToolSystemInstruction(queryPhase); + string queryResponse = await client.GetChatCompletionAsync(queryInstruction, BuildToolContext(queryPhase), maxTokens: 128, temperature: 0.1f); + if (string.IsNullOrEmpty(queryResponse)) + { + _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); + return; + } + + if (!IsXmlToolCall(queryResponse)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Turn 1/3 missing XML; treating as "); + } + queryResponse = ""; + } + + PhaseExecutionResult queryResult = await ExecuteXmlToolsForPhase(queryResponse, queryPhase); + + if (!queryResult.AnyToolSuccess && !_queryRetryUsed) + { + _queryRetryUsed = true; + string lastUserMessage = _history.LastOrDefault(entry => entry.role == "user").message ?? ""; + string persona = !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona; + string retryInstruction = persona + + "\n\n# RETRY DECISION\n" + + "No successful tool calls occurred in PHASE 1 (Query).\n" + + "If you need to use tools in PHASE 1, output exactly: .\n" + + "If you will proceed without actions, output exactly: .\n" + + "Output the XML tag only and NOTHING else.\n" + + "\nLast user request:\n" + lastUserMessage; + + string retryDecision = await client.GetChatCompletionAsync(retryInstruction, new List<(string role, string message)>(), maxTokens: 16, temperature: 0.1f); + if (!string.IsNullOrEmpty(retryDecision) && ShouldRetryTools(retryDecision)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Retry requested; re-opening query phase once."); + } + + SetThinkingPhase(1, true); + string retryQueryInstruction = GetToolSystemInstruction(queryPhase) + + "\n\n# RETRY\nYou chose to retry. Output XML tool calls only (or )."; + string retryQueryResponse = await client.GetChatCompletionAsync(retryQueryInstruction, BuildToolContext(queryPhase), maxTokens: 128, temperature: 0.1f); + if (string.IsNullOrEmpty(retryQueryResponse)) + { + _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); + return; + } + + if (!IsXmlToolCall(retryQueryResponse)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Retry query phase missing XML; treating as "); + } + retryQueryResponse = ""; + } + + queryResult = await ExecuteXmlToolsForPhase(retryQueryResponse, queryPhase); + } + } + + var actionPhase = RequestPhase.ActionTools; + if (Prefs.DevMode) + { + WulaLog.Debug($"[WulaAI] ===== Turn 2/3 ({actionPhase}) ====="); + } + + SetThinkingPhase(2, false); + string actionInstruction = GetToolSystemInstruction(actionPhase); + var actionContext = BuildToolContext(actionPhase, includeUser: true); + string actionResponse = await client.GetChatCompletionAsync(actionInstruction, actionContext, maxTokens: 128, temperature: 0.1f); + if (string.IsNullOrEmpty(actionResponse)) + { + _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); + return; + } + + bool actionHasXml = IsXmlToolCall(actionResponse); + bool actionIsNoActionOnly = IsNoActionOnly(actionResponse); + bool actionHasActionTool = actionHasXml && HasActionToolCall(actionResponse); + if (!actionHasXml || (!actionHasActionTool && !actionIsNoActionOnly)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Turn 2/3 missing XML or no action tool; attempting XML-only conversion."); + } + string fixInstruction = "# FORMAT FIX (ACTION XML ONLY)\n" + + "Preserve the intent of the previous output.\n" + + "If the previous output indicates no action is needed or refuses action, output exactly: .\n" + + "Do NOT invent new actions.\n" + + "Output VALID XML tool calls only. No natural language, no commentary.\n" + + "Allowed tags: , , , , .\n" + + "\nAction tool XML formats:\n" + + "- DefNameInt\n" + + "- PawnKindDef: Count, ...\n" + + "- DefNameIntInt\n" + + "- Int\n" + + "\nPrevious output:\n" + TrimForPrompt(actionResponse, 600); + string fixedResponse = await client.GetChatCompletionAsync(fixInstruction, actionContext, maxTokens: 128, temperature: 0.1f); + bool fixedHasXml = !string.IsNullOrEmpty(fixedResponse) && IsXmlToolCall(fixedResponse); + bool fixedIsNoActionOnly = fixedHasXml && IsNoActionOnly(fixedResponse); + bool fixedHasActionTool = fixedHasXml && HasActionToolCall(fixedResponse); + if (fixedHasXml && (fixedHasActionTool || fixedIsNoActionOnly)) + { + actionResponse = fixedResponse; + } + else + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Turn 2/3 conversion failed; treating as "); + } + actionResponse = ""; + } + } + + PhaseExecutionResult actionResult = await ExecuteXmlToolsForPhase(actionResponse, actionPhase); + if (!actionResult.AnyActionSuccess && !_actionRetryUsed) + { + _actionRetryUsed = true; + string lastUserMessage = _history.LastOrDefault(entry => entry.role == "user").message ?? ""; + string persona = !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultPersona; + string retryInstruction = persona + + "\n\n# RETRY DECISION\n" + + "No successful action tools occurred in PHASE 2 (Action).\n" + + "If you need to execute an in-game action, output exactly: .\n" + + "If you will proceed without actions, output exactly: .\n" + + "Output the XML tag only and NOTHING else.\n" + + "\nLast user request:\n" + lastUserMessage; + + string retryDecision = await client.GetChatCompletionAsync(retryInstruction, new List<(string role, string message)>(), maxTokens: 16, temperature: 0.1f); + if (!string.IsNullOrEmpty(retryDecision) && ShouldRetryTools(retryDecision)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Retry requested; re-opening action phase once."); + } + + SetThinkingPhase(2, true); + string retryActionInstruction = GetToolSystemInstruction(actionPhase) + + "\n\n# RETRY\nYou chose to retry. Output XML tool calls only (or )."; + var retryActionContext = BuildToolContext(actionPhase, includeUser: true); + string retryActionResponse = await client.GetChatCompletionAsync(retryActionInstruction, retryActionContext, maxTokens: 128, temperature: 0.1f); + if (string.IsNullOrEmpty(retryActionResponse)) + { + _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); + return; + } + + if (!IsXmlToolCall(retryActionResponse)) + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Retry action phase missing XML; attempting XML-only conversion."); + } + string retryFixInstruction = "# FORMAT FIX (ACTION XML ONLY)\n" + + "Preserve the intent of the previous output.\n" + + "If the previous output indicates no action is needed or refuses action, output exactly: .\n" + + "Do NOT invent new actions.\n" + + "Output VALID XML tool calls only. No natural language, no commentary.\n" + + "Allowed tags: , , , , .\n" + + "\nAction tool XML formats:\n" + + "- DefNameInt\n" + + "- PawnKindDef: Count, ...\n" + + "- DefNameIntInt\n" + + "- Int\n" + + "\nPrevious output:\n" + TrimForPrompt(retryActionResponse, 600); + string retryFixedResponse = await client.GetChatCompletionAsync(retryFixInstruction, retryActionContext, maxTokens: 128, temperature: 0.1f); + bool retryFixedHasXml = !string.IsNullOrEmpty(retryFixedResponse) && IsXmlToolCall(retryFixedResponse); + bool retryFixedIsNoActionOnly = retryFixedHasXml && IsNoActionOnly(retryFixedResponse); + bool retryFixedHasActionTool = retryFixedHasXml && HasActionToolCall(retryFixedResponse); + if (retryFixedHasXml && (retryFixedHasActionTool || retryFixedIsNoActionOnly)) + { + retryActionResponse = retryFixedResponse; + } + else + { + if (Prefs.DevMode) + { + WulaLog.Debug("[WulaAI] Retry action conversion failed; treating as "); + } + retryActionResponse = ""; + } + } + + actionResult = await ExecuteXmlToolsForPhase(retryActionResponse, actionPhase); + } + } + + _lastSuccessfulToolCall = _querySuccessfulToolCall || _actionSuccessfulToolCall; + + var replyPhase = RequestPhase.Reply; + if (Prefs.DevMode) + { + WulaLog.Debug($"[WulaAI] ===== Turn 3/3 ({replyPhase}) ====="); + } + + SetThinkingPhase(3, false); + string replyInstruction = GetSystemInstruction(false, "") + "\n\n" + GetPhaseInstruction(replyPhase); + if (!string.IsNullOrWhiteSpace(_queryToolLedgerNote)) + { + replyInstruction += "\n" + _queryToolLedgerNote; + } + if (!string.IsNullOrWhiteSpace(_actionToolLedgerNote)) + { + replyInstruction += "\n" + _actionToolLedgerNote; + } + if (!string.IsNullOrWhiteSpace(_lastActionLedgerNote)) + { + replyInstruction += "\n" + _lastActionLedgerNote + + "\nIMPORTANT: Do NOT claim any in-game actions beyond the Action Ledger. If the ledger is None, you MUST NOT claim any deliveries, reinforcements, or bombardments."; + } + if (_lastActionExecuted) + { + replyInstruction += "\nIMPORTANT: Actions in the Action Ledger were executed in-game. You MUST acknowledge them as completed in your reply. You MUST NOT deny, retract, or contradict them."; + } + if (!_lastSuccessfulToolCall) + { + replyInstruction += "\nIMPORTANT: No successful tool calls occurred in the tool phases. You MUST NOT claim any tools or actions succeeded."; + } + if (_lastActionHadError) + { + replyInstruction += "\nIMPORTANT: An action tool failed. You MUST acknowledge the failure and MUST NOT claim success."; + if (_lastActionExecuted) + { + replyInstruction += " You MUST still confirm any successful actions separately."; + } + } + + string reply = await client.GetChatCompletionAsync(replyInstruction, BuildReplyHistory()); + if (string.IsNullOrEmpty(reply)) + { + _currentResponse = "Wula_AI_Error_ConnectionLost".Translate(); + return; + } + + bool replyHadXml = IsXmlToolCall(reply); + string strippedReply = StripXmlTags(reply)?.Trim() ?? ""; + if (replyHadXml || string.IsNullOrWhiteSpace(strippedReply)) + { + string retryReplyInstruction = replyInstruction + + "\n\n# RETRY (REPLY OUTPUT)\n" + + "Your last reply included XML or was empty. Tool calls are DISABLED.\n" + + "You MUST reply in natural language only. Do NOT output any XML.\n"; + string retryReply = await client.GetChatCompletionAsync(retryReplyInstruction, BuildReplyHistory(), maxTokens: 256, temperature: 0.3f); + if (!string.IsNullOrEmpty(retryReply)) + { + reply = retryReply; + replyHadXml = IsXmlToolCall(reply); + strippedReply = StripXmlTags(reply)?.Trim() ?? ""; + } + } + + if (replyHadXml) + { + string cleaned = StripXmlTags(reply)?.Trim() ?? ""; + if (string.IsNullOrWhiteSpace(cleaned)) + { + cleaned = "(系统)AI 返回了工具调用(XML),已被拦截。请重试或输入 /clear 清空上下文。"; + } + reply = cleaned; + } + + ParseResponse(reply); + } + catch (Exception ex) + { + WulaLog.Debug($"[WulaAI] Exception in RunPhasedRequestAsync: {ex}"); + _currentResponse = "Wula_AI_Error_Internal".Translate(ex.Message); + } + finally + { + _isThinking = false; + } + } + + private async Task ExecuteXmlToolsForPhase(string xml, RequestPhase phase) + { + if (phase == RequestPhase.Reply) + { + await Task.CompletedTask; + return default; + } + + string guidance = "ToolRunner Guidance: Reply to the player in natural language only. Do NOT output any XML. You may include [EXPR:n] to set expression (n=1-6)."; + + var matches = Regex.Matches(xml ?? "", @"<([a-zA-Z0-9_]+)(?:>.*?|/>)", RegexOptions.Singleline); + if (matches.Count == 0) + { + UpdatePhaseToolLedger(phase, false, new List()); + _history.Add(("assistant", "")); + _history.Add(("tool", $"[Tool Results]\nTool 'no_action' Result: No action taken.\n{guidance}")); + PersistHistory(); + UpdateActionLedgerNote(); + return default; + } + if (matches.Count == 1 && matches[0].Groups[1].Value.Equals("no_action", StringComparison.OrdinalIgnoreCase)) + { + UpdatePhaseToolLedger(phase, false, new List()); + _history.Add(("assistant", "")); + _history.Add(("tool", $"[Tool Results]\nTool 'no_action' Result: No action taken.\n{guidance}")); + PersistHistory(); + UpdateActionLedgerNote(); + return default; + } + + int maxTools = MaxToolsPerPhase(phase); + int executed = 0; + bool executedActionTool = false; + bool successfulToolCall = false; + var successfulTools = new List(); + var successfulActions = new List(); + var failedActions = new List(); + var nonActionToolsInActionPhase = new List(); + StringBuilder combinedResults = new StringBuilder(); + StringBuilder xmlOnlyBuilder = new StringBuilder(); + + bool countActionSuccessOnly = phase == RequestPhase.ActionTools; + + foreach (Match match in matches) + { + if (executed >= maxTools) + { + combinedResults.AppendLine($"ToolRunner Note: Skipped remaining tools because this phase allows at most {maxTools} tool call(s)."); + break; + } + + string toolCallXml = match.Value; + string toolName = match.Groups[1].Value; + + if (toolName.Equals("no_action", StringComparison.OrdinalIgnoreCase)) + { + combinedResults.AppendLine("ToolRunner Note: Ignored because other tool calls were present."); + continue; + } + + if (xmlOnlyBuilder.Length > 0) xmlOnlyBuilder.AppendLine().AppendLine(); + xmlOnlyBuilder.Append(toolCallXml); + + if (phase == RequestPhase.ActionTools && IsQueryToolName(toolName)) + { + combinedResults.AppendLine($"ToolRunner Note: Ignored query tool in action phase: {toolName}."); + nonActionToolsInActionPhase.Add(toolName); + executed++; + continue; + } + + var tool = _tools.FirstOrDefault(t => t.Name == toolName); + if (tool == null) + { + combinedResults.AppendLine($"Error: Tool '{toolName}' not found."); + combinedResults.AppendLine("ToolRunner Guard: The tool call failed. In your reply you MUST acknowledge the failure and MUST NOT claim success."); + executed++; + continue; + } + + string argsXml = toolCallXml; + var contentMatch = Regex.Match(toolCallXml, $@"<{toolName}>(.*?)", RegexOptions.Singleline); + if (contentMatch.Success) + { + argsXml = contentMatch.Groups[1].Value; + } + + if (Prefs.DevMode) + { + WulaLog.Debug($"[WulaAI] Executing tool (phase {phase}): {toolName} with args: {argsXml}"); + } + + string result = tool.Execute(argsXml).Trim(); + bool isError = !string.IsNullOrEmpty(result) && result.StartsWith("Error:", StringComparison.OrdinalIgnoreCase); + if (toolName == "modify_goodwill") + { + combinedResults.AppendLine($"Tool '{toolName}' Result (Invisible): {result}"); + } + else + { + combinedResults.AppendLine($"Tool '{toolName}' Result: {result}"); + } + if (isError) + { + combinedResults.AppendLine("ToolRunner Guard: The tool returned an error. In your reply you MUST acknowledge the failure and MUST NOT claim success."); + } + if (!isError) + { + bool countsAsSuccess = !countActionSuccessOnly || IsActionToolName(toolName); + if (countsAsSuccess) + { + successfulToolCall = true; + successfulTools.Add(toolName); + } + else + { + nonActionToolsInActionPhase.Add(toolName); + } + } + if (IsActionToolName(toolName)) + { + if (!isError) + { + executedActionTool = true; + successfulActions.Add(toolName); + AddActionSuccess(toolName); + } + else + { + failedActions.Add(toolName); + AddActionFailure(toolName); + } + } + + executed++; + } + + string nonXmlText = StripXmlTags(xml); + if (!string.IsNullOrWhiteSpace(nonXmlText)) + { + combinedResults.AppendLine("ToolRunner Note: Non-XML text in the tool phase was ignored."); + } + if (phase == RequestPhase.ActionTools && nonActionToolsInActionPhase.Count > 0) + { + combinedResults.AppendLine($"ToolRunner Note: Action phase ignores non-action tools for success: {string.Join(", ", nonActionToolsInActionPhase)}."); + } + if (executedActionTool) + { + combinedResults.AppendLine("ToolRunner Guard: An in-game action tool WAS executed this turn. You MAY reference it, but do NOT invent additional actions."); + } + else + { + combinedResults.AppendLine("ToolRunner Guard: NO in-game actions were executed. You MUST NOT claim any deliveries, reinforcements, bombardments, or other actions occurred."); + if (phase == RequestPhase.ActionTools) + { + combinedResults.AppendLine("ToolRunner Guard: Action phase failed (no action tools executed)."); + } + } + combinedResults.AppendLine(guidance); + + string xmlOnly = xmlOnlyBuilder.Length == 0 ? "" : xmlOnlyBuilder.ToString().Trim(); + _history.Add(("toolcall", xmlOnly)); + _history.Add(("tool", $"[Tool Results]\n{combinedResults.ToString().Trim()}")); + PersistHistory(); + + UpdatePhaseToolLedger(phase, successfulToolCall, successfulTools); + UpdateActionLedgerNote(); + + // Between phases, do not request the model again here; RunPhasedRequestAsync controls the sequence. + await Task.CompletedTask; + return new PhaseExecutionResult + { + AnyToolSuccess = successfulToolCall, + AnyActionSuccess = successfulActions.Count > 0, + AnyActionError = failedActions.Count > 0 + }; + } + + private void AddActionSuccess(string toolName) + { + if (_actionSuccessLedgerSet.Add(toolName)) + { + _actionSuccessLedger.Add(toolName); + } + } + + private void AddActionFailure(string toolName) + { + if (_actionFailedLedgerSet.Add(toolName)) + { + _actionFailedLedger.Add(toolName); + } + } + + private void UpdateActionLedgerNote() + { + _lastActionExecuted = _actionSuccessLedger.Count > 0; + _lastActionHadError = _actionFailedLedger.Count > 0; + if (_lastActionExecuted) + { + _lastActionLedgerNote = $"Action Ledger: {string.Join(", ", _actionSuccessLedger)}"; + } + else if (_lastActionHadError) + { + _lastActionLedgerNote = $"Action Ledger: None (no successful actions). Failed: {string.Join(", ", _actionFailedLedger)}"; + } + else + { + _lastActionLedgerNote = "Action Ledger: None (no in-game actions executed)."; + } + } + + private void UpdatePhaseToolLedger(RequestPhase phase, bool hasSuccess, List successfulTools) + { + if (phase == RequestPhase.QueryTools) + { + _querySuccessfulToolCall = hasSuccess; + _queryToolLedgerNote = hasSuccess + ? $"Tool Ledger (Query): {string.Join(", ", successfulTools)}" + : "Tool Ledger (Query): None (no successful tool calls)."; + } + else if (phase == RequestPhase.ActionTools) + { + _actionSuccessfulToolCall = hasSuccess; + _actionToolLedgerNote = hasSuccess + ? $"Tool Ledger (Action): {string.Join(", ", successfulTools)}" + : "Tool Ledger (Action): None (no successful tool calls)."; + } + } + + private void CompressHistoryIfNeeded() + { + int estimatedTokens = _history.Sum(h => h.message?.Length ?? 0) / CharsPerToken; + if (estimatedTokens > GetMaxHistoryTokens()) + { + int removeCount = _history.Count / 2; + if (removeCount > 0) + { + _history.RemoveRange(0, removeCount); + _history.Insert(0, ("system", "[Previous conversation summarized]")); + PersistHistory(); + } + } + } + + private static string StripXmlTags(string text) + { + if (string.IsNullOrEmpty(text)) return text; + string stripped = Regex.Replace(text, @"<([a-zA-Z0-9_]+)[^>]*>.*?", "", RegexOptions.Singleline); + stripped = Regex.Replace(stripped, @"<([a-zA-Z0-9_]+)[^>]*/>", ""); + return stripped; + } + + private List<(string role, string message)> BuildReplyHistory() + { + if (_history == null || _history.Count == 0) return new List<(string role, string message)>(); + + int lastUserIndex = -1; + for (int i = _history.Count - 1; i >= 0; i--) + { + if (string.Equals(_history[i].role, "user", StringComparison.OrdinalIgnoreCase)) + { + lastUserIndex = i; + break; + } + } + + var filtered = new List<(string role, string message)>(); + for (int i = 0; i < _history.Count; i++) + { + var entry = _history[i]; + if (string.Equals(entry.role, "tool", StringComparison.OrdinalIgnoreCase)) + { + if (lastUserIndex != -1 && i > lastUserIndex) + { + filtered.Add(entry); + } + continue; + } + + if (!string.Equals(entry.role, "assistant", StringComparison.OrdinalIgnoreCase)) + { + filtered.Add(entry); + continue; + } + + string stripped = StripXmlTags(entry.message)?.Trim() ?? ""; + if (!string.IsNullOrWhiteSpace(stripped)) + { + filtered.Add(entry); + } + } + + return filtered; + } + + private string StripExpressionTags(string text) + { + if (string.IsNullOrEmpty(text)) return text; + + var matches = ExpressionTagRegex.Matches(text); + int exprId = 0; + foreach (Match match in matches) + { + if (int.TryParse(match.Groups[1].Value, out int id)) + { + exprId = id; + } + } + + if (exprId >= 1 && exprId <= 6) + { + SetPortrait(exprId); + } + + return matches.Count > 0 ? ExpressionTagRegex.Replace(text, "").Trim() : text; + } + + private void ParseResponse(string rawResponse, bool addToHistory = true) + { + string cleanedResponse = StripExpressionTags(rawResponse ?? ""); + _currentResponse = cleanedResponse; + var parts = cleanedResponse.Split(new[] { "OPTIONS:" }, StringSplitOptions.None); + if (addToHistory) + { + if (_history.Count == 0 || _history.Last().role != "assistant") + { + _history.Add(("assistant", cleanedResponse)); + PersistHistory(); + } + else if (_history.Last().message != cleanedResponse) + { + if (_history.Last().message == rawResponse) + { + _history[_history.Count - 1] = ("assistant", cleanedResponse); + } + else + { + _history.Add(("assistant", cleanedResponse)); + } + PersistHistory(); + } + } + + if (!string.IsNullOrEmpty(ParseResponseForDisplay(cleanedResponse))) + { + _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); + + if (Prefs.DevMode) + { + // 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, BuildThinkingStatus()); + 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" && e.role != "toolcall").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 = ExpressionTagRegex.Replace(text, ""); + + text = text.Trim(); + + return text.Split(new[] { "OPTIONS:" }, StringSplitOptions.None)[0].Trim(); + } + + private string BuildThinkingStatus() + { + float elapsedSeconds = Mathf.Max(0f, Time.realtimeSinceStartup - _thinkingStartTime); + string elapsedText = elapsedSeconds.ToString("0.0", CultureInfo.InvariantCulture); + string retrySuffix = _thinkingPhaseRetry ? "Wula_AI_Thinking_RetrySuffix".Translate() : ""; + return "Wula_AI_Thinking_Status".Translate(elapsedText, _thinkingPhaseIndex, ThinkingPhaseTotal, retrySuffix); + } + + 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) + { + if (!string.IsNullOrWhiteSpace(text) && string.Equals(text.Trim(), "/clear", StringComparison.OrdinalIgnoreCase)) + { + _isThinking = false; + _options.Clear(); + _inputText = ""; + + _history.Clear(); + try + { + var historyManager = Find.World?.GetComponent(); + historyManager?.ClearHistory(def.defName); + } + catch (Exception ex) + { + WulaLog.Debug($"[WulaAI] Failed to clear AI history: {ex}"); + } + + Messages.Message("已清除 AI 对话上下文历史。", MessageTypeDefOf.NeutralEvent); + return; + } + + _history.Add(("user", text)); + PersistHistory(); + _scrollToBottom = true; + await RunPhasedRequestAsync(); + } + + public override void PostClose() + { + if (Instance == this) Instance = null; + PersistHistory(); + base.PostClose(); + HandleAction(def.dismissEffects); + } + } +}