已把工具调用从 XML 改成 OpenAI 兼容 JSON,并统一解析/执行流程。改动概览如下:

新增 JSON tool_calls 解析/序列化并替换核心执行与提示词为 JSON-only:JsonToolCallParser.cs、AIIntelligenceCore.cs
工具基类移除 XML 解析,统一 JSON 参数读取与类型转换辅助:AITool.cs
工具实现统一 JSON args/UsageSchema(含重写/修复):Tool_ModifyGoodwill.cs、Tool_SendReinforcement.cs、Tool_GetMapPawns.cs、Tool_GetMapResources.cs、Tool_GetAvailablePrefabs.cs、Tool_CallPrefabAirdrop.cs、Tool_CallBombardment.cs、Tool_GetAvailableBombardments.cs、Tool_GetPawnStatus.cs、Tool_GetRecentNotifications.cs、Tool_SearchThingDef.cs、Tool_SearchPawnKind.cs、Tool_ChangeExpression.cs、Tool_SetOverwatchMode.cs、Tool_RememberFact.cs、Tool_RecallMemories.cs、Tool_SpawnResources.cs、Tool_AnalyzeScreen.cs
轰炸相关解析统一到 JSON 字典并增强数值解析:BombardmentUtility.cs
UI 对话展示改为剥离 JSON tool_calls:Overlay_WulaLink.cs、Dialog_AIConversation.cs
This commit is contained in:
2025-12-31 01:45:38 +08:00
parent 0cea79ddff
commit b906a468b6
32 changed files with 6396 additions and 542 deletions

View File

@@ -2,7 +2,6 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using RimWorld;
using Verse;
using WulaFallenEmpire.EventSystem.AI.Utils;
@@ -18,8 +17,8 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
"If goodwill is low (< 0), give significantly less than asked or refuse. " +
"If goodwill is high (> 50), you may give what is asked or slightly more. " +
"Otherwise, give a moderate amount. " +
"TIP: Use the `search_thing_def` tool first and then spawn by DefName (<defName> or put DefName into <name>) to avoid language mismatch.";
public override string UsageSchema => "<spawn_resources><items><item><name>Item Name</name><count>Integer</count></item></items></spawn_resources>";
"TIP: Use the `search_thing_def` tool first and then spawn by DefName to avoid language mismatch.";
public override string UsageSchema => "{\"items\":[{\"name\":\"Steel\",\"count\":100,\"stuffDefName\":\"Steel\"}]}";
public override string Execute(string args)
{
@@ -27,89 +26,44 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
{
if (args == null) args = "";
// Custom XML parsing for nested items
var parsedArgs = ParseJsonArgs(args);
var itemsToSpawn = new List<(ThingDef def, int count, string requestedName, string stuffDefName)>();
var substitutions = new List<string>();
// Match all <item>...</item> blocks
var itemMatches = Regex.Matches(args, @"<item\b[^>]*>(.*?)</item>", RegexOptions.Singleline | RegexOptions.IgnoreCase);
foreach (Match match in itemMatches)
if (TryGetList(parsedArgs, "items", out List<object> itemsRaw))
{
string itemXml = match.Groups[1].Value;
// Extract name (supports <name> or <defName> for backward compatibility)
string ExtractTag(string xml, string tag)
foreach (var item in itemsRaw)
{
var m = Regex.Match(
xml,
$@"<{tag}\b[^>]*>(?:<!\[CDATA\[(.*?)\]\]>|(.*?))</{tag}>",
RegexOptions.Singleline | RegexOptions.IgnoreCase);
if (!m.Success) return null;
string val = m.Groups[1].Success ? m.Groups[1].Value : m.Groups[2].Value;
return val?.Trim();
}
if (item is not Dictionary<string, object> itemDict) continue;
string name = ExtractTag(itemXml, "name") ?? ExtractTag(itemXml, "defName");
string stuffDefName = ExtractTag(itemXml, "stuffDefName") ?? ExtractTag(itemXml, "stuff") ?? ExtractTag(itemXml, "material");
string name = TryGetString(itemDict, "name", out string n) ? n :
(TryGetString(itemDict, "defName", out string dn) ? dn : null);
if (string.IsNullOrEmpty(name)) continue;
string stuffDefName = TryGetString(itemDict, "stuffDefName", out string sdn) ? sdn :
(TryGetString(itemDict, "stuff", out string s) ? s :
(TryGetString(itemDict, "material", out string m) ? m : null));
// Extract count
string countStr = ExtractTag(itemXml, "count");
if (string.IsNullOrEmpty(countStr)) continue;
if (!int.TryParse(countStr, out int count)) continue;
if (count <= 0) continue;
if (string.IsNullOrWhiteSpace(name)) continue;
if (!TryGetInt(itemDict, "count", out int count) || count <= 0) continue;
// Search for ThingDef
ThingDef def = null;
// 1. Try exact defName match
def = DefDatabase<ThingDef>.GetNamed(name.Trim(), false);
// 2. Try exact label match (case-insensitive)
if (def == null)
{
foreach (var d in DefDatabase<ThingDef>.AllDefs)
{
if (d.label != null && d.label.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase))
{
def = d;
break;
}
}
}
// 3. Try fuzzy search (thresholded)
if (def == null)
{
var searchResult = ThingDefSearcher.ParseAndSearch(name);
if (searchResult.Count > 0)
{
def = searchResult[0].Def;
}
}
// 4. Closest-match fallback: accept the best similar item even if not an exact match.
if (def == null)
{
ThingDefSearcher.TryFindBestThingDef(name, out ThingDef best, out float score, itemsOnly: true, minScore: 0.15f);
if (best != null && score >= 0.15f)
{
def = best;
substitutions.Add($"'{name}' -> '{best.label}' (score {score:F2})");
}
}
if (def != null)
{
itemsToSpawn.Add((def, count, name, stuffDefName));
AddItem(name, count, stuffDefName, itemsToSpawn, substitutions);
}
}
if (itemsToSpawn.Count == 0)
{
// Fallback: allow natural language without <item> blocks.
if (TryGetString(parsedArgs, "name", out string singleName) && TryGetInt(parsedArgs, "count", out int singleCount))
{
string stuffDefName = TryGetString(parsedArgs, "stuffDefName", out string sdn) ? sdn :
(TryGetString(parsedArgs, "stuff", out string s) ? s :
(TryGetString(parsedArgs, "material", out string m) ? m : null));
AddItem(singleName, singleCount, stuffDefName, itemsToSpawn, substitutions);
}
}
if (itemsToSpawn.Count == 0 && !LooksLikeJson(args))
{
var parsed = ThingDefSearcher.ParseAndSearch(args);
foreach (var r in parsed)
{
@@ -122,7 +76,7 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
if (itemsToSpawn.Count == 0)
{
string msg = "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: {\"items\":[{\"name\":\"Steel\",\"count\":100}]}";
Messages.Message(msg, MessageTypeDefOf.RejectInput);
return msg;
}
@@ -247,7 +201,7 @@ 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"));
// Avoid unresolved named placeholders if the translation system doesn't pick up NamedArguments as expected.
string template = "Wula_ResourceDrop".Translate();
@@ -294,6 +248,49 @@ namespace WulaFallenEmpire.EventSystem.AI.Tools
}
}
private static void AddItem(string name, int count, string stuffDefName, List<(ThingDef def, int count, string requestedName, string stuffDefName)> itemsToSpawn, List<string> substitutions)
{
if (string.IsNullOrWhiteSpace(name) || count <= 0) return;
ThingDef def = DefDatabase<ThingDef>.GetNamed(name.Trim(), false);
if (def == null)
{
foreach (var d in DefDatabase<ThingDef>.AllDefs)
{
if (d.label != null && d.label.Equals(name.Trim(), StringComparison.OrdinalIgnoreCase))
{
def = d;
break;
}
}
}
if (def == null)
{
var searchResult = ThingDefSearcher.ParseAndSearch(name);
if (searchResult.Count > 0)
{
def = searchResult[0].Def;
}
}
if (def == null)
{
ThingDefSearcher.TryFindBestThingDef(name, out ThingDef best, out float score, itemsOnly: true, minScore: 0.15f);
if (best != null && score >= 0.15f)
{
def = best;
substitutions.Add($"'{name}' -> '{best.label}' (score {score:F2})");
}
}
if (def != null)
{
itemsToSpawn.Add((def, count, name, stuffDefName));
}
}
private static Map GetTargetMap()
{
Map map = Find.CurrentMap;