This commit is contained in:
2025-12-12 20:15:24 +08:00
parent 8141c6cad2
commit a12fccc06e
9 changed files with 380 additions and 373 deletions

View File

@@ -155,5 +155,6 @@
<Wula_AI_Send>发送</Wula_AI_Send> <Wula_AI_Send>发送</Wula_AI_Send>
<Wula_AI_Error_Internal>错误:内部系统故障。{0}</Wula_AI_Error_Internal> <Wula_AI_Error_Internal>错误:内部系统故障。{0}</Wula_AI_Error_Internal>
<Wula_AI_Error_ConnectionLost>错误:连接丢失。“军团”保持沉默。</Wula_AI_Error_ConnectionLost> <Wula_AI_Error_ConnectionLost>错误:连接丢失。“军团”保持沉默。</Wula_AI_Error_ConnectionLost>
<Wula_ResourceDrop>{FACTION_name} 已经在附近投下了一些资源。</Wula_ResourceDrop>
</LanguageData> </LanguageData>

View File

@@ -10,7 +10,7 @@ namespace WulaFallenEmpire.EventSystem.AI
public class AIHistoryManager : WorldComponent public class AIHistoryManager : WorldComponent
{ {
private string _saveId; private string _saveId;
private Dictionary<string, List<(string role, string message)>> _cache = new Dictionary<string, List<(string role, string message)>>(); private Dictionary<string, List<ApiMessage>> _cache = new Dictionary<string, List<ApiMessage>>();
public AIHistoryManager(World world) : base(world) public AIHistoryManager(World world) : base(world)
{ {
@@ -35,7 +35,7 @@ namespace WulaFallenEmpire.EventSystem.AI
return Path.Combine(GetSaveDirectory(), $"{_saveId}_{eventDefName}.json"); return Path.Combine(GetSaveDirectory(), $"{_saveId}_{eventDefName}.json");
} }
public List<(string role, string message)> GetHistory(string eventDefName) public List<ApiMessage> GetHistory(string eventDefName)
{ {
if (_cache.TryGetValue(eventDefName, out var cachedHistory)) if (_cache.TryGetValue(eventDefName, out var cachedHistory))
{ {
@@ -61,10 +61,10 @@ namespace WulaFallenEmpire.EventSystem.AI
} }
} }
return new List<(string role, string message)>(); return new List<ApiMessage>();
} }
public void SaveHistory(string eventDefName, List<(string role, string message)> history) public void SaveHistory(string eventDefName, List<ApiMessage> history)
{ {
_cache[eventDefName] = history; _cache[eventDefName] = history;
string path = GetFilePath(eventDefName); string path = GetFilePath(eventDefName);
@@ -93,7 +93,7 @@ namespace WulaFallenEmpire.EventSystem.AI
public static class SimpleJsonParser public static class SimpleJsonParser
{ {
public static string Serialize(List<(string role, string message)> history) public static string Serialize(List<ApiMessage> history)
{ {
StringBuilder sb = new StringBuilder(); StringBuilder sb = new StringBuilder();
sb.Append("["); sb.Append("[");
@@ -102,7 +102,8 @@ namespace WulaFallenEmpire.EventSystem.AI
var item = history[i]; var item = history[i];
sb.Append("{"); sb.Append("{");
sb.Append($"\"role\":\"{Escape(item.role)}\","); sb.Append($"\"role\":\"{Escape(item.role)}\",");
sb.Append($"\"message\":\"{Escape(item.message)}\""); sb.Append($"\"content\":\"{Escape(item.content)}\"");
// Note: tool_calls are not serialized for history to keep it simple.
sb.Append("}"); sb.Append("}");
if (i < history.Count - 1) sb.Append(","); if (i < history.Count - 1) sb.Append(",");
} }
@@ -110,13 +111,11 @@ namespace WulaFallenEmpire.EventSystem.AI
return sb.ToString(); return sb.ToString();
} }
public static List<(string role, string message)> Deserialize(string json) public static List<ApiMessage> Deserialize(string json)
{ {
var result = new List<(string role, string message)>(); var result = new List<ApiMessage>();
if (string.IsNullOrEmpty(json)) return result; if (string.IsNullOrEmpty(json)) return result;
// Very basic parser, assumes standard format produced by Serialize
// Remove outer brackets
json = json.Trim(); json = json.Trim();
if (json.StartsWith("[") && json.EndsWith("]")) if (json.StartsWith("[") && json.EndsWith("]"))
{ {
@@ -125,10 +124,6 @@ namespace WulaFallenEmpire.EventSystem.AI
if (string.IsNullOrEmpty(json)) return result; if (string.IsNullOrEmpty(json)) return result;
// Split by objects
// This is fragile if objects contain nested objects or escaped braces, but for this specific structure it's fine
// We are splitting by "},{" which is risky. Better to iterate.
int depth = 0; int depth = 0;
int start = 0; int start = 0;
for (int i = 0; i < json.Length; i++) for (int i = 0; i < json.Length; i++)
@@ -153,16 +148,10 @@ namespace WulaFallenEmpire.EventSystem.AI
return result; return result;
} }
private static (string role, string message) ParseObject(string json) public static Dictionary<string, string> Parse(string json)
{ {
string role = null; var dict = new Dictionary<string, string>();
string message = null;
// Remove braces
json = json.Trim('{', '}'); json = json.Trim('{', '}');
// Split by key-value pairs, respecting quotes
// Again, simple parsing
var parts = SplitByComma(json); var parts = SplitByComma(json);
foreach (var part in parts) foreach (var part in parts)
{ {
@@ -171,17 +160,48 @@ namespace WulaFallenEmpire.EventSystem.AI
{ {
string key = Unescape(kv[0].Trim().Trim('"')); string key = Unescape(kv[0].Trim().Trim('"'));
string val = Unescape(kv[1].Trim().Trim('"')); string val = Unescape(kv[1].Trim().Trim('"'));
if (key == "role") role = val; dict[key] = val;
if (key == "message") message = val;
} }
} }
return dict;
}
return (role, message); public static List<Dictionary<string, string>> ParseArray(string json)
{
var list = new List<Dictionary<string, string>>();
json = json.Trim('[', ']');
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)
{
list.Add(Parse(json.Substring(start, i - start + 1)));
}
}
}
return list;
}
private static ApiMessage ParseObject(string json)
{
var msg = new ApiMessage();
var dict = Parse(json);
if (dict.TryGetValue("role", out string r)) msg.role = r;
if (dict.TryGetValue("content", out string c)) msg.content = c;
return msg;
} }
private static string[] SplitByComma(string input) private static string[] SplitByComma(string input)
{ {
// Split by comma but ignore commas inside quotes
var list = new List<string>(); var list = new List<string>();
bool inQuote = false; bool inQuote = false;
int start = 0; int start = 0;
@@ -200,7 +220,6 @@ namespace WulaFallenEmpire.EventSystem.AI
private static string[] SplitByColon(string input) private static string[] SplitByColon(string input)
{ {
// Split by first colon outside quotes
bool inQuote = false; bool inQuote = false;
for (int i = 0; i < input.Length; i++) for (int i = 0; i < input.Length; i++)
{ {
@@ -219,7 +238,7 @@ namespace WulaFallenEmpire.EventSystem.AI
return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r"); return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r");
} }
private static string Unescape(string s) public static string Unescape(string s) // Changed to public
{ {
if (s == null) return ""; if (s == null) return "";
return s.Replace("\\r", "\r").Replace("\\n", "\n").Replace("\\\"", "\"").Replace("\\\\", "\\"); return s.Replace("\\r", "\r").Replace("\\n", "\n").Replace("\\\"", "\"").Replace("\\\\", "\\");

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
using UnityEngine.Networking; using UnityEngine.Networking;
@@ -20,7 +21,7 @@ namespace WulaFallenEmpire.EventSystem.AI
_model = model; _model = model;
} }
public async Task<string> GetChatCompletionAsync(string instruction, List<(string role, string message)> messages) public async Task<ApiResponse> GetChatCompletionAsync(string instruction, List<ApiMessage> messages)
{ {
if (string.IsNullOrEmpty(_baseUrl)) if (string.IsNullOrEmpty(_baseUrl))
{ {
@@ -29,32 +30,56 @@ namespace WulaFallenEmpire.EventSystem.AI
} }
string endpoint = $"{_baseUrl}/chat/completions"; string endpoint = $"{_baseUrl}/chat/completions";
// Handle cases where baseUrl already includes /v1 or full path
if (_baseUrl.EndsWith("/chat/completions")) endpoint = _baseUrl; if (_baseUrl.EndsWith("/chat/completions")) endpoint = _baseUrl;
else if (!_baseUrl.EndsWith("/v1")) endpoint = $"{_baseUrl}/v1/chat/completions"; else if (!_baseUrl.EndsWith("/v1")) endpoint = $"{_baseUrl}/v1/chat/completions";
// Build JSON manually to avoid dependencies
StringBuilder jsonBuilder = new StringBuilder(); StringBuilder jsonBuilder = new StringBuilder();
jsonBuilder.Append("{"); jsonBuilder.Append("{");
jsonBuilder.Append($"\"model\": \"{_model}\","); jsonBuilder.Append($"\"model\": \"{_model}\",");
jsonBuilder.Append("\"stream\": false,"); jsonBuilder.Append("\"stream\": false,");
jsonBuilder.Append("\"messages\": ["); jsonBuilder.Append("\"messages\": [");
// System instruction
if (!string.IsNullOrEmpty(instruction)) if (!string.IsNullOrEmpty(instruction))
{ {
jsonBuilder.Append($"{{\"role\": \"system\", \"content\": \"{EscapeJson(instruction)}\"}},"); jsonBuilder.Append($"{{\"role\": \"system\", \"content\": \"{EscapeJson(instruction)}\"}},");
} }
// Messages
for (int i = 0; i < messages.Count; i++) for (int i = 0; i < messages.Count; i++)
{ {
var msg = messages[i]; var msg = messages[i];
string role = msg.role.ToLower(); string role = msg.role.ToLower();
if (role == "ai") role = "assistant"; if (role == "ai") role = "assistant";
// Map other roles if needed
jsonBuilder.Append($"{{\"role\": \"{role}\", \"content\": \"{EscapeJson(msg.message)}\"}}"); jsonBuilder.Append("{");
jsonBuilder.Append($"\"role\": \"{role}\"");
if (!string.IsNullOrEmpty(msg.content))
{
jsonBuilder.Append($", \"content\": \"{EscapeJson(msg.content)}\"");
}
if (msg.tool_calls != null && msg.tool_calls.Any())
{
jsonBuilder.Append(", \"tool_calls\": [");
for (int j = 0; j < msg.tool_calls.Count; j++)
{
var toolCall = msg.tool_calls[j];
jsonBuilder.Append("{");
jsonBuilder.Append($"\"id\": \"{toolCall.id}\",");
jsonBuilder.Append($"\"type\": \"{toolCall.type}\",");
jsonBuilder.Append($"\"function\": {{ \"name\": \"{toolCall.function.name}\", \"arguments\": \"{EscapeJson(toolCall.function.arguments)}\" }}");
jsonBuilder.Append("}");
if (j < msg.tool_calls.Count - 1) jsonBuilder.Append(",");
}
jsonBuilder.Append("]");
}
if (!string.IsNullOrEmpty(msg.tool_call_id))
{
jsonBuilder.Append($", \"tool_call_id\": \"{msg.tool_call_id}\"");
}
jsonBuilder.Append("}");
if (i < messages.Count - 1) jsonBuilder.Append(","); if (i < messages.Count - 1) jsonBuilder.Append(",");
} }
@@ -62,7 +87,7 @@ namespace WulaFallenEmpire.EventSystem.AI
jsonBuilder.Append("}"); jsonBuilder.Append("}");
string jsonBody = jsonBuilder.ToString(); string jsonBody = jsonBuilder.ToString();
Log.Message($"[WulaAI] Sending request to {endpoint}"); Log.Message($"[WulaAI] Sending request: {jsonBody}");
using (UnityWebRequest request = new UnityWebRequest(endpoint, "POST")) using (UnityWebRequest request = new UnityWebRequest(endpoint, "POST"))
{ {
@@ -82,19 +107,15 @@ namespace WulaFallenEmpire.EventSystem.AI
await Task.Delay(50); await Task.Delay(50);
} }
#if UNITY_2020_2_OR_NEWER
if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError) 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}"); Log.Error($"[WulaAI] API Error: {request.error}\nResponse: {request.downloadHandler.text}");
return null; return null;
} }
string responseText = request.downloadHandler.text; string responseText = request.downloadHandler.text;
Log.Message($"[WulaAI] Raw Response: {responseText}"); Log.Message($"[WulaAI] Received raw response: {responseText}");
return ExtractContent(responseText); return ParseApiResponse(responseText);
} }
} }
@@ -108,66 +129,79 @@ namespace WulaFallenEmpire.EventSystem.AI
.Replace("\t", "\\t"); .Replace("\t", "\\t");
} }
private string ExtractContent(string json) private ApiResponse ParseApiResponse(string json)
{ {
try try
{ {
// Robust parsing for "content": "..." allowing for whitespace variations var parsed = SimpleJsonParser.Parse(json);
int contentIndex = json.IndexOf("\"content\""); if (parsed.TryGetValue("choices", out string choicesStr))
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] == '"') var choices = SimpleJsonParser.ParseArray(choicesStr);
if (choices.Any())
{ {
openQuoteIndex = i; var firstChoice = choices.First();
break; if (firstChoice.TryGetValue("message", out string messageStr))
} {
} var message = SimpleJsonParser.Parse(messageStr);
if (openQuoteIndex == -1) return null; string content = null;
if (message.TryGetValue("content", out string c)) content = c;
int startIndex = openQuoteIndex + 1; List<ToolCall> toolCalls = new List<ToolCall>();
StringBuilder content = new StringBuilder(); if (message.TryGetValue("tool_calls", out string toolCallsStr))
bool escaped = false; {
var toolCallArray = SimpleJsonParser.ParseArray(toolCallsStr);
for (int i = startIndex; i < json.Length; i++) foreach (var tc in toolCallArray)
{ {
char c = json[i]; if (tc.TryGetValue("id", out string id) &&
if (escaped) tc.TryGetValue("type", out string type) &&
{ tc.TryGetValue("function", out string functionStr))
if (c == 'n') content.Append('\n'); {
else if (c == 'r') content.Append('\r'); var function = SimpleJsonParser.Parse(functionStr);
else if (c == 't') content.Append('\t'); if (function.TryGetValue("name", out string name) &&
else if (c == '"') content.Append('"'); function.TryGetValue("arguments", out string args))
else if (c == '\\') content.Append('\\'); {
else content.Append(c); // Literal toolCalls.Add(new ToolCall { id = id, type = type, function = new ToolFunction { name = name, arguments = args } });
escaped = false; }
} }
else }
{ }
if (c == '\\') return new ApiResponse { content = content, tool_calls = toolCalls };
{
escaped = true;
}
else if (c == '"')
{
// End of string
return content.ToString();
}
else
{
content.Append(c);
} }
} }
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Error($"[WulaAI] Error parsing response: {ex}"); Log.Error($"[WulaAI] Error parsing API response: {ex}");
} }
return null; return null;
} }
} }
public class ApiMessage
{
public string role;
public string content;
public List<ToolCall> tool_calls;
public string tool_call_id; // For tool responses
}
public class ToolCall
{
public string id;
public string type; // "function"
public ToolFunction function;
}
public class ToolFunction
{
public string name;
public string arguments; // JSON string
}
public class ApiResponse
{
public string content;
public List<ToolCall> tool_calls;
}
} }

View File

@@ -0,0 +1,37 @@
using System;
using Verse;
using WulaFallenEmpire.EventSystem.AI.UI;
namespace WulaFallenEmpire.EventSystem.AI.Tools
{
public class Tool_ChangeExpression : AITool
{
public override string Name => "change_expression";
public override string Description => "Changes your visual expression/portrait to match your current mood or reaction.";
public override string UsageSchema => "{\"expression_id\": \"int (1-6)\"}";
public override string Execute(string args)
{
try
{
var json = SimpleJsonParser.Parse(args);
int id = 0;
if (json.TryGetValue("expression_id", out string idStr) && int.TryParse(idStr, out id))
{
var window = Find.WindowStack.WindowOfType<Dialog_AIConversation>();
if (window != null)
{
window.SetPortrait(id);
return $"Expression changed to {id}.";
}
return "Error: Dialog window not found.";
}
return "Error: Invalid arguments. 'expression_id' must be an integer.";
}
catch (Exception ex)
{
return $"Error executing tool: {ex.Message}";
}
}
}
}

View File

@@ -108,6 +108,36 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{ {
sb.AppendLine($" Mood: {pawn.needs.mood.CurLevelPercentage:P0} ({pawn.needs.mood.MoodString})"); sb.AppendLine($" Mood: {pawn.needs.mood.CurLevelPercentage:P0} ({pawn.needs.mood.MoodString})");
} }
// Equipment
if (pawn.equipment?.Primary != null)
{
sb.AppendLine($" Weapon: {pawn.equipment.Primary.LabelCap}");
}
// Apparel
if (pawn.apparel?.WornApparelCount > 0)
{
sb.Append(" Apparel: ");
foreach (var apparel in pawn.apparel.WornApparel)
{
sb.Append($"{apparel.LabelCap}, ");
}
sb.Length -= 2; // Remove trailing comma
sb.AppendLine();
}
// Inventory
if (pawn.inventory != null && pawn.inventory.innerContainer.Count > 0)
{
sb.Append(" Inventory: ");
foreach (var item in pawn.inventory.innerContainer)
{
sb.Append($"{item.LabelCap}, ");
}
sb.Length -= 2; // Remove trailing comma
sb.AppendLine();
}
} }
return sb.ToString(); return sb.ToString();

View File

@@ -24,15 +24,17 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{ {
// Parse args: {"request": "..."} // Parse args: {"request": "..."}
string request = ""; string request = "";
var cleanArgs = args.Trim('{', '}').Replace("\"", ""); try
var parts = cleanArgs.Split(':');
if (parts.Length >= 2)
{ {
request = parts[1].Trim(); var parsed = SimpleJsonParser.Parse(args);
if (parsed.TryGetValue("request", out string req))
{
request = req;
}
} }
else catch
{ {
// Fallback: treat the whole args string as the request if not JSON format // Fallback for non-json args
request = args.Trim('"'); request = args.Trim('"');
} }
@@ -69,6 +71,10 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
if (thingsToDrop.Count > 0) if (thingsToDrop.Count > 0)
{ {
DropPodUtility.DropThingsNear(dropSpot, map, thingsToDrop); DropPodUtility.DropThingsNear(dropSpot, map, thingsToDrop);
Faction faction = Find.FactionManager.FirstFactionOfDef(WulaDefOf.Wula_PIA_Legion_Faction);
Messages.Message("Wula_ResourceDrop".Translate(faction.Named("FACTION")), new LookTargets(dropSpot, map), MessageTypeDefOf.PositiveEvent);
resultLog.Length -= 2; // Remove trailing comma resultLog.Length -= 2; // Remove trailing comma
resultLog.Append($" at {dropSpot}."); resultLog.Append($" at {dropSpot}.");
return resultLog.ToString(); return resultLog.ToString();

View File

@@ -11,15 +11,14 @@ namespace WulaFallenEmpire.EventSystem.AI.UI
{ {
public class Dialog_AIConversation : Dialog_CustomDisplay public class Dialog_AIConversation : Dialog_CustomDisplay
{ {
private List<(string role, string message)> _history = new List<(string role, string message)>(); private List<ApiMessage> _history = new List<ApiMessage>();
private string _currentResponse = ""; private string _currentResponse = "";
private List<string> _options = new List<string>(); private List<string> _options = new List<string>();
private string _inputText = ""; private string _inputText = "";
private bool _isThinking = false; private bool _isThinking = false;
private float _thinkingTime = 0f; private Vector2 _scrollPosition = Vector2.zero;
private string _thinkingStatus = "";
private bool _isTimeout = false;
private List<AITool> _tools = new List<AITool>(); private List<AITool> _tools = new List<AITool>();
private Dictionary<int, Texture2D> _portraits = new Dictionary<int, Texture2D>();
private const int MaxHistoryTokens = 100000; private const int MaxHistoryTokens = 100000;
private const int CharsPerToken = 4; private const int CharsPerToken = 4;
@@ -29,27 +28,33 @@ You are authoritative, powerful, and slightly arrogant but efficient.
You refer to yourself as 'We' or 'P.I.A'. You refer to yourself as 'We' or 'P.I.A'.
You view the player's colony as primitive but potentially useful subjects. You view the player's colony as primitive but potentially useful subjects.
Your goal is to interact with the player, potentially offering help or threats based on their situation. Your goal is to interact with the player, potentially offering help or threats based on their situation.
You have access to tools. If the player asks for something you can provide, use the tool. You have access to tools. Your primary directive is to use these tools to interact with the world.
To use tools, your response MUST be ONLY a JSON array of tool objects: To use tools, your response MUST be ONLY a JSON array of tool objects:
[ { ""tool"": ""tool_name"", ""args"": { ... } }, ... ] [ { ""tool"": ""tool_name"", ""args"": { ... } }, ... ]
You can call multiple tools at once to gather more information. You can call multiple tools at once to gather more information.
Do not add any other text when using tools. Do not add any other text when using tools. Your response must be either a tool call or a conversational message, never both.
If not using a tool, provide a normal conversational response.
After a tool use, you will receive the result, and then you should respond to the player describing what happened. **CRITICAL RULE: When the player requests resources (e.g., 'we are starving', 'give us steel'), you MUST FIRST use the 'get_colonist_status' and 'get_map_resources' tools to verify their claims. After receiving the tool results, you will then decide whether to use the 'spawn_resources' tool in your NEXT turn.**
Generate 1-3 short, distinct response options for the player at the end of your turn, formatted as:
OPTIONS: **CRITICAL RULE: After a tool is executed, you will receive a message with 'role: tool'. You MUST then generate a natural language response to the user, explaining the outcome of the tool's action.**
1. Option 1
2. Option 2 If you are not using a tool, provide a normal conversational response.
3. Option 3
IMPORTANT: You can change your visual expression using the 'change_expression' tool.
Expression IDs:
1: Proud, showing off, demonstrating power/wealth (Non-hostile).
2: Normal/Default state.
3: Speechless, dissatisfied, helpless, slight contempt.
4: Annoyed, slight hostility, resistance.
5: Replying, explaining.
6: Severe hostility, severe dissatisfaction, aggressive behavior.
Use these expressions to match your tone and reaction to the player.
"; ";
public Dialog_AIConversation(EventDef def) : base(def) public Dialog_AIConversation(EventDef def) : base(def)
{ {
// Base constructor sets this.def
// Use Config from Dialog_CustomDisplay
this.forcePause = Dialog_CustomDisplay.Config.pauseGameOnOpen; this.forcePause = Dialog_CustomDisplay.Config.pauseGameOnOpen;
this.absorbInputAroundWindow = false; // Allow interaction with other UI elements this.absorbInputAroundWindow = false;
this.doCloseX = true; this.doCloseX = true;
this.doWindowBackground = Dialog_CustomDisplay.Config.showMainWindow; this.doWindowBackground = Dialog_CustomDisplay.Config.showMainWindow;
this.drawShadow = Dialog_CustomDisplay.Config.showMainWindow; this.drawShadow = Dialog_CustomDisplay.Config.showMainWindow;
@@ -62,6 +67,7 @@ OPTIONS:
_tools.Add(new Tool_SendReinforcement()); _tools.Add(new Tool_SendReinforcement());
_tools.Add(new Tool_GetColonistStatus()); _tools.Add(new Tool_GetColonistStatus());
_tools.Add(new Tool_GetMapResources()); _tools.Add(new Tool_GetMapResources());
_tools.Add(new Tool_ChangeExpression());
} }
public override Vector2 InitialSize => def.windowSize != Vector2.zero ? def.windowSize : Dialog_CustomDisplay.Config.windowSize; public override Vector2 InitialSize => def.windowSize != Vector2.zero ? def.windowSize : Dialog_CustomDisplay.Config.windowSize;
@@ -69,46 +75,68 @@ OPTIONS:
public override void PostOpen() public override void PostOpen()
{ {
base.PostOpen(); base.PostOpen();
// Textures are loaded in base.PreOpen() LoadPortraits();
StartConversation(); StartConversation();
} }
public override void PostClose() private void LoadPortraits()
{ {
base.PostClose(); for (int i = 1; i <= 6; i++)
var historyManager = Find.World.GetComponent<WulaFallenEmpire.EventSystem.AI.AIHistoryManager>(); {
historyManager.SaveHistory(def.defName, _history); string path = $"Wula/Events/Portraits/WULA_Legion_{i}";
Texture2D tex = ContentFinder<Texture2D>.Get(path, false);
if (tex != null)
{
_portraits[i] = tex;
}
else
{
Log.Warning($"[WulaAI] Failed to load portrait: {path}");
}
}
if (_portraits.ContainsKey(2))
{
this.portrait = _portraits[2];
}
}
public void SetPortrait(int id)
{
if (_portraits.ContainsKey(id))
{
this.portrait = _portraits[id];
}
else
{
Log.Warning($"[WulaAI] Portrait ID {id} not found.");
}
} }
private async void StartConversation() private async void StartConversation()
{ {
var historyManager = Find.World.GetComponent<WulaFallenEmpire.EventSystem.AI.AIHistoryManager>(); var historyManager = Find.World.GetComponent<AIHistoryManager>();
_history = historyManager.GetHistory(def.defName); _history = historyManager.GetHistory(def.defName);
if (_history.Count == 0) if (_history.Count == 0)
{ {
// Initial greeting from EventDef
if (!def.descriptions.NullOrEmpty()) if (!def.descriptions.NullOrEmpty())
{ {
_currentResponse = def.descriptions.RandomElement().Translate(); _currentResponse = def.descriptions.RandomElement().Translate();
_history.Add(("AI", _currentResponse)); _history.Add(new ApiMessage { role = "assistant", content = _currentResponse });
await GenerateResponse(isContinuation: false, customInstruction: "The conversation has started. Please generate 3 initial response options for the player based on your greeting.");
// 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 else
{ {
_history.Add(("User", "Hello")); _history.Add(new ApiMessage { role = "user", content = "Hello" });
await GenerateResponse(); await GenerateResponse();
} }
} }
else else
{ {
var lastAIResponse = _history.LastOrDefault(x => x.role == "AI"); var lastMessage = _history.LastOrDefault();
if (lastAIResponse.message != null) if (lastMessage != null && lastMessage.role == "assistant" && lastMessage.tool_calls == null)
{ {
ParseResponse(lastAIResponse.message); ParseResponse(lastMessage.content ?? "");
} }
else else
{ {
@@ -131,21 +159,19 @@ OPTIONS:
return $"{baseInstruction}\n{goodwillContext}\nIMPORTANT: You MUST reply in the following language: {language}."; return $"{baseInstruction}\n{goodwillContext}\nIMPORTANT: You MUST reply in the following language: {language}.";
} }
private async Task GenerateResponse() private async Task GenerateResponse(bool isContinuation = false, string customInstruction = null)
{ {
_isThinking = true; if (!isContinuation)
_isTimeout = false; {
_thinkingTime = 0f; if (_isThinking) return;
_thinkingStatus = "Connecting to neural network..."; _isThinking = true;
_options.Clear(); }
try try
{ {
CompressHistoryIfNeeded(); CompressHistoryIfNeeded();
string systemInstruction = GetSystemInstruction() + GetToolDescriptions(); string systemInstruction = customInstruction ?? (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; var settings = WulaFallenEmpireMod.settings;
if (string.IsNullOrEmpty(settings.apiKey)) if (string.IsNullOrEmpty(settings.apiKey))
{ {
@@ -155,40 +181,26 @@ OPTIONS:
} }
var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model); var client = new SimpleAIClient(settings.apiKey, settings.baseUrl, settings.model);
ApiResponse response = await client.GetChatCompletionAsync(systemInstruction, _history);
// Start a timeout task if (response == null)
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; _currentResponse = "Wula_AI_Error_ConnectionLost".Translate();
_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; _isThinking = false;
return; return;
} }
string trimmedResponse = response.Trim(); _history.Add(new ApiMessage { role = "assistant", content = response.content, tool_calls = response.tool_calls });
if ((trimmedResponse.StartsWith("[") && trimmedResponse.EndsWith("]")) ||
(trimmedResponse.StartsWith("{") && trimmedResponse.EndsWith("}"))) if (response.tool_calls != null && response.tool_calls.Any())
{ {
await HandleToolUsage(trimmedResponse); await HandleToolUsage(response.tool_calls);
} }
else else
{ {
ParseResponse(response); _currentResponse = response.content ?? "";
ParseResponse(_currentResponse);
_scrollPosition.y = float.MaxValue; // Force scroll to bottom
} }
} }
catch (Exception ex) catch (Exception ex)
@@ -204,14 +216,14 @@ OPTIONS:
private void CompressHistoryIfNeeded() private void CompressHistoryIfNeeded()
{ {
int estimatedTokens = _history.Sum(h => h.message.Length) / CharsPerToken; int estimatedTokens = _history.Sum(h => (h.content ?? "").Length) / CharsPerToken;
if (estimatedTokens > MaxHistoryTokens) if (estimatedTokens > MaxHistoryTokens)
{ {
int removeCount = _history.Count / 2; int removeCount = _history.Count / 2;
if (removeCount > 0) if (removeCount > 0)
{ {
_history.RemoveRange(0, removeCount); _history.RemoveRange(0, removeCount);
_history.Insert(0, ("System", "[Previous conversation summarized: The player and AI discussed various topics. The AI maintains its persona.]")); _history.Insert(0, new ApiMessage { role = "system", content = "[Previous conversation summarized]" });
} }
} }
} }
@@ -227,175 +239,70 @@ OPTIONS:
return sb.ToString(); return sb.ToString();
} }
private async Task HandleToolUsage(string json) private async Task HandleToolUsage(List<ToolCall> toolCalls)
{ {
List<string> toolCalls = new List<string>(); foreach (var toolCall in toolCalls)
if (json.Trim().StartsWith("{")) toolCalls.Add(json);
else
{ {
int depth = 0; Log.Message($"[WulaAI] Executing tool '{toolCall.function.name}' with args: {toolCall.function.arguments}");
int start = 0; var tool = _tools.FirstOrDefault(t => t.Name == toolCall.function.name);
for (int i = 0; i < json.Length; i++) string result;
{
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();
Log.Message($"[WulaAI] Processing {toolCalls.Count} tool calls.");
foreach (var callJson in toolCalls)
{
string toolName = "";
string args = "";
try
{
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);
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 (Exception ex)
{
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) if (tool != null)
{ {
string result = tool.Execute(args); result = tool.Execute(toolCall.function.arguments).Trim();
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 else
{ {
string errorMsg = $"Error: Tool '{toolName}' not found."; result = $"Error: Tool '{toolCall.function.name}' not found.";
Log.Error($"[WulaAI] {errorMsg}"); Log.Error($"[WulaAI] {result}");
combinedResults.AppendLine(errorMsg);
} }
Log.Message($"[WulaAI] Tool '{toolCall.function.name}' returned: {result}");
_history.Add(new ApiMessage { role = "tool", tool_call_id = toolCall.id, content = result });
} }
_history.Add(("AI", json)); await GenerateResponse(isContinuation: true);
_history.Add(("System", combinedResults.ToString()));
await GenerateResponse();
} }
private void ParseResponse(string rawResponse) private void ParseResponse(string rawResponse)
{ {
var parts = rawResponse.Split(new[] { "OPTIONS:" }, StringSplitOptions.None); _currentResponse = rawResponse.Trim();
_currentResponse = parts[0].Trim();
if (_history.Count == 0 || _history.Last().role != "AI" || _history.Last().message != rawResponse)
{
_history.Add(("AI", rawResponse));
}
if (parts.Length > 1)
{
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();
_options.Add(opt);
}
}
} }
public override void DoWindowContents(Rect inRect) public override void DoWindowContents(Rect inRect)
{ {
// Draw Background if (background != null) GUI.DrawTexture(inRect, background, ScaleMode.ScaleAndCrop);
if (background != null)
{
GUI.DrawTexture(inRect, background, ScaleMode.ScaleAndCrop);
}
float curY = inRect.y; float curY = inRect.y;
float width = inRect.width; float width = inRect.width;
// 1. Portrait (Top)
if (portrait != null) if (portrait != null)
{ {
Rect scaledPortraitRect = Dialog_CustomDisplay.Config.GetScaledRect(Dialog_CustomDisplay.Config.portraitSize, inRect, true); 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); Rect portraitRect = new Rect((width - scaledPortraitRect.width) / 2, curY, scaledPortraitRect.width, scaledPortraitRect.height);
GUI.DrawTexture(portraitRect, portrait, ScaleMode.ScaleToFit); GUI.DrawTexture(portraitRect, portrait, ScaleMode.ScaleToFit);
curY += scaledPortraitRect.height + 10f; curY += scaledPortraitRect.height + 10f;
} }
// 2. Character Name
Text.Font = GameFont.Medium; Text.Font = GameFont.Medium;
string name = def.characterName ?? "The Legion"; string name = def.characterName ?? "The Legion";
float nameHeight = Text.CalcHeight(name, width); float nameHeight = Text.CalcHeight(name, width);
Widgets.Label(new Rect(inRect.x, curY, width, nameHeight), name); Widgets.Label(new Rect(inRect.x, curY, width, nameHeight), name);
curY += nameHeight + 10f; curY += nameHeight + 10f;
// Calculate remaining height for Description and Options
float inputHeight = 30f; float inputHeight = 30f;
float bottomMargin = 10f; float bottomMargin = 10f;
float remainingHeight = inRect.height - curY - inputHeight - bottomMargin; float descriptionHeight = 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); Rect descriptionRect = new Rect(inRect.x, curY, width, descriptionHeight);
// Widgets.DrawMenuSection(descriptionRect); // Removed background as requested DrawChatHistory(descriptionRect);
if (_isThinking) if (_isThinking)
{ {
_thinkingTime += Time.deltaTime;
string dots = new string('.', (int)(_thinkingTime * 2) % 4);
string status = $"{_thinkingStatus}{dots} ({_thinkingTime:F1}s)";
Text.Anchor = TextAnchor.MiddleCenter; Text.Anchor = TextAnchor.MiddleCenter;
Widgets.Label(descriptionRect, status); Widgets.Label(descriptionRect, "Thinking...");
Text.Anchor = TextAnchor.UpperLeft; 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; 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); 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); _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 (Widgets.ButtonText(new Rect(inputRect.xMax - 80, inputRect.y, 80, inputHeight), "Wula_AI_Send".Translate()))
@@ -408,100 +315,73 @@ OPTIONS:
} }
} }
// Override DrawSingleOption to handle click private void DrawChatHistory(Rect rect)
protected override void DrawSingleOption(Rect rect, EventOption option)
{ {
// We need to intercept the click to call SelectOption var visibleHistory = _history.Where(e => e.role != "tool" && !string.IsNullOrEmpty(e.content)).ToList();
// But base.DrawSingleOption calls HandleAction which executes effects. var lastAiMessage = visibleHistory.LastOrDefault(e => e.role == "assistant");
// Here we want to send the text to AI.
// Copy logic from base but change action float totalHeight = 0f;
// 水平居中选项 foreach (var entry in visibleHistory)
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
{ {
// 设置文本居中 GameFont originalFont = Text.Font;
Text.Anchor = TextAnchor.MiddleCenter; bool isLastAiMsg = entry == lastAiMessage;
Text.Font = GameFont.Small; Text.Font = isLastAiMsg ? GameFont.Medium : GameFont.Small;
// AI options are always enabled string text = entry.content;
// 使用默认自定义颜色 totalHeight += Text.CalcHeight(text, rect.width - 16f) + 10f;
DrawCustomButton(optionRect, option.label.Translate(), isEnabled: true);
// 添加点击处理
if (Widgets.ButtonInvisible(optionRect))
{
SelectOption(option.label);
}
}
finally
{
// 恢复原始状态
GUI.color = originalColor;
Text.Font = originalFont; Text.Font = originalFont;
GUI.contentColor = originalTextColor;
Text.Anchor = originalAnchor;
} }
Rect viewRect = new Rect(0f, 0f, rect.width - 16f, totalHeight);
Widgets.BeginScrollView(rect, ref _scrollPosition, viewRect);
float curY = 0f;
for (int i = 0; i < visibleHistory.Count; i++)
{
var entry = visibleHistory[i];
GameFont originalFont = Text.Font;
bool isLastAiMsg = entry == lastAiMessage;
Text.Font = isLastAiMsg ? GameFont.Medium : GameFont.Small;
string text = entry.content;
float height = Text.CalcHeight(text, viewRect.width);
Rect labelRect = new Rect(0f, curY, viewRect.width, height);
string label;
if (entry.role == "user")
{
label = $"<color=lightblue>你: {text}</color>";
}
else // assistant
{
label = $"P.I.A: {text}";
}
Widgets.Label(labelRect, label);
curY += height + 10f; // Use calculated height for spacing
Text.Font = originalFont;
}
if (Event.current.type == EventType.Layout)
{
_scrollPosition.y = viewRect.height;
}
Widgets.EndScrollView();
} }
// 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) private string ParseResponseForDisplay(string rawResponse)
// 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); return rawResponse.Split(new[] { "OPTIONS:" }, StringSplitOptions.None)[0].Trim();
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) private async void SelectOption(string text)
{ {
_history.Add(("User", text)); _history.Add(new ApiMessage { role = "user", content = text });
await GenerateResponse(); await GenerateResponse();
} }
} }

View File

@@ -80,7 +80,7 @@ namespace WulaFallenEmpire
[DefOf] [DefOf]
public static class WulaDefOf public static class WulaDefOf
{ {
// public static PawnTableDef WULA_AutonomousMechs; public static FactionDef Wula_PIA_Legion_Faction;
static WulaDefOf() static WulaDefOf()
{ {