This commit is contained in:
2025-12-14 10:00:26 +08:00
parent 6a513180ae
commit c00fc0743b
6 changed files with 254 additions and 46 deletions

View File

@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8" ?>
<LanguageData>
<Wula_ResourceDrop>{FACTION_name}已经在附近投下了一些资源。</Wula_ResourceDrop>
</LanguageData>

View File

@@ -18,6 +18,9 @@
},
{
"path": "../../../../../../../../Users/Kalo/Downloads/RimTalk-main"
},
{
"path": "../../../../../../../../Users/Kalo/Downloads/openai_token-main"
}
],
"settings": {}

View File

@@ -12,6 +12,7 @@ namespace WulaFallenEmpire.EventSystem.AI
private readonly string _apiKey;
private readonly string _baseUrl;
private readonly string _model;
private const int MaxLogChars = 2000;
public SimpleAIClient(string apiKey, string baseUrl, string model)
{
@@ -41,28 +42,36 @@ namespace WulaFallenEmpire.EventSystem.AI
jsonBuilder.Append("\"messages\": [");
// System instruction
bool firstMessage = true;
if (!string.IsNullOrEmpty(instruction))
{
jsonBuilder.Append($"{{\"role\": \"system\", \"content\": \"{EscapeJson(instruction)}\"}},");
jsonBuilder.Append($"{{\"role\": \"system\", \"content\": \"{EscapeJson(instruction)}\"}}");
firstMessage = false;
}
// Messages
for (int i = 0; i < messages.Count; i++)
{
var msg = messages[i];
string role = msg.role.ToLower();
string role = (msg.role ?? "user").ToLowerInvariant();
if (role == "ai") role = "assistant";
// Map other roles if needed
else if (role == "tool") role = "system"; // Internal-only role; map to supported role for Chat Completions APIs.
else if (role != "system" && role != "user" && role != "assistant") role = "user";
if (!firstMessage) jsonBuilder.Append(",");
jsonBuilder.Append($"{{\"role\": \"{role}\", \"content\": \"{EscapeJson(msg.message)}\"}}");
if (i < messages.Count - 1) jsonBuilder.Append(",");
firstMessage = false;
}
jsonBuilder.Append("]");
jsonBuilder.Append("}");
string jsonBody = jsonBuilder.ToString();
Log.Message($"[WulaAI] Sending request to {endpoint}:\n{jsonBody}");
if (Prefs.DevMode)
{
Log.Message($"[WulaAI] Sending request to {endpoint} (model={_model}, messages={messages?.Count ?? 0})");
Log.Message($"[WulaAI] Request body (truncated):\n{TruncateForLog(jsonBody)}");
}
using (UnityWebRequest request = new UnityWebRequest(endpoint, "POST"))
{
@@ -84,24 +93,57 @@ namespace WulaFallenEmpire.EventSystem.AI
if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError)
{
Log.Error($"[WulaAI] API Error: {request.error}\nResponse: {request.downloadHandler.text}");
Log.Error($"[WulaAI] API Error: {request.error}\nResponse (truncated): {TruncateForLog(request.downloadHandler.text)}");
return null;
}
string responseText = request.downloadHandler.text;
Log.Message($"[WulaAI] Raw Response: {responseText}");
if (Prefs.DevMode)
{
Log.Message($"[WulaAI] Raw Response (truncated): {TruncateForLog(responseText)}");
}
return ExtractContent(responseText);
}
}
private static string TruncateForLog(string s)
{
if (string.IsNullOrEmpty(s)) return s;
if (s.Length <= MaxLogChars) return s;
return s.Substring(0, MaxLogChars) + $"... (truncated, total {s.Length} chars)";
}
private string EscapeJson(string s)
{
if (s == null) return "";
return s.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\n", "\\n")
.Replace("\r", "\\r")
.Replace("\t", "\\t");
StringBuilder sb = new StringBuilder(s.Length + 16);
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
switch (c)
{
case '\\': sb.Append("\\\\"); break;
case '"': sb.Append("\\\""); break;
case '\n': sb.Append("\\n"); break;
case '\r': sb.Append("\\r"); break;
case '\t': sb.Append("\\t"); break;
case '\b': sb.Append("\\b"); break;
case '\f': sb.Append("\\f"); break;
default:
if (c < 0x20)
{
sb.Append("\\u");
sb.Append(((int)c).ToString("x4"));
}
else
{
sb.Append(c);
}
break;
}
}
return sb.ToString();
}
private string ExtractContent(string json)
@@ -116,13 +158,13 @@ namespace WulaFallenEmpire.EventSystem.AI
string[] lines = json.Split(new[] { "\n", "\r" }, StringSplitOptions.RemoveEmptyEntries);
foreach (string line in lines)
{
string trimmedLine = line.Trim();
if (trimmedLine == "data: [DONE]") continue;
if (trimmedLine.StartsWith("data: "))
{
string dataJson = trimmedLine.Substring(6);
string trimmedLine = line.Trim();
if (trimmedLine == "data: [DONE]") continue;
if (trimmedLine.StartsWith("data: "))
{
string dataJson = trimmedLine.Substring(6);
// Extract content from this chunk
string chunkContent = ExtractJsonValue(dataJson, "content");
string chunkContent = TryExtractAssistantContent(dataJson) ?? ExtractJsonValue(dataJson, "content");
if (!string.IsNullOrEmpty(chunkContent))
{
fullContent.Append(chunkContent);
@@ -134,7 +176,7 @@ namespace WulaFallenEmpire.EventSystem.AI
else
{
// Standard non-stream format
return ExtractJsonValue(json, "content");
return TryExtractAssistantContent(json) ?? ExtractJsonValue(json, "content");
}
}
catch (Exception ex)
@@ -144,12 +186,109 @@ namespace WulaFallenEmpire.EventSystem.AI
return null;
}
private string ExtractJsonValue(string json, string key)
private static string TryExtractAssistantContent(string json)
{
if (string.IsNullOrWhiteSpace(json)) return null;
int choicesIndex = json.IndexOf("\"choices\"", StringComparison.Ordinal);
if (choicesIndex == -1) return null;
string firstChoiceJson = TryExtractFirstChoiceObject(json, choicesIndex);
if (string.IsNullOrEmpty(firstChoiceJson)) return null;
int messageIndex = firstChoiceJson.IndexOf("\"message\"", StringComparison.Ordinal);
if (messageIndex != -1)
{
return ExtractJsonValue(firstChoiceJson, "content", messageIndex);
}
int deltaIndex = firstChoiceJson.IndexOf("\"delta\"", StringComparison.Ordinal);
if (deltaIndex != -1)
{
return ExtractJsonValue(firstChoiceJson, "content", deltaIndex);
}
return ExtractJsonValue(firstChoiceJson, "text", 0);
}
private static string TryExtractFirstChoiceObject(string json, int choicesKeyIndex)
{
int arrayStart = json.IndexOf('[', choicesKeyIndex);
if (arrayStart == -1) return null;
int objStart = json.IndexOf('{', arrayStart);
if (objStart == -1) return null;
int objEnd = FindMatchingBrace(json, objStart);
if (objEnd == -1) return null;
return json.Substring(objStart, objEnd - objStart + 1);
}
private static int FindMatchingBrace(string json, int startIndex)
{
int depth = 0;
bool inString = false;
bool escaped = false;
for (int i = startIndex; i < json.Length; i++)
{
char c = json[i];
if (inString)
{
if (escaped)
{
escaped = false;
continue;
}
if (c == '\\')
{
escaped = true;
continue;
}
if (c == '"')
{
inString = false;
}
continue;
}
if (c == '"')
{
inString = true;
continue;
}
if (c == '{')
{
depth++;
continue;
}
if (c == '}')
{
depth--;
if (depth == 0) return i;
}
}
return -1;
}
private static string ExtractJsonValue(string json, string key)
{
// Simple parser to find "key": "value"
// This is not a full JSON parser and assumes standard formatting
return ExtractJsonValue(json, key, 0);
}
private static string ExtractJsonValue(string json, string key, int startIndex)
{
string keyPattern = $"\"{key}\"";
int keyIndex = json.IndexOf(keyPattern);
int keyIndex = json.IndexOf(keyPattern, startIndex, StringComparison.Ordinal);
if (keyIndex == -1) return null;
// Find the colon after the key
@@ -196,4 +335,4 @@ namespace WulaFallenEmpire.EventSystem.AI
return null;
}
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using RimWorld;
@@ -90,16 +91,24 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
if (itemsToSpawn.Count == 0)
{
return "Error: No valid items found in request. Usage: <spawn_resources><items><item><name>...</name><count>...</count></item></items></spawn_resources>";
string msg = "Error: No valid items found in request. Usage: <spawn_resources><items><item><name>...</name><count>...</count></item></items></spawn_resources>";
Messages.Message(msg, MessageTypeDefOf.RejectInput);
return msg;
}
Map map = Find.CurrentMap;
Map map = GetTargetMap();
if (map == null)
{
return "Error: No active map.";
string msg = "Error: No active map.";
Messages.Message(msg, MessageTypeDefOf.RejectInput);
return msg;
}
IntVec3 dropSpot = DropCellFinder.TradeDropSpot(map);
if (!dropSpot.IsValid || !dropSpot.InBounds(map))
{
dropSpot = DropCellFinder.RandomDropSpot(map);
}
List<Thing> thingsToDrop = new List<Thing>();
StringBuilder resultLog = new StringBuilder();
resultLog.Append("Success: Dropped ");
@@ -114,27 +123,60 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
if (thingsToDrop.Count > 0)
{
DropPodUtility.DropThingsNear(dropSpot, map, thingsToDrop);
Faction faction = Find.FactionManager.FirstFactionOfDef(FactionDef.Named("Wula_PIA_Legion_Faction"));
if (faction != null)
// If the conversation window pauses the game, incoming drop pods may not "land" until unpaused.
// To keep this tool reliable, place items immediately when paused; otherwise, use drop pods.
bool isPaused = Find.TickManager != null && Find.TickManager.Paused;
if (isPaused)
{
Messages.Message("Wula_ResourceDrop".Translate(faction.def.defName.Named("FACTION_name")), new LookTargets(dropSpot, map), MessageTypeDefOf.PositiveEvent);
foreach (var thing in thingsToDrop)
{
GenPlace.TryPlaceThing(thing, dropSpot, map, ThingPlaceMode.Near);
}
}
else
{
DropPodUtility.DropThingsNear(dropSpot, map, thingsToDrop);
}
Faction faction = Find.FactionManager.FirstFactionOfDef(FactionDef.Named("Wula_PIA_Legion_Faction"));
string letterText = faction != null
? "Wula_ResourceDrop".Translate(faction.def.defName.Named("FACTION_name"))
: "Wula_ResourceDrop".Translate("Unknown".Named("FACTION_name"));
Messages.Message(letterText, new LookTargets(dropSpot, map), MessageTypeDefOf.PositiveEvent);
resultLog.Length -= 2; // Remove trailing comma
resultLog.Append($" at {dropSpot}.");
resultLog.Append($" at {dropSpot}. {(isPaused ? "(placed immediately because game is paused)" : "(drop pods inbound)")}");
return resultLog.ToString();
}
else
{
return "Error: Failed to create items.";
string msg = "Error: Failed to create items.";
Messages.Message(msg, MessageTypeDefOf.RejectInput);
return msg;
}
}
catch (Exception ex)
{
return $"Error: {ex.Message}";
string msg = $"Error: {ex.Message}";
Messages.Message(msg, MessageTypeDefOf.RejectInput);
return msg;
}
}
private static Map GetTargetMap()
{
Map map = Find.CurrentMap;
if (map != null) return map;
if (Find.Maps != null)
{
Map homeMap = Find.Maps.FirstOrDefault(m => m != null && m.IsPlayerHome);
if (homeMap != null) return homeMap;
if (Find.Maps.Count > 0) return Find.Maps[0];
}
return null;
}
}
}
}

View File

@@ -228,6 +228,19 @@ When the player requests any form of resources, you MUST follow this multi-turn
StartConversation();
}
private void PersistHistory()
{
try
{
var historyManager = Find.World?.GetComponent<AIHistoryManager>();
historyManager?.SaveHistory(def.defName, _history);
}
catch (Exception ex)
{
Log.Error($"[WulaAI] Failed to persist AI history: {ex}");
}
}
private void LoadPortraits()
{
for (int i = 1; i <= 6; i++)
@@ -281,6 +294,7 @@ When the player requests any form of resources, you MUST follow this multi-turn
if (_history.Count == 0)
{
_history.Add(("user", "Hello"));
PersistHistory();
await GenerateResponse();
}
else
@@ -379,6 +393,7 @@ When the player requests any form of resources, you MUST follow this multi-turn
{
_history.RemoveRange(0, removeCount);
_history.Insert(0, ("system", "[Previous conversation summarized]"));
PersistHistory();
}
}
}
@@ -422,8 +437,17 @@ When the player requests any form of resources, you MUST follow this multi-turn
argsXml = contentMatch.Groups[1].Value;
}
Log.Message($"[WulaAI] Executing tool: {toolName} with args: {argsXml}");
if (Prefs.DevMode)
{
Log.Message($"[WulaAI] Executing tool: {toolName} with args: {argsXml}");
}
string result = tool.Execute(argsXml).Trim();
if (Prefs.DevMode && !string.IsNullOrEmpty(result))
{
string toLog = result.Length <= 2000 ? result : result.Substring(0, 2000) + $"... (truncated, total {result.Length} chars)";
Log.Message($"[WulaAI] Tool '{toolName}' result: {toLog}");
}
if (toolName == "modify_goodwill")
{
@@ -436,9 +460,9 @@ When the player requests any form of resources, you MUST follow this multi-turn
}
_history.Add(("assistant", xml));
// Use role "user" for tool results to avoid API errors about tool_calls format.
// This is safe and won't break AI logic, as the AI still sees the result clearly.
_history.Add(("user", $"[Tool Results]\n{combinedResults.ToString().Trim()}"));
// Persist tool results with a dedicated role; the API request maps this role to a supported one.
_history.Add(("tool", $"[Tool Results]\n{combinedResults.ToString().Trim()}"));
PersistHistory();
// Check if there is any text content in the response
string textContent = Regex.Replace(xml, @"<([a-zA-Z0-9_]+)(?:>.*?</\1>|/>)", "", RegexOptions.Singleline).Trim();
@@ -460,6 +484,7 @@ When the player requests any form of resources, you MUST follow this multi-turn
{
Log.Error($"[WulaAI] Exception in HandleXmlToolUsage: {ex}");
_history.Add(("tool", $"Error processing tool call: {ex.Message}"));
PersistHistory();
await GenerateResponse(isContinuation: true);
}
}
@@ -473,6 +498,7 @@ When the player requests any form of resources, you MUST follow this multi-turn
if (_history.Count == 0 || _history.Last().role != "assistant" || _history.Last().message != rawResponse)
{
_history.Add(("assistant", rawResponse));
PersistHistory();
}
}
@@ -563,7 +589,9 @@ When the player requests any form of resources, you MUST follow this multi-turn
try
{
float viewHeight = 0f;
var filteredHistory = _history.Where(e => e.role != "tool" && e.role != "system").ToList();
var filteredHistory = _history
.Where(e => e.role != "system" && (Prefs.DevMode || e.role != "tool"))
.ToList();
// Pre-calculate height
for (int i = 0; i < filteredHistory.Count; i++)
{
@@ -603,7 +631,7 @@ When the player requests any form of resources, you MUST follow this multi-turn
if (entry.role == "user")
{
Text.Anchor = TextAnchor.MiddleRight;
Widgets.Label(labelRect, $"<color=#add8e6>{text} :你</color>");
Widgets.Label(labelRect, $"<color=#add8e6>{text}</color>");
}
else
{
@@ -707,6 +735,7 @@ When the player requests any form of resources, you MUST follow this multi-turn
private async void SelectOption(string text)
{
_history.Add(("user", text));
PersistHistory();
_scrollToBottom = true;
await GenerateResponse();
}
@@ -714,6 +743,7 @@ When the player requests any form of resources, you MUST follow this multi-turn
public override void PostClose()
{
if (Instance == this) Instance = null;
PersistHistory();
base.PostClose();
HandleAction(def.dismissEffects);
}