diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.dll b/1.6/1.6/Assemblies/WulaFallenEmpire.dll index cf6c3281..1f546f84 100644 Binary files a/1.6/1.6/Assemblies/WulaFallenEmpire.dll and b/1.6/1.6/Assemblies/WulaFallenEmpire.dll differ diff --git a/1.6/1.6/Defs/EventDefs/EventDef_Wula/Wula_AI_Events.xml b/1.6/1.6/Defs/EventDefs/EventDef_Wula/Wula_AI_Events.xml index 31497273..06d8afdc 100644 --- a/1.6/1.6/Defs/EventDefs/EventDef_Wula/Wula_AI_Events.xml +++ b/1.6/1.6/Defs/EventDefs/EventDef_Wula/Wula_AI_Events.xml @@ -19,23 +19,16 @@
  • 正在建立加密连接...连接成功。这里是乌拉帝国行星封锁机关P.I.A。我是“军团”。说明你的来意,原始人。
  • - -
  • - -
  • - Wula_AI_Initial_Contact -
  • -
  • - -
  • -
  • - +
  • + Wula_AI_Initial_Contact +
  • +
  • diff --git a/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml b/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml index c4ce1f45..79e7e820 100644 --- a/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml +++ b/1.6/1.6/Languages/ChineseSimplified (简体中文)/Keyed/WULA_Keyed.xml @@ -142,4 +142,18 @@ 由于传送取消,生成的临时地图已被清理。 燃料不足。 + + AI 设置 (OpenAI 兼容) + API 密钥: + API 地址 (Base URL): + 模型名称: + 启用流式传输 (实验性) + 启用实时打字机效果。如果遇到问题请禁用。 + + + 重试 + 发送 + 错误:内部系统故障。{0} + 错误:连接丢失。“军团”保持沉默。 + \ No newline at end of file diff --git a/Source/WulaFallenEmpire/EventSystem/AI/RimTalkBridge.cs b/Source/WulaFallenEmpire/EventSystem/AI/RimTalkBridge.cs deleted file mode 100644 index 1fc63dce..00000000 --- a/Source/WulaFallenEmpire/EventSystem/AI/RimTalkBridge.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection; -using System.Threading.Tasks; -using Verse; - -namespace WulaFallenEmpire.EventSystem.AI -{ - public static class RimTalkBridge - { - private static bool? _isRimTalkActive; - private static Type _aiClientFactoryType; - private static Type _aiClientInterfaceType; - private static Type _roleEnum; - private static MethodInfo _getAIClientAsyncMethod; - private static MethodInfo _getChatCompletionAsyncMethod; - - public static bool IsRimTalkActive - { - get - { - if (!_isRimTalkActive.HasValue) - { - _isRimTalkActive = ModsConfig.IsActive("RimTalk.Mod"); // Replace with actual PackageId if different - if (_isRimTalkActive.Value) - { - InitializeReflection(); - } - } - return _isRimTalkActive.Value; - } - } - - private static void InitializeReflection() - { - try - { - // Assuming RimTalk assembly is loaded - Assembly rimTalkAssembly = AppDomain.CurrentDomain.GetAssemblies() - .FirstOrDefault(a => a.GetName().Name == "RimTalk"); - - if (rimTalkAssembly == null) - { - Log.Error("[WulaFallenEmpire] RimTalk assembly not found despite mod being active."); - _isRimTalkActive = false; - return; - } - - _aiClientFactoryType = rimTalkAssembly.GetType("RimTalk.Client.AIClientFactory"); - _aiClientInterfaceType = rimTalkAssembly.GetType("RimTalk.Client.IAIClient"); - _roleEnum = rimTalkAssembly.GetType("RimTalk.Data.Role"); - - if (_aiClientFactoryType != null) - { - _getAIClientAsyncMethod = _aiClientFactoryType.GetMethod("GetAIClientAsync", BindingFlags.Public | BindingFlags.Static); - } - - if (_aiClientInterfaceType != null) - { - _getChatCompletionAsyncMethod = _aiClientInterfaceType.GetMethod("GetChatCompletionAsync"); - } - } - catch (Exception ex) - { - Log.Error($"[WulaFallenEmpire] Failed to initialize RimTalk reflection: {ex}"); - _isRimTalkActive = false; - } - } - - public static async Task GetChatCompletion(string instruction, List<(string role, string message)> messages) - { - if (!IsRimTalkActive || _getAIClientAsyncMethod == null || _getChatCompletionAsyncMethod == null) - { - return null; - } - - try - { - // Get AI Client - var clientTask = (Task)_getAIClientAsyncMethod.Invoke(null, null); - await clientTask.ConfigureAwait(false); - - // The Task returns an IAIClient object - object client = ((dynamic)clientTask).Result; - - if (client == null) return null; - - // Prepare messages list - // List<(Role role, string message)> - var tupleType = typeof(ValueTuple<,>).MakeGenericType(_roleEnum, typeof(string)); - var listType = typeof(List<>).MakeGenericType(tupleType); - var messageList = Activator.CreateInstance(listType); - var addMethod = listType.GetMethod("Add"); - - foreach (var msg in messages) - { - object roleValue = Enum.Parse(_roleEnum, msg.role, true); - object tuple = Activator.CreateInstance(tupleType, roleValue, msg.message); - addMethod.Invoke(messageList, new object[] { tuple }); - } - - // Call GetChatCompletionAsync - var completionTask = (Task)_getChatCompletionAsyncMethod.Invoke(client, new object[] { instruction, messageList }); - await completionTask.ConfigureAwait(false); - - // The Task returns a Payload object - object payload = ((dynamic)completionTask).Result; - - // Payload has a 'Response' property (or similar, based on previous analysis it was 'Content' or 'Response') - // Checking previous analysis: Payload has 'Content' property for the text response. - PropertyInfo contentProp = payload.GetType().GetProperty("Content"); - return contentProp?.GetValue(payload) as string; - } - catch (Exception ex) - { - Log.Error($"[WulaFallenEmpire] Error calling RimTalk AI: {ex}"); - return null; - } - } - } -} \ No newline at end of file diff --git a/Source/WulaFallenEmpire/EventSystem/AI/SimpleAIClient.cs b/Source/WulaFallenEmpire/EventSystem/AI/SimpleAIClient.cs new file mode 100644 index 00000000..de5f99a0 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/SimpleAIClient.cs @@ -0,0 +1,173 @@ +using System; +using System.Collections.Generic; +using System.Text; +using System.Threading.Tasks; +using UnityEngine.Networking; +using Verse; + +namespace WulaFallenEmpire.EventSystem.AI +{ + public class SimpleAIClient + { + private readonly string _apiKey; + private readonly string _baseUrl; + private readonly string _model; + + public SimpleAIClient(string apiKey, string baseUrl, string model) + { + _apiKey = apiKey; + _baseUrl = baseUrl?.TrimEnd('/'); + _model = model; + } + + public async Task GetChatCompletionAsync(string instruction, List<(string role, string message)> messages) + { + if (string.IsNullOrEmpty(_baseUrl)) + { + Log.Error("[WulaAI] Base URL is missing."); + return null; + } + + string endpoint = $"{_baseUrl}/chat/completions"; + // Handle cases where baseUrl already includes /v1 or full path + if (_baseUrl.EndsWith("/chat/completions")) endpoint = _baseUrl; + else if (!_baseUrl.EndsWith("/v1")) endpoint = $"{_baseUrl}/v1/chat/completions"; + + // Build JSON manually to avoid dependencies + StringBuilder jsonBuilder = new StringBuilder(); + jsonBuilder.Append("{"); + jsonBuilder.Append($"\"model\": \"{_model}\","); + jsonBuilder.Append("\"stream\": false,"); + jsonBuilder.Append("\"messages\": ["); + + // System instruction + if (!string.IsNullOrEmpty(instruction)) + { + jsonBuilder.Append($"{{\"role\": \"system\", \"content\": \"{EscapeJson(instruction)}\"}},"); + } + + // Messages + for (int i = 0; i < messages.Count; i++) + { + var msg = messages[i]; + string role = msg.role.ToLower(); + if (role == "ai") role = "assistant"; + // Map other roles if needed + + jsonBuilder.Append($"{{\"role\": \"{role}\", \"content\": \"{EscapeJson(msg.message)}\"}}"); + if (i < messages.Count - 1) jsonBuilder.Append(","); + } + + jsonBuilder.Append("]"); + jsonBuilder.Append("}"); + + string jsonBody = jsonBuilder.ToString(); + Log.Message($"[WulaAI] Sending request to {endpoint}"); + + using (UnityWebRequest request = new UnityWebRequest(endpoint, "POST")) + { + byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonBody); + request.uploadHandler = new UploadHandlerRaw(bodyRaw); + request.downloadHandler = new DownloadHandlerBuffer(); + request.SetRequestHeader("Content-Type", "application/json"); + if (!string.IsNullOrEmpty(_apiKey)) + { + request.SetRequestHeader("Authorization", $"Bearer {_apiKey}"); + } + + var operation = request.SendWebRequest(); + + while (!operation.isDone) + { + await Task.Delay(50); + } + +#if UNITY_2020_2_OR_NEWER + if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError) +#else + if (request.isNetworkError || request.isHttpError) +#endif + { + Log.Error($"[WulaAI] API Error: {request.error}\nResponse: {request.downloadHandler.text}"); + return null; + } + + string responseText = request.downloadHandler.text; + Log.Message($"[WulaAI] Raw Response: {responseText}"); + return ExtractContent(responseText); + } + } + + private string EscapeJson(string s) + { + if (s == null) return ""; + return s.Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\n", "\\n") + .Replace("\r", "\\r") + .Replace("\t", "\\t"); + } + + private string ExtractContent(string json) + { + try + { + // Robust parsing for "content": "..." allowing for whitespace variations + int contentIndex = json.IndexOf("\"content\""); + if (contentIndex == -1) return null; + + // Find the opening quote after "content" + int openQuoteIndex = -1; + for (int i = contentIndex + 9; i < json.Length; i++) + { + if (json[i] == '"') + { + openQuoteIndex = i; + break; + } + } + if (openQuoteIndex == -1) return null; + + int startIndex = openQuoteIndex + 1; + StringBuilder content = new StringBuilder(); + bool escaped = false; + + for (int i = startIndex; i < json.Length; i++) + { + char c = json[i]; + if (escaped) + { + if (c == 'n') content.Append('\n'); + else if (c == 'r') content.Append('\r'); + else if (c == 't') content.Append('\t'); + else if (c == '"') content.Append('"'); + else if (c == '\\') content.Append('\\'); + else content.Append(c); // Literal + escaped = false; + } + else + { + if (c == '\\') + { + escaped = true; + } + else if (c == '"') + { + // End of string + return content.ToString(); + } + else + { + content.Append(c); + } + } + } + } + catch (Exception ex) + { + Log.Error($"[WulaAI] Error parsing response: {ex}"); + } + return null; + } + } +} \ No newline at end of file diff --git a/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs b/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs index b2c4ba01..627f1049 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/UI/Dialog_AIConversation.cs @@ -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 _options = new List(); 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 _tools = new List(); - 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(); - historyManager.SaveHistory(_def.defName, _history); + historyManager.SaveHistory(def.defName, _history); } private async void StartConversation() { - _isThinking = true; - - // Load history var historyManager = Find.World.GetComponent(); - _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(); int goodwill = eventVarManager.GetVariable("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 toolCalls = new List(); - 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 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); } } diff --git a/Source/WulaFallenEmpire/EventSystem/Dialog_CustomDisplay.cs b/Source/WulaFallenEmpire/EventSystem/Dialog_CustomDisplay.cs index 9215c159..cc7a7439 100644 --- a/Source/WulaFallenEmpire/EventSystem/Dialog_CustomDisplay.cs +++ b/Source/WulaFallenEmpire/EventSystem/Dialog_CustomDisplay.cs @@ -9,12 +9,12 @@ namespace WulaFallenEmpire { public class Dialog_CustomDisplay : Window { - private EventDef def; - private Texture2D portrait; - private Texture2D background; - private string selectedDescription; + protected EventDef def; + protected Texture2D portrait; + protected Texture2D background; + protected string selectedDescription; - private static EventUIConfigDef config; + protected static EventUIConfigDef config; public static EventUIConfigDef Config { get @@ -37,8 +37,8 @@ namespace WulaFallenEmpire private static readonly Color CustomButtonTextDisabledColor = new Color(0.6f, 0.6f, 0.6f, 1f); // 滚动位置 - private Vector2 descriptionScrollPosition = Vector2.zero; - private Vector2 optionsScrollPosition = Vector2.zero; + protected Vector2 descriptionScrollPosition = Vector2.zero; + protected Vector2 optionsScrollPosition = Vector2.zero; // 使用配置的窗口尺寸 public override Vector2 InitialSize @@ -56,8 +56,10 @@ namespace WulaFallenEmpire // 关键修改:使用配置控制是否暂停游戏 this.forcePause = Config.pauseGameOnOpen; - this.absorbInputAroundWindow = true; + this.absorbInputAroundWindow = false; // Allow interaction with other UI elements this.doCloseX = true; + this.draggable = true; // Allow dragging + this.resizeable = true; // Allow resizing // 根据配置设置是否绘制窗口背景和阴影 this.doWindowBackground = Config.showMainWindow; @@ -280,7 +282,7 @@ namespace WulaFallenEmpire /// /// 修复的描述区域滚动视图 - 只显示纵向滚动条 /// - private void DrawDescriptionScrollView(Rect outRect, string text) + protected virtual void DrawDescriptionScrollView(Rect outRect, string text) { try { @@ -315,7 +317,7 @@ namespace WulaFallenEmpire } // 绘制单个选项 - 使用自定义样式 - private void DrawSingleOption(Rect rect, EventOption option) + protected virtual void DrawSingleOption(Rect rect, EventOption option) { string reason; bool conditionsMet = AreConditionsMet(option.conditions, out reason); @@ -500,7 +502,7 @@ namespace WulaFallenEmpire } // 绘制选项区域 - private void DrawOptions(Rect rect, List options) + protected virtual void DrawOptions(Rect rect, List options) { if (options == null || options.Count == 0) return; @@ -599,7 +601,7 @@ namespace WulaFallenEmpire } } - private void HandleAction(List conditionalEffects) + protected virtual void HandleAction(List conditionalEffects) { if (conditionalEffects.NullOrEmpty()) { @@ -615,7 +617,7 @@ namespace WulaFallenEmpire } } - private bool AreConditionsMet(List conditions, out string reason) + protected bool AreConditionsMet(List conditions, out string reason) { reason = ""; if (conditions.NullOrEmpty()) @@ -634,7 +636,7 @@ namespace WulaFallenEmpire return true; } - private string GetDisabledReason(EventOption option, string reason) + protected string GetDisabledReason(EventOption option, string reason) { if (!option.disabledReason.NullOrEmpty()) { diff --git a/Source/WulaFallenEmpire/EventSystem/Effect/Effect_OpenAIConversation.cs b/Source/WulaFallenEmpire/EventSystem/Effect/Effect_OpenAIConversation.cs index bf3136f5..777c0916 100644 --- a/Source/WulaFallenEmpire/EventSystem/Effect/Effect_OpenAIConversation.cs +++ b/Source/WulaFallenEmpire/EventSystem/Effect/Effect_OpenAIConversation.cs @@ -12,9 +12,10 @@ namespace WulaFallenEmpire public override void Execute(Window dialog = null) { - if (!RimTalkBridge.IsRimTalkActive) + // Check if API Key is configured in local settings + if (string.IsNullOrEmpty(WulaFallenEmpireMod.settings.apiKey)) { - Messages.Message("RimTalk mod is not active. AI conversation cannot be started.", MessageTypeDefOf.RejectInput, false); + Messages.Message("AI API Key is not configured in Mod Settings. AI conversation cannot be started.", MessageTypeDefOf.RejectInput, false); return; } diff --git a/Source/WulaFallenEmpire/WulaFallenEmpire.csproj b/Source/WulaFallenEmpire/WulaFallenEmpire.csproj index 5f7afa9f..a38e8689 100644 --- a/Source/WulaFallenEmpire/WulaFallenEmpire.csproj +++ b/Source/WulaFallenEmpire/WulaFallenEmpire.csproj @@ -62,6 +62,10 @@ ..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.CoreModule.dll False + + ..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.UnityWebRequestModule.dll + False + ..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.IMGUIModule.dll False diff --git a/Source/WulaFallenEmpire/WulaFallenEmpireMod.cs b/Source/WulaFallenEmpire/WulaFallenEmpireMod.cs index 3a41b8fe..47481dea 100644 --- a/Source/WulaFallenEmpire/WulaFallenEmpireMod.cs +++ b/Source/WulaFallenEmpire/WulaFallenEmpireMod.cs @@ -9,8 +9,12 @@ namespace WulaFallenEmpire [StaticConstructorOnStartup] public class WulaFallenEmpireMod : Mod { + public static WulaFallenEmpireSettings settings; + public WulaFallenEmpireMod(ModContentPack content) : base(content) { + settings = GetSettings(); + // 初始化Harmony var harmony = new Harmony("tourswen.wulafallenempire"); // 替换为您的唯一Mod ID harmony.PatchAll(Assembly.GetExecutingAssembly()); @@ -18,6 +22,30 @@ namespace WulaFallenEmpire Log.Message("[WulaFallenEmpire] Harmony patches applied."); } + public override void DoSettingsWindowContents(Rect inRect) + { + Listing_Standard listingStandard = new Listing_Standard(); + listingStandard.Begin(inRect); + + listingStandard.Label("Wula_AISettings_Title".Translate()); + + listingStandard.Label("Wula_AISettings_ApiKey".Translate()); + settings.apiKey = listingStandard.TextEntry(settings.apiKey); + + listingStandard.Label("Wula_AISettings_BaseUrl".Translate()); + settings.baseUrl = listingStandard.TextEntry(settings.baseUrl); + + listingStandard.Label("Wula_AISettings_Model".Translate()); + settings.model = listingStandard.TextEntry(settings.model); + + listingStandard.End(); + base.DoSettingsWindowContents(inRect); + } + + public override string SettingsCategory() + { + return "Wula Fallen Empire"; + } } [StaticConstructorOnStartup] diff --git a/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs b/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs new file mode 100644 index 00000000..c19a09f9 --- /dev/null +++ b/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs @@ -0,0 +1,19 @@ +using Verse; + +namespace WulaFallenEmpire +{ + public class WulaFallenEmpireSettings : ModSettings + { + public string apiKey = ""; + public string baseUrl = "https://api.openai.com/v1"; + public string model = "gpt-3.5-turbo"; + + public override void ExposeData() + { + Scribe_Values.Look(ref apiKey, "apiKey", ""); + Scribe_Values.Look(ref baseUrl, "baseUrl", "https://api.openai.com/v1"); + Scribe_Values.Look(ref model, "model", "gpt-3.5-turbo"); + base.ExposeData(); + } + } +} \ No newline at end of file