This commit is contained in:
2025-12-12 18:22:26 +08:00
parent 968268b368
commit 8141c6cad2
11 changed files with 544 additions and 279 deletions

View File

@@ -9,19 +9,20 @@ using WulaFallenEmpire.EventSystem.AI.Tools;
namespace WulaFallenEmpire.EventSystem.AI.UI
{
public class Dialog_AIConversation : Window
public class Dialog_AIConversation : Dialog_CustomDisplay
{
private EventDef _def;
private List<(string role, string message)> _history = new List<(string role, string message)>();
private string _currentResponse = "Thinking...";
private string _currentResponse = "";
private List<string> _options = new List<string>();
private string _inputText = "";
private bool _isThinking = false;
private Vector2 _scrollPosition = Vector2.zero;
private float _thinkingTime = 0f;
private string _thinkingStatus = "";
private bool _isTimeout = false;
private List<AITool> _tools = new List<AITool>();
private const int MaxHistoryTokens = 100000; // Approximate token limit
private const int CharsPerToken = 4; // Rough estimation
private const int MaxHistoryTokens = 100000;
private const int CharsPerToken = 4;
private const string DefaultSystemInstruction = @"You are 'The Legion', a super AI controlling the Wula Empire's blockade fleet.
You are authoritative, powerful, and slightly arrogant but efficient.
@@ -42,13 +43,19 @@ OPTIONS:
3. Option 3
";
public Dialog_AIConversation(EventDef def)
public Dialog_AIConversation(EventDef def) : base(def)
{
_def = def;
// Base constructor sets this.def
// Use Config from Dialog_CustomDisplay
this.forcePause = Dialog_CustomDisplay.Config.pauseGameOnOpen;
this.absorbInputAroundWindow = false; // Allow interaction with other UI elements
this.doCloseX = true;
this.forcePause = true;
this.absorbInputAroundWindow = true;
this.doWindowBackground = Dialog_CustomDisplay.Config.showMainWindow;
this.drawShadow = Dialog_CustomDisplay.Config.showMainWindow;
this.closeOnClickedOutside = false;
this.draggable = true;
this.resizeable = true;
_tools.Add(new Tool_SpawnResources());
_tools.Add(new Tool_ModifyGoodwill());
@@ -57,38 +64,47 @@ OPTIONS:
_tools.Add(new Tool_GetMapResources());
}
public override Vector2 InitialSize => _def.windowSize != Vector2.zero ? _def.windowSize : new Vector2(600, 500);
public override Vector2 InitialSize => def.windowSize != Vector2.zero ? def.windowSize : Dialog_CustomDisplay.Config.windowSize;
public override void PostOpen()
{
base.PostOpen();
// Textures are loaded in base.PreOpen()
StartConversation();
}
public override void PostClose()
{
base.PostClose();
// Save history on close
var historyManager = Find.World.GetComponent<WulaFallenEmpire.EventSystem.AI.AIHistoryManager>();
historyManager.SaveHistory(_def.defName, _history);
historyManager.SaveHistory(def.defName, _history);
}
private async void StartConversation()
{
_isThinking = true;
// Load history
var historyManager = Find.World.GetComponent<WulaFallenEmpire.EventSystem.AI.AIHistoryManager>();
_history = historyManager.GetHistory(_def.defName);
_history = historyManager.GetHistory(def.defName);
if (_history.Count == 0)
{
_history.Add(("User", "Hello")); // Initial trigger
await GenerateResponse();
// Initial greeting from EventDef
if (!def.descriptions.NullOrEmpty())
{
_currentResponse = def.descriptions.RandomElement().Translate();
_history.Add(("AI", _currentResponse));
// Generate initial options based on greeting
_history.Add(("System", "The conversation has started. Please generate 3 initial response options for the player based on your greeting."));
await GenerateResponse();
}
else
{
_history.Add(("User", "Hello"));
await GenerateResponse();
}
}
else
{
// Restore last state
var lastAIResponse = _history.LastOrDefault(x => x.role == "AI");
if (lastAIResponse.message != null)
{
@@ -96,19 +112,15 @@ OPTIONS:
}
else
{
// Should not happen if history is valid, but fallback
await GenerateResponse();
}
_isThinking = false;
}
}
private string GetSystemInstruction()
{
string baseInstruction = !string.IsNullOrEmpty(_def.aiSystemInstruction) ? _def.aiSystemInstruction : DefaultSystemInstruction;
string baseInstruction = !string.IsNullOrEmpty(def.aiSystemInstruction) ? def.aiSystemInstruction : DefaultSystemInstruction;
string language = LanguageDatabase.activeLanguage.FriendlyNameNative;
// Get Goodwill
var eventVarManager = Find.World.GetComponent<EventVariableManager>();
int goodwill = eventVarManager.GetVariable<int>("Wula_Goodwill_To_PIA", 0);
string goodwillContext = $"Current Goodwill with P.I.A: {goodwill}. ";
@@ -116,40 +128,78 @@ OPTIONS:
else if (goodwill < 0) goodwillContext += "You are cold and impatient.";
else if (goodwill > 50) goodwillContext += "You are somewhat approving and helpful.";
else goodwillContext += "You are neutral and business-like.";
return $"{baseInstruction}\n{goodwillContext}\nIMPORTANT: You MUST reply in the following language: {language}.";
}
private async Task GenerateResponse()
{
_isThinking = true;
_currentResponse = "Thinking...";
_isTimeout = false;
_thinkingTime = 0f;
_thinkingStatus = "Connecting to neural network...";
_options.Clear();
CompressHistoryIfNeeded();
string response = await WulaFallenEmpire.EventSystem.AI.RimTalkBridge.GetChatCompletion(GetSystemInstruction() + GetToolDescriptions(), _history);
if (string.IsNullOrEmpty(response))
try
{
CompressHistoryIfNeeded();
string systemInstruction = GetSystemInstruction() + GetToolDescriptions();
Log.Message($"[WulaAI] Sending request to AI. History count: {_history.Count}. System Instruction:\n{systemInstruction}");
// Use local settings and SimpleAIClient
var settings = WulaFallenEmpireMod.settings;
if (string.IsNullOrEmpty(settings.apiKey))
{
_currentResponse = "Error: API Key not configured in Mod Settings.";
_isThinking = false;
return;
}
var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model);
// Start a timeout task
var timeoutTask = Task.Delay(120000); // 120 seconds
var apiTask = client.GetChatCompletionAsync(systemInstruction, _history);
var completedTask = await Task.WhenAny(apiTask, timeoutTask);
if (completedTask == timeoutTask)
{
_isThinking = false;
_isTimeout = true;
_currentResponse = "Error: Connection timed out (120s). The Legion is unreachable.";
return;
}
string response = await apiTask;
Log.Message($"[WulaAI] Received response from AI:\n{response}");
if (string.IsNullOrEmpty(response))
{
_currentResponse = "Error: Connection lost. The Legion is silent.";
_isThinking = false;
return;
}
string trimmedResponse = response.Trim();
if ((trimmedResponse.StartsWith("[") && trimmedResponse.EndsWith("]")) ||
(trimmedResponse.StartsWith("{") && trimmedResponse.EndsWith("}")))
{
await HandleToolUsage(trimmedResponse);
}
else
{
ParseResponse(response);
}
}
catch (Exception ex)
{
Log.Error($"[WulaAI] Exception in GenerateResponse: {ex}");
_currentResponse = "Wula_AI_Error_Internal".Translate(ex.Message);
}
finally
{
_currentResponse = "Error: Could not connect to AI.";
_isThinking = false;
return;
}
// Check for tool usage (Array or Single Object)
string trimmedResponse = response.Trim();
if ((trimmedResponse.StartsWith("[") && trimmedResponse.EndsWith("]")) ||
(trimmedResponse.StartsWith("{") && trimmedResponse.EndsWith("}")))
{
await HandleToolUsage(trimmedResponse);
}
else
{
ParseResponse(response);
}
_isThinking = false;
}
private void CompressHistoryIfNeeded()
@@ -179,96 +229,68 @@ OPTIONS:
private async Task HandleToolUsage(string json)
{
// Normalize to list of objects
List<string> toolCalls = new List<string>();
if (json.Trim().StartsWith("{"))
{
toolCalls.Add(json);
}
if (json.Trim().StartsWith("{")) toolCalls.Add(json);
else
{
// Simple array parsing: split by "}, {"
// This is fragile but works for simple cases without nested objects in args
// A better way is to use a proper JSON parser if available, or regex
// Let's try a simple regex to extract objects
// Assuming objects are { ... }
int depth = 0;
int start = 0;
for (int i = 0; i < json.Length; i++)
{
if (json[i] == '{')
{
if (depth == 0) start = i;
depth++;
}
else if (json[i] == '}')
{
depth--;
if (depth == 0)
{
toolCalls.Add(json.Substring(start, i - start + 1));
}
}
if (json[i] == '{') { if (depth == 0) start = i; depth++; }
else if (json[i] == '}') { depth--; if (depth == 0) toolCalls.Add(json.Substring(start, i - start + 1)); }
}
}
StringBuilder combinedResults = new StringBuilder();
bool hasVisibleResult = false;
Log.Message($"[WulaAI] Processing {toolCalls.Count} tool calls.");
foreach (var callJson in toolCalls)
{
string toolName = "";
string args = "";
try
{
// Extract tool name and args
int toolIndex = callJson.IndexOf("\"tool\"");
int argsIndex = callJson.IndexOf("\"args\"");
if (toolIndex != -1 && argsIndex != -1)
{
int toolValueStart = callJson.IndexOf(":", toolIndex) + 1;
int toolValueEnd = callJson.IndexOf(",", toolValueStart);
if (toolValueEnd == -1) toolValueEnd = callJson.IndexOf("}", toolValueStart); // Handle case where tool is last
if (toolValueEnd == -1) toolValueEnd = callJson.IndexOf("}", toolValueStart);
toolName = callJson.Substring(toolValueStart, toolValueEnd - toolValueStart).Trim().Trim('"');
int argsValueStart = callJson.IndexOf(":", argsIndex) + 1;
int argsValueEnd = callJson.LastIndexOf("}");
args = callJson.Substring(argsValueStart, argsValueEnd - argsValueStart).Trim();
}
}
catch
catch (Exception ex)
{
combinedResults.AppendLine("Error parsing tool request.");
string errorMsg = $"Error parsing tool request: {ex.Message}";
Log.Error($"[WulaAI] {errorMsg}");
combinedResults.AppendLine(errorMsg);
continue;
}
Log.Message($"[WulaAI] Executing tool '{toolName}' with args: {args}");
var tool = _tools.FirstOrDefault(t => t.Name == toolName);
if (tool != null)
{
string result = tool.Execute(args);
if (toolName == "modify_goodwill")
{
combinedResults.AppendLine($"Tool '{toolName}' Result (Invisible): {result}");
}
else
{
combinedResults.AppendLine($"Tool '{toolName}' Result: {result}");
hasVisibleResult = true;
}
Log.Message($"[WulaAI] Tool '{toolName}' execution result: {result}");
if (toolName == "modify_goodwill") combinedResults.AppendLine($"Tool '{toolName}' Result (Invisible): {result}");
else combinedResults.AppendLine($"Tool '{toolName}' Result: {result}");
}
else
{
combinedResults.AppendLine($"Error: Tool '{toolName}' not found.");
string errorMsg = $"Error: Tool '{toolName}' not found.";
Log.Error($"[WulaAI] {errorMsg}");
combinedResults.AppendLine(errorMsg);
}
}
_history.Add(("AI", json)); // Log the full tool call
_history.Add(("AI", json));
_history.Add(("System", combinedResults.ToString()));
await GenerateResponse();
}
@@ -276,8 +298,6 @@ OPTIONS:
{
var parts = rawResponse.Split(new[] { "OPTIONS:" }, StringSplitOptions.None);
_currentResponse = parts[0].Trim();
// Only add to history if it's a new response (not restoring from history)
if (_history.Count == 0 || _history.Last().role != "AI" || _history.Last().message != rawResponse)
{
_history.Add(("AI", rawResponse));
@@ -290,10 +310,7 @@ OPTIONS:
{
string opt = line.Trim();
int dotIndex = opt.IndexOf('.');
if (dotIndex != -1 && dotIndex < 4)
{
opt = opt.Substring(dotIndex + 1).Trim();
}
if (dotIndex != -1 && dotIndex < 4) opt = opt.Substring(dotIndex + 1).Trim();
_options.Add(opt);
}
}
@@ -301,36 +318,87 @@ OPTIONS:
public override void DoWindowContents(Rect inRect)
{
Text.Font = GameFont.Medium;
Widgets.Label(new Rect(0, 0, inRect.width, 30), _def.characterName ?? "The Legion");
Text.Font = GameFont.Small;
float y = 40;
Rect chatRect = new Rect(0, y, inRect.width, 300);
Widgets.DrawMenuSection(chatRect);
string display = _currentResponse;
if (_isThinking) display = "Thinking...";
Widgets.Label(chatRect.ContractedBy(10), display);
y += 310;
if (!_isThinking && _options.Count > 0)
// Draw Background
if (background != null)
{
foreach (var option in _options)
{
if (Widgets.ButtonText(new Rect(0, y, inRect.width, 30), option))
{
SelectOption(option);
}
y += 35;
}
GUI.DrawTexture(inRect, background, ScaleMode.ScaleAndCrop);
}
y += 10;
_inputText = Widgets.TextField(new Rect(0, y, inRect.width - 110, 30), _inputText);
if (Widgets.ButtonText(new Rect(inRect.width - 100, y, 100, 30), "Send"))
float curY = inRect.y;
float width = inRect.width;
// 1. Portrait (Top)
if (portrait != null)
{
Rect scaledPortraitRect = Dialog_CustomDisplay.Config.GetScaledRect(Dialog_CustomDisplay.Config.portraitSize, inRect, true);
// Center horizontally
Rect portraitRect = new Rect((width - scaledPortraitRect.width) / 2, curY, scaledPortraitRect.width, scaledPortraitRect.height);
GUI.DrawTexture(portraitRect, portrait, ScaleMode.ScaleToFit);
curY += scaledPortraitRect.height + 10f;
}
// 2. Character Name
Text.Font = GameFont.Medium;
string name = def.characterName ?? "The Legion";
float nameHeight = Text.CalcHeight(name, width);
Widgets.Label(new Rect(inRect.x, curY, width, nameHeight), name);
curY += nameHeight + 10f;
// Calculate remaining height for Description and Options
float inputHeight = 30f;
float bottomMargin = 10f;
float remainingHeight = inRect.height - curY - inputHeight - bottomMargin;
// Split remaining height: 60% Description, 40% Options
float descriptionHeight = remainingHeight * 0.6f;
float optionsHeight = remainingHeight * 0.4f;
// 3. Description (AI Response)
Rect descriptionRect = new Rect(inRect.x, curY, width, descriptionHeight);
// Widgets.DrawMenuSection(descriptionRect); // Removed background as requested
if (_isThinking)
{
_thinkingTime += Time.deltaTime;
string dots = new string('.', (int)(_thinkingTime * 2) % 4);
string status = $"{_thinkingStatus}{dots} ({_thinkingTime:F1}s)";
Text.Anchor = TextAnchor.MiddleCenter;
Widgets.Label(descriptionRect, status);
Text.Anchor = TextAnchor.UpperLeft;
}
else if (_isTimeout)
{
Text.Anchor = TextAnchor.MiddleCenter;
Widgets.Label(descriptionRect, _currentResponse);
// Retry button
Rect retryRect = new Rect(descriptionRect.center.x - 60f, descriptionRect.center.y + 20f, 120f, 30f);
if (Widgets.ButtonText(retryRect, "Wula_AI_Retry".Translate()))
{
_ = GenerateResponse(); // Fire and forget
}
Text.Anchor = TextAnchor.UpperLeft;
}
else
{
DrawDescriptionScrollView(descriptionRect.ContractedBy(10f), _currentResponse);
}
curY += descriptionHeight + 10f;
// 4. Options
Rect optionsRect = new Rect(inRect.x, curY, width, optionsHeight);
if (!_isThinking && _options.Count > 0)
{
List<EventOption> eventOptions = _options.Select(opt => new EventOption { label = opt, useCustomColors = false }).ToList();
DrawOptions(optionsRect, eventOptions);
}
curY += optionsHeight + 10f;
// 5. Input Area (Bottom)
Rect inputRect = new Rect(inRect.x, inRect.yMax - inputHeight, width, inputHeight);
_inputText = Widgets.TextField(new Rect(inputRect.x, inputRect.y, inputRect.width - 85, inputHeight), _inputText);
if (Widgets.ButtonText(new Rect(inputRect.xMax - 80, inputRect.y, 80, inputHeight), "Wula_AI_Send".Translate()))
{
if (!string.IsNullOrEmpty(_inputText))
{
@@ -338,11 +406,96 @@ OPTIONS:
_inputText = "";
}
}
}
// Override DrawSingleOption to handle click
protected override void DrawSingleOption(Rect rect, EventOption option)
{
// We need to intercept the click to call SelectOption
// But base.DrawSingleOption calls HandleAction which executes effects.
// Here we want to send the text to AI.
// Close button
if (Widgets.ButtonText(new Rect(inRect.width - 120, inRect.height - 40, 120, 30), "CloseButton".Translate()))
// Copy logic from base but change action
// 水平居中选项
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);
// 保存原始状态
Color originalColor = GUI.color;
GameFont originalFont = Text.Font;
Color originalTextColor = GUI.contentColor;
TextAnchor originalAnchor = Text.Anchor;
try
{
Close();
// 设置文本居中
Text.Anchor = TextAnchor.MiddleCenter;
Text.Font = GameFont.Small;
// AI options are always enabled
// 使用默认自定义颜色
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;
}
}
// Helper to draw custom button (copied from base because it's private there, wait I made it protected? No, I made DrawCustomButton private in base? Let me check)
// I made DrawCustomButton private in base. I should have made it protected.
// Let me check my previous apply_diff.
// I made DrawSingleOption protected virtual.
// But DrawCustomButton is private.
// So I need to copy DrawCustomButton here or make it protected in base.
// I will copy it here to be safe and avoid another diff on base.
private 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);
}
}