diff --git a/AGENTS.md b/AGENTS.md index 250295d0..fb8ef6d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,8 +9,8 @@ RimMind-Core 是 RimMind AI 模组套件的核心基础设施层。所有子模 1. **LLM 客户端**:OpenAI Chat Completions 兼容,通过 `UnityWebRequest` 发送 2. **异步请求队列**:后台线程发请求,主线程回调,`ConcurrentQueue` 桥接 3. **游戏上下文构建**:将游戏状态打包为中文文本,供 AI Prompt 使用 -4. **Prompt 组装系统**:`StructuredPromptBuilder` + `PromptSection` + `PromptBudget` + `ContextComposer`,支持优先级排序与 Token 预算裁剪 -5. **Provider 注册机制**:子模组通过注册 API 注入上下文段,实现解耦 +4. **Prompt 组装系统**:`StructuredPromptBuilder` + `PromptSection` + `PromptBudget` + `ContextComposer`,支持优先级排序、压缩与 Token 预算裁剪 +5. **Provider 注册机制**:子模组通过注册 API 注入上下文段,支持卸载与覆盖,实现解耦 6. **请求审批悬浮窗**:`RequestOverlay` + `RequestEntry`,子模组可注册待审批请求供玩家选择 7. **设置 UI**:多分页设置界面,子模组可注册额外分页 8. **调试工具**:AI Debug Log 窗口、Dev DebugAction @@ -27,24 +27,24 @@ Source/ │ ├── AIResponse.cs 响应数据结构 │ └── OpenAI/ │ ├── OpenAIClient.cs OpenAI 兼容客户端实现 -│ └── OpenAIDto.cs 请求/响应 DTO +│ └── OpenAIDto.cs 请求/响应 DTO(internal) ├── Core/ │ ├── AIRequestQueue.cs GameComponent,异步请求队列 + 冷却管理 -│ ├── GameContextBuilder.cs 静态工具类,构建地图/Pawn 上下文文本 +│ ├── GameContextBuilder.cs 静态工具类,构建地图/Pawn 上下文文本(含 Section 和精简版本) │ ├── JsonTagExtractor.cs 从 AI 响应提取 JSON 内容 -│ ├── AIDebugLog.cs GameComponent,存储最近 200 条请求记录 +│ ├── AIDebugLog.cs GameComponent,存储最近 200 条请求记录(含 AIDebugEntry) │ └── Prompt/ -│ ├── StructuredPromptBuilder.cs 流式 Prompt 构建器(链式 API) -│ ├── PromptSection.cs Prompt 段落(Tag/Content/Priority/EstimatedTokens) -│ ├── PromptBudget.cs Token 预算管理(按优先级裁剪) +│ ├── StructuredPromptBuilder.cs 流式 Prompt 构建器(链式 API + FromKeyPrefix + ToSection) +│ ├── PromptSection.cs Prompt 段落(Tag/Content/Priority/EstimatedTokens/Compress) +│ ├── PromptBudget.cs Token 预算管理(先压缩后裁剪) │ └── ContextComposer.cs 段落排序 + 历史压缩 ├── Settings/ -│ ├── AICoreSettings.cs 模组设置(API 配置 + 性能 + 调试) -│ └── ContextSettings.cs 上下文过滤器(控制哪些字段注入 Prompt) +│ ├── AICoreSettings.cs 模组设置(API 配置 + 请求控制 + 调试 + 流式) +│ └── ContextSettings.cs 上下文过滤器(28 个 Include* 字段 + ContextPreset 预设) ├── UI/ │ ├── AICoreSettingsUI.cs 多分页设置界面 │ ├── Window_AIDebugLog.cs AI Debug Log 浮动窗口 -│ ├── RequestOverlay.cs 请求审批悬浮窗(可拖拽/可缩放) +│ ├── RequestOverlay.cs 请求审批悬浮窗(可拖拽/可缩放/可持久化位置) │ ├── RequestEntry.cs 悬浮窗请求条目数据结构 │ ├── Window_RequestLog.cs 请求日志窗口 │ └── SettingsUIHelper.cs 设置 UI 辅助工具类 @@ -62,11 +62,11 @@ Source/ 所有子模组通过此静态类与 Core 交互: ```csharp -// 发起 AI 请求 +// ── 请求 ── RimMindAPI.RequestAsync(AIRequest request, Action onComplete) RimMindAPI.RequestImmediate(AIRequest request, Action onComplete) // 绕过队列/冷却 -// 上下文构建 +// ── 上下文构建 ── RimMindAPI.BuildMapContext(Map map, bool brief = false) RimMindAPI.BuildPawnContext(Pawn pawn) RimMindAPI.BuildStaticContext() @@ -75,30 +75,39 @@ RimMindAPI.BuildFullPawnPrompt(Pawn pawn, string? currentQuery, string[]? exclud RimMindAPI.BuildFullPawnPrompt(Pawn pawn, PromptBudget budget, string? currentQuery, string[]? excludeProviders) RimMindAPI.BuildFullPawnSections(Pawn pawn, string? currentQuery, string[]? excludeProviders) -// Provider 注册(子模组在 Mod 构造时调用) -RimMindAPI.RegisterStaticProvider(string category, Func provider, int priority = PromptSection.PriorityAuxiliary) -RimMindAPI.RegisterDynamicProvider(string category, Func provider, int priority = PromptSection.PriorityAuxiliary) -RimMindAPI.RegisterPawnContextProvider(string category, Func provider, int priority = PromptSection.PriorityAuxiliary) +// ── Provider 注册(子模组在 Mod 构造时调用)── +RimMindAPI.RegisterStaticProvider(string category, Func provider, int priority, string modId = "", bool overrideExisting = true) +RimMindAPI.RegisterDynamicProvider(string category, Func provider, int priority, string modId = "", bool overrideExisting = true) +RimMindAPI.RegisterPawnContextProvider(string category, Func provider, int priority, string modId = "", bool overrideExisting = true) -// UI 扩展 +// ── Provider 卸载 ── +RimMindAPI.UnregisterStaticProvider(string category) +RimMindAPI.UnregisterDynamicProvider(string category) +RimMindAPI.UnregisterPawnContextProvider(string category) +RimMindAPI.UnregisterModProviders(string modId) // 卸载某模组注册的所有 Provider + +// ── UI 扩展 ── RimMindAPI.RegisterSettingsTab(string tabId, Func labelFn, Action drawFn) +RimMindAPI.SettingsTabs // IReadOnlyList<(tabId, labelFn, drawFn)> RimMindAPI.RegisterToggleBehavior(string id, Func isActive, Action toggle) +RimMindAPI.HasToggleBehaviors // getter -// 冷却控制 +// ── 冷却控制 ── RimMindAPI.RegisterModCooldown(string modId, Func getCooldownTicks) -RimMindAPI.GetModCooldownGetter(string modId) +RimMindAPI.GetModCooldownGetter(string modId) // Func? +RimMindAPI.ModCooldownGetters // IReadOnlyDictionary> -// 对话触发 +// ── 对话触发 ── RimMindAPI.RegisterDialogueTrigger(Action triggerFn) RimMindAPI.TriggerDialogue(Pawn pawn, string context, Pawn? recipient) RimMindAPI.CanTriggerDialogue // getter -// 请求审批悬浮窗 +// ── 请求审批悬浮窗 ── RimMindAPI.RegisterPendingRequest(RequestEntry entry) -RimMindAPI.GetPendingRequests() +RimMindAPI.GetPendingRequests() // IReadOnlyList RimMindAPI.RemovePendingRequest(RequestEntry entry) -// 状态查询 +// ── 状态查询 ── RimMindAPI.IsConfigured() RimMindAPI.IsAnyToggleActive() RimMindAPI.ToggleAll() @@ -119,6 +128,11 @@ class AIRequest { bool UseJsonMode; // 默认 true,设 false 绕过 response_format } +class ChatMessage { + string Role; // "system" / "user" / "assistant" + string Content; +} + class AIResponse { bool Success; string Content; @@ -150,6 +164,22 @@ var prompt = new StructuredPromptBuilder() 翻译键版本:`RoleFromKey()`, `GoalFromKey()` 等,配合 Keyed 翻译系统使用。 +快捷方式: + +```csharp +// 从翻译键前缀一键构建(自动拼接 .Role/.Goal/.Process/... 后缀) +StructuredPromptBuilder.FromKeyPrefix("RimMind.MyMod") + +// 带翻译键表头的自定义段 +.WithCustom(customText, "RimMind.MyMod.CustomHeader") + +// 启用段落标签输出([角色] [目标] ...) +.WithSectionLabels(true) + +// 直接转为 PromptSection(用于注入上下文系统) +.ToSection(tag: "system_prompt", priority: PromptSection.PriorityCore) +``` + #### PromptSection ```csharp @@ -158,6 +188,7 @@ class PromptSection { string Content; // 段落内容 int Priority; // 优先级(低=重要,不可裁剪) int EstimatedTokens; // 估算 Token 数 + Func? Compress; // 压缩回调(非 null 时可在超预算时压缩而非删除) // 优先级常量 const int PriorityCore = 0; // 核心指令,不可裁剪 @@ -168,6 +199,7 @@ class PromptSection { const int PriorityCustom = 10; // 自定义内容 bool IsTrimable => Priority > PriorityCore; + bool IsCompressible => Compress != null && IsTrimable; static int EstimateTokens(string text); // 混合 CJK/Latin 估算 } ``` @@ -180,12 +212,17 @@ class PromptBudget { int ReserveForOutput = 800; // 为输出预留 int AvailableForInput; // 可用于输入的 Token 数 + PromptBudget() + PromptBudget(int totalBudget, int reserveForOutput = 800) + List Compose(List sections); // 按预算裁剪 string ComposeToString(List sections); // 裁剪后拼接 } ``` -裁剪逻辑:从最高优先级(最不重要)开始删除,直到总 Token 数在预算内。 +裁剪逻辑(两阶段): +1. **压缩阶段**:对 `IsCompressible` 的段,按优先级从高到低调用 `Compress` 回调,直到总 Token 在预算内 +2. **裁剪阶段**:压缩后仍超预算,对 `IsTrimable` 的段按优先级从高到低删除,直到总 Token 在预算内 #### ContextComposer @@ -193,7 +230,24 @@ class PromptBudget { static class ContextComposer { List Reorder(List sections); // 按优先级排序 string BuildFromSections(List sections); // 排序后拼接 - string CompressHistory(string historyText, int maxLines, string summaryLine); // 历史压缩 + string CompressHistory(string historyText, int maxLines = 6, string summaryLine = ""); // 历史压缩 +} +``` + +### GameContextBuilder + +```csharp +static class GameContextBuilder { + // 纯文本版本 + string BuildMapContext(Map map, bool brief = false); + string BuildPawnContext(Pawn pawn); + string BuildCompactPawnContext(Pawn pawn); // 精简版 + string BuildHistoryContext(int maxEntries = 10); + + // PromptSection 版本(带压缩回调,可参与 PromptBudget 压缩) + PromptSection BuildMapContextSection(Map map, bool brief = false); + PromptSection BuildPawnContextSection(Pawn pawn); + PromptSection BuildCompactPawnContextSection(Pawn pawn); } ``` @@ -202,12 +256,12 @@ static class ContextComposer { ``` 1. 静态段 → RegisterStaticProvider 注册的段(Rules、Skills 等) 2. Pawn段 → RegisterPawnContextProvider 注册的段(人格、记忆等) -3. 游戏状态 → BuildPawnContext(pawn)(客观游戏状态) -4. 地图状态 → BuildMapContext(pawn.Map) +3. 游戏状态 → BuildPawnContextSection(pawn)(带压缩回调) +4. 地图状态 → BuildMapContextSection(pawn.Map)(带压缩回调) 5. 动态段 → RegisterDynamicProvider 注册的段(Memory 语义检索等) ``` -所有段落以 `PromptSection` 形式传递,按 `Priority` 排序后拼接。带 `PromptBudget` 的版本会在拼接前裁剪。 +所有段落以 `PromptSection` 形式传递,按 `Priority` 排序后拼接。带 `PromptBudget` 的版本会先压缩再裁剪。 ### JsonTagExtractor @@ -220,9 +274,54 @@ string? raw = JsonTagExtractor.ExtractRaw(aiResponse, "TagName"); List allRaw = JsonTagExtractor.ExtractAllRaw(aiResponse, "TagName"); ``` +### AIRequestQueue + +```csharp +class AIRequestQueue : GameComponent { + static AIRequestQueue Instance { get; } + static void LogFromBackground(string msg, bool isWarning = false); + + void Enqueue(AIRequest request, Action callback, IAIClient client); + void EnqueueImmediate(AIRequest request, Action callback, IAIClient client); + + int GetCooldownTicksLeft(string modId); + int GetQueueDepth(string modId); + void ClearCooldown(string modId); + void ClearAllCooldowns(); + void ClearAllQueues(); + IReadOnlyDictionary GetAllCooldowns(); + IReadOnlyDictionary GetAllQueueDepths(); +} +``` + +### AIDebugLog / AIDebugEntry + +```csharp +class AIDebugLog : GameComponent { + static AIDebugLog? Instance { get; } + IReadOnlyList Entries { get; } + void Clear(); + static void Record(AIRequest request, AIResponse response, int elapsedMs); +} + +class AIDebugEntry { + int GameTick; + string Source; + string ModelName; + string FullSystemPrompt; + string FullUserPrompt; + string FullResponse; + int ElapsedMs; + int TokensUsed; + bool IsError; + string ErrorMsg; + string FormattedTime; // 格式化时间 +} +``` + ### RequestEntry / RequestOverlay -请求审批悬浮窗系统,供子模组注册需要玩家决策的请求: +请求审批悬浮窗系统: ```csharp class RequestEntry { @@ -236,20 +335,77 @@ class RequestEntry { int tick; // 创建时间 int expireTicks; // 过期 tick 数 } + +static class RequestOverlay { + void Register(RequestEntry entry); + IReadOnlyList Pending { get; } + void Remove(RequestEntry entry); + Rect GetWindowRect(); // 获取悬浮窗位置(持久化用) + void SetWindowRect(Rect rect); + void OnGUI(); +} ``` 过期后自动触发最后一个选项(视为"忽略")。 ### SettingsUIHelper -设置 UI 辅助工具类: +```csharp +static class SettingsUIHelper { + DrawSectionHeader(Listing_Standard listing, string label); + DrawCustomPromptSection(Listing_Standard listing, string label, ref string prompt, float height = 80f); + SplitContentArea(inRect); // 分割内容区域 + SplitBottomBar(inRect); // 分割底部栏 + DrawBottomBar(barRect, onReset); // 重置按钮 +} +``` + +### RimMindCoreSettings ```csharp -SettingsUIHelper.DrawSectionHeader(listing, "标题"); -SettingsUIHelper.DrawCustomPromptSection(listing, "标签", ref prompt, height); -SettingsUIHelper.SplitContentArea(inRect); // 分割内容区域 -SettingsUIHelper.SplitBottomBar(inRect); // 分割底部栏 -SettingsUIHelper.DrawBottomBar(barRect, onReset); // 重置按钮 +class RimMindCoreSettings : ModSettings { + string apiKey; + string apiEndpoint; // 默认 "https://api.deepseek.com/v1" + string modelName; // 默认 "deepseek-chat" + bool forceJsonMode; // 默认 true + bool useStreaming; // 流式响应(预留,默认 false) + int maxTokens; // 默认 800 + int maxConcurrentRequests; // 默认 3 + int maxRetryCount; // 默认 2 + int requestTimeoutMs; // 默认 120000 (120秒) + bool debugLogging; + ContextSettings Context; // 上下文过滤器 + string customPawnPrompt; + string customMapPrompt; + bool requestOverlayEnabled; + float requestOverlayX, requestOverlayY, requestOverlayW, requestOverlayH; + + bool IsConfigured(); + override void ExposeData(); +} +``` + +### ContextSettings / ContextPreset + +```csharp +class ContextSettings : IExposable { + // 28 个 Include* bool 字段控制上下文注入 + // 小人信息 (20项): IncludeRace, IncludeAge, IncludeGender, IncludeBackstory, + // IncludeIdeology, IncludeTraits, IncludeSkills, IncludeCapacities, + // IncludeHealth, IncludeMood, IncludeMoodThoughts, IncludeCurrentJob, + // IncludeWorkPriorities, IncludeEquipment, IncludeInventory, IncludeLocation, + // IncludeRelations, IncludeGenes, IncludeSurroundings, IncludeCombatStatus + // 地图/环境信息 (8项): IncludeGameTime, IncludeColonistCount, IncludeColonistNames, + // IncludeWealth, IncludeFood, IncludeSeason, IncludeWeather, IncludeThreats + int MinSkillLevel; // 技能显示阈值(默认 4) + HashSet disabledProviders; // 禁用的 Provider category 列表 + HashSet exposedProviders; // 允许对外暴露的 Provider category 列表 + + void ExposeData(); + void ApplyPreset(ContextPreset preset); +} + +enum ContextPreset { Minimal, Standard, Full, Custom } ``` ## 线程安全规则 @@ -347,15 +503,17 @@ SettingsUIHelper.DrawBottomBar(barRect, onReset); // 重置按钮 ### 命名空间 -- `RimMind.Core` — 顶层(Mod 入口、API) -- `RimMind.Core.Client` — AI 客户端 -- `RimMind.Core.Client.OpenAI` — OpenAI 实现 -- `RimMind.Core.Internal` — 内部组件(队列、上下文构建、日志、JSON 提取) -- `RimMind.Core.Prompt` — Prompt 组装(段落、预算、排序、结构化构建) -- `RimMind.Core.Settings` — 设置 -- `RimMind.Core.UI` — 界面 -- `RimMind.Core.Patch` — Harmony 补丁 -- `RimMind.Core.Debug` — 调试动作 +| 命名空间 | 目录 | 职责 | +|---------|------|------| +| `RimMind.Core` | Source/ 根目录 | Mod 入口、API | +| `RimMind.Core.Client` | Client/ | AI 客户端接口与数据结构 | +| `RimMind.Core.Client.OpenAI` | Client/OpenAI/ | OpenAI 实现 | +| `RimMind.Core.Internal` | Core/ | 内部组件(队列、上下文构建、日志、JSON 提取) | +| `RimMind.Core.Prompt` | Core/Prompt/ | Prompt 组装(段落、预算、排序、结构化构建) | +| `RimMind.Core.Settings` | Settings/ | 设置 | +| `RimMind.Core.UI` | UI/ | 界面 | +| `RimMind.Core.Patch` | Patch/ | Harmony 补丁 | +| `RimMind.Core.Debug` | Debug/ | 调试动作 | ### 序列化 @@ -376,7 +534,7 @@ RimWorld 1.6 的 GameComponent 基类无参构造,但 `Game.InitNewGame` 仍 ### UI 本地化 -所有 UI 文本通过 `Languages/ChineseSimplified/Keyed/RimMind.xml` 的 Keyed 翻译,禁止硬编码中文。代码中使用 `"Key".Translate()`。 +所有 UI 文本通过 `Languages/ChineseSimplified/Keyed/RimMind_Core.xml` 的 Keyed 翻译,禁止硬编码中文。代码中使用 `"Key".Translate()`。 ### Harmony @@ -386,16 +544,21 @@ RimWorld 1.6 的 GameComponent 基类无参构造,但 `Game.InitNewGame` 仍 ### 构建 -- 目标框架:`net48` -- C# 语言版本:9.0 -- RimWorld 版本:1.6 -- 输出路径:`../1.6/Assemblies/` -- 部署:设置 `RIMWORLD_DIR` 环境变量后自动部署 +| 配置项 | 值 | +|--------|-----| +| 目标框架 | `net48` | +| C# 语言版本 | 9.0 | +| Nullable | enable | +| RimWorld 版本 | 1.6 | +| 输出路径 | `../1.6/Assemblies/` | +| 部署 | 设置 `RIMWORLD_DIR` 环境变量后自动部署 | +| NuGet 依赖 | `Krafs.Rimworld.Ref 1.6.*-*`, `Lib.Harmony.Ref 2.*`, `Newtonsoft.Json 13.0.*` | ### 测试 - 单元测试项目:`Tests/`,使用 xUnit,目标 `net10.0` - 测试纯逻辑层,不依赖 RimWorld +- 测试文件直接 `` 引用源码(不引用主项目 DLL) - 已有测试:`JsonTagExtractorTests` ## 扩展指南(子模组开发) @@ -413,7 +576,7 @@ RimWorld 1.6 的 GameComponent 基类无参构造,但 `Game.InitNewGame` 仍 ### 2. 注册 Provider -在 Mod 构造函数中注册: +在 Mod 构造函数中注册,传入 `modId` 以支持卸载: ```csharp public class MyMod : Mod @@ -423,7 +586,7 @@ public class MyMod : Mod RimMindAPI.RegisterPawnContextProvider("my_category", pawn => { return $"[{pawn.Name.ToStringShort} 自定义信息]\n..."; - }, PromptSection.PriorityMemory); + }, PromptSection.PriorityMemory, modId: "MyMod"); RimMindAPI.RegisterSettingsTab("my_tab", () => "我的设置", rect => { @@ -433,7 +596,17 @@ public class MyMod : Mod } ``` -### 3. 发起 AI 请求 +### 3. 卸载 Provider + +模组卸载时清理注册: + +```csharp +RimMindAPI.UnregisterModProviders("MyMod"); +// 或按 category 卸载: +RimMindAPI.UnregisterPawnContextProvider("my_category"); +``` + +### 4. 发起 AI 请求 ```csharp var request = new AIRequest @@ -454,9 +627,10 @@ RimMindAPI.RequestAsync(request, response => }); ``` -### 4. 使用 StructuredPromptBuilder 构建 System Prompt +### 5. 使用 StructuredPromptBuilder 构建 System Prompt ```csharp +// 方式一:逐项构建 var systemPrompt = new StructuredPromptBuilder() .RoleFromKey("RimMind.MyMod.Role") .GoalFromKey("RimMind.MyMod.Goal") @@ -465,11 +639,18 @@ var systemPrompt = new StructuredPromptBuilder() .OutputFromKey("RimMind.MyMod.Output") .ExampleFromKey("RimMind.MyMod.Example") .FallbackFromKey("RimMind.MyMod.Fallback") - .Custom(customPrompt) .Build(); + +// 方式二:从翻译键前缀一键构建 +var systemPrompt = StructuredPromptBuilder.FromKeyPrefix("RimMind.MyMod") + .Build(); + +// 方式三:转为 PromptSection 注入上下文系统 +var section = StructuredPromptBuilder.FromKeyPrefix("RimMind.MyMod") + .ToSection("my_system_prompt", PromptSection.PriorityCore); ``` -### 5. 注册请求审批 +### 6. 注册请求审批 ```csharp RimMindAPI.RegisterPendingRequest(new RequestEntry @@ -487,7 +668,7 @@ RimMindAPI.RegisterPendingRequest(new RequestEntry }); ``` -### 6. 响应格式约定 +### 7. 响应格式约定 所有子模组统一使用 `{JSON}` 格式,AI 可在标签前后输出思考过程: @@ -498,7 +679,7 @@ RimMindAPI.RegisterPendingRequest(new RequestEntry ``` -### 7. 冷却机制 +### 8. 冷却机制 - Core 层冷却:`globalCooldownTicks`(默认 3600),按 ModId 独立 - 子模组通过 `RegisterModCooldown` 注册自定义冷却 Getter diff --git a/About/About.xml b/About/About.xml index b9ee00e8..17d283f6 100644 --- a/About/About.xml +++ b/About/About.xml @@ -3,6 +3,7 @@ RimMind - Core mcocdaa mcocdaa.RimMindCore + 0.0.1
  • 1.6
  • diff --git a/CLAUDE.md b/CLAUDE.md index 250295d0..24d2f3ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,8 +9,8 @@ RimMind-Core 是 RimMind AI 模组套件的核心基础设施层。所有子模 1. **LLM 客户端**:OpenAI Chat Completions 兼容,通过 `UnityWebRequest` 发送 2. **异步请求队列**:后台线程发请求,主线程回调,`ConcurrentQueue` 桥接 3. **游戏上下文构建**:将游戏状态打包为中文文本,供 AI Prompt 使用 -4. **Prompt 组装系统**:`StructuredPromptBuilder` + `PromptSection` + `PromptBudget` + `ContextComposer`,支持优先级排序与 Token 预算裁剪 -5. **Provider 注册机制**:子模组通过注册 API 注入上下文段,实现解耦 +4. **Prompt 组装系统**:`StructuredPromptBuilder` + `PromptSection` + `PromptBudget` + `ContextComposer`,支持优先级排序、压缩与 Token 预算裁剪 +5. **Provider 注册机制**:子模组通过注册 API 注入上下文段,支持卸载与覆盖,实现解耦 6. **请求审批悬浮窗**:`RequestOverlay` + `RequestEntry`,子模组可注册待审批请求供玩家选择 7. **设置 UI**:多分页设置界面,子模组可注册额外分页 8. **调试工具**:AI Debug Log 窗口、Dev DebugAction @@ -27,24 +27,24 @@ Source/ │ ├── AIResponse.cs 响应数据结构 │ └── OpenAI/ │ ├── OpenAIClient.cs OpenAI 兼容客户端实现 -│ └── OpenAIDto.cs 请求/响应 DTO +│ └── OpenAIDto.cs 请求/响应 DTO(internal) ├── Core/ │ ├── AIRequestQueue.cs GameComponent,异步请求队列 + 冷却管理 -│ ├── GameContextBuilder.cs 静态工具类,构建地图/Pawn 上下文文本 +│ ├── GameContextBuilder.cs 静态工具类,构建地图/Pawn 上下文文本(含 Section 和精简版本) │ ├── JsonTagExtractor.cs 从 AI 响应提取 JSON 内容 -│ ├── AIDebugLog.cs GameComponent,存储最近 200 条请求记录 +│ ├── AIDebugLog.cs GameComponent,存储最近 200 条请求记录(含 AIDebugEntry) │ └── Prompt/ -│ ├── StructuredPromptBuilder.cs 流式 Prompt 构建器(链式 API) -│ ├── PromptSection.cs Prompt 段落(Tag/Content/Priority/EstimatedTokens) -│ ├── PromptBudget.cs Token 预算管理(按优先级裁剪) +│ ├── StructuredPromptBuilder.cs 流式 Prompt 构建器(链式 API + FromKeyPrefix + ToSection) +│ ├── PromptSection.cs Prompt 段落(Tag/Content/Priority/EstimatedTokens/Compress) +│ ├── PromptBudget.cs Token 预算管理(先压缩后裁剪) │ └── ContextComposer.cs 段落排序 + 历史压缩 ├── Settings/ -│ ├── AICoreSettings.cs 模组设置(API 配置 + 性能 + 调试) -│ └── ContextSettings.cs 上下文过滤器(控制哪些字段注入 Prompt) +│ ├── AICoreSettings.cs 模组设置(API 配置 + 性能 + 调试 + 流式) +│ └── ContextSettings.cs 上下文过滤器(21 个 Include* 字段 + ContextPreset 预设) ├── UI/ │ ├── AICoreSettingsUI.cs 多分页设置界面 │ ├── Window_AIDebugLog.cs AI Debug Log 浮动窗口 -│ ├── RequestOverlay.cs 请求审批悬浮窗(可拖拽/可缩放) +│ ├── RequestOverlay.cs 请求审批悬浮窗(可拖拽/可缩放/可持久化位置) │ ├── RequestEntry.cs 悬浮窗请求条目数据结构 │ ├── Window_RequestLog.cs 请求日志窗口 │ └── SettingsUIHelper.cs 设置 UI 辅助工具类 @@ -62,11 +62,11 @@ Source/ 所有子模组通过此静态类与 Core 交互: ```csharp -// 发起 AI 请求 +// ── 请求 ── RimMindAPI.RequestAsync(AIRequest request, Action onComplete) RimMindAPI.RequestImmediate(AIRequest request, Action onComplete) // 绕过队列/冷却 -// 上下文构建 +// ── 上下文构建 ── RimMindAPI.BuildMapContext(Map map, bool brief = false) RimMindAPI.BuildPawnContext(Pawn pawn) RimMindAPI.BuildStaticContext() @@ -75,30 +75,39 @@ RimMindAPI.BuildFullPawnPrompt(Pawn pawn, string? currentQuery, string[]? exclud RimMindAPI.BuildFullPawnPrompt(Pawn pawn, PromptBudget budget, string? currentQuery, string[]? excludeProviders) RimMindAPI.BuildFullPawnSections(Pawn pawn, string? currentQuery, string[]? excludeProviders) -// Provider 注册(子模组在 Mod 构造时调用) -RimMindAPI.RegisterStaticProvider(string category, Func provider, int priority = PromptSection.PriorityAuxiliary) -RimMindAPI.RegisterDynamicProvider(string category, Func provider, int priority = PromptSection.PriorityAuxiliary) -RimMindAPI.RegisterPawnContextProvider(string category, Func provider, int priority = PromptSection.PriorityAuxiliary) +// ── Provider 注册(子模组在 Mod 构造时调用)── +RimMindAPI.RegisterStaticProvider(string category, Func provider, int priority, string modId = "", bool overrideExisting = true) +RimMindAPI.RegisterDynamicProvider(string category, Func provider, int priority, string modId = "", bool overrideExisting = true) +RimMindAPI.RegisterPawnContextProvider(string category, Func provider, int priority, string modId = "", bool overrideExisting = true) -// UI 扩展 +// ── Provider 卸载 ── +RimMindAPI.UnregisterStaticProvider(string category) +RimMindAPI.UnregisterDynamicProvider(string category) +RimMindAPI.UnregisterPawnContextProvider(string category) +RimMindAPI.UnregisterModProviders(string modId) // 卸载某模组注册的所有 Provider + +// ── UI 扩展 ── RimMindAPI.RegisterSettingsTab(string tabId, Func labelFn, Action drawFn) +RimMindAPI.SettingsTabs // IReadOnlyList<(tabId, labelFn, drawFn)> RimMindAPI.RegisterToggleBehavior(string id, Func isActive, Action toggle) +RimMindAPI.HasToggleBehaviors // getter -// 冷却控制 +// ── 冷却控制 ── RimMindAPI.RegisterModCooldown(string modId, Func getCooldownTicks) -RimMindAPI.GetModCooldownGetter(string modId) +RimMindAPI.GetModCooldownGetter(string modId) // Func? +RimMindAPI.ModCooldownGetters // IReadOnlyDictionary> -// 对话触发 +// ── 对话触发 ── RimMindAPI.RegisterDialogueTrigger(Action triggerFn) RimMindAPI.TriggerDialogue(Pawn pawn, string context, Pawn? recipient) RimMindAPI.CanTriggerDialogue // getter -// 请求审批悬浮窗 +// ── 请求审批悬浮窗 ── RimMindAPI.RegisterPendingRequest(RequestEntry entry) -RimMindAPI.GetPendingRequests() +RimMindAPI.GetPendingRequests() // IReadOnlyList RimMindAPI.RemovePendingRequest(RequestEntry entry) -// 状态查询 +// ── 状态查询 ── RimMindAPI.IsConfigured() RimMindAPI.IsAnyToggleActive() RimMindAPI.ToggleAll() @@ -119,6 +128,11 @@ class AIRequest { bool UseJsonMode; // 默认 true,设 false 绕过 response_format } +class ChatMessage { + string Role; // "system" / "user" / "assistant" + string Content; +} + class AIResponse { bool Success; string Content; @@ -150,6 +164,22 @@ var prompt = new StructuredPromptBuilder() 翻译键版本:`RoleFromKey()`, `GoalFromKey()` 等,配合 Keyed 翻译系统使用。 +快捷方式: + +```csharp +// 从翻译键前缀一键构建(自动拼接 .Role/.Goal/.Process/... 后缀) +StructuredPromptBuilder.FromKeyPrefix("RimMind.MyMod") + +// 带翻译键表头的自定义段 +.WithCustom(customText, "RimMind.MyMod.CustomHeader") + +// 启用段落标签输出([角色] [目标] ...) +.WithSectionLabels(true) + +// 直接转为 PromptSection(用于注入上下文系统) +.ToSection(tag: "system_prompt", priority: PromptSection.PriorityCore) +``` + #### PromptSection ```csharp @@ -158,6 +188,7 @@ class PromptSection { string Content; // 段落内容 int Priority; // 优先级(低=重要,不可裁剪) int EstimatedTokens; // 估算 Token 数 + Func? Compress; // 压缩回调(非 null 时可在超预算时压缩而非删除) // 优先级常量 const int PriorityCore = 0; // 核心指令,不可裁剪 @@ -168,6 +199,7 @@ class PromptSection { const int PriorityCustom = 10; // 自定义内容 bool IsTrimable => Priority > PriorityCore; + bool IsCompressible => Compress != null && IsTrimable; static int EstimateTokens(string text); // 混合 CJK/Latin 估算 } ``` @@ -180,12 +212,17 @@ class PromptBudget { int ReserveForOutput = 800; // 为输出预留 int AvailableForInput; // 可用于输入的 Token 数 + PromptBudget() + PromptBudget(int totalBudget, int reserveForOutput = 800) + List Compose(List sections); // 按预算裁剪 string ComposeToString(List sections); // 裁剪后拼接 } ``` -裁剪逻辑:从最高优先级(最不重要)开始删除,直到总 Token 数在预算内。 +裁剪逻辑(两阶段): +1. **压缩阶段**:对 `IsCompressible` 的段,按优先级从高到低调用 `Compress` 回调,直到总 Token 在预算内 +2. **裁剪阶段**:压缩后仍超预算,对 `IsTrimable` 的段按优先级从高到低删除,直到总 Token 在预算内 #### ContextComposer @@ -193,7 +230,24 @@ class PromptBudget { static class ContextComposer { List Reorder(List sections); // 按优先级排序 string BuildFromSections(List sections); // 排序后拼接 - string CompressHistory(string historyText, int maxLines, string summaryLine); // 历史压缩 + string CompressHistory(string historyText, int maxLines = 6, string summaryLine = ""); // 历史压缩 +} +``` + +### GameContextBuilder + +```csharp +static class GameContextBuilder { + // 纯文本版本 + string BuildMapContext(Map map, bool brief = false); + string BuildPawnContext(Pawn pawn); + string BuildCompactPawnContext(Pawn pawn); // 精简版 + string BuildHistoryContext(int maxEntries = 10); + + // PromptSection 版本(带压缩回调,可参与 PromptBudget 压缩) + PromptSection BuildMapContextSection(Map map, bool brief = false); + PromptSection BuildPawnContextSection(Pawn pawn); + PromptSection BuildCompactPawnContextSection(Pawn pawn); } ``` @@ -202,12 +256,12 @@ static class ContextComposer { ``` 1. 静态段 → RegisterStaticProvider 注册的段(Rules、Skills 等) 2. Pawn段 → RegisterPawnContextProvider 注册的段(人格、记忆等) -3. 游戏状态 → BuildPawnContext(pawn)(客观游戏状态) -4. 地图状态 → BuildMapContext(pawn.Map) +3. 游戏状态 → BuildPawnContextSection(pawn)(带压缩回调) +4. 地图状态 → BuildMapContextSection(pawn.Map)(带压缩回调) 5. 动态段 → RegisterDynamicProvider 注册的段(Memory 语义检索等) ``` -所有段落以 `PromptSection` 形式传递,按 `Priority` 排序后拼接。带 `PromptBudget` 的版本会在拼接前裁剪。 +所有段落以 `PromptSection` 形式传递,按 `Priority` 排序后拼接。带 `PromptBudget` 的版本会先压缩再裁剪。 ### JsonTagExtractor @@ -220,9 +274,54 @@ string? raw = JsonTagExtractor.ExtractRaw(aiResponse, "TagName"); List allRaw = JsonTagExtractor.ExtractAllRaw(aiResponse, "TagName"); ``` +### AIRequestQueue + +```csharp +class AIRequestQueue : GameComponent { + static AIRequestQueue Instance { get; } + static void LogFromBackground(string msg, bool isWarning = false); + + void Enqueue(AIRequest request, Action callback, IAIClient client); + void EnqueueImmediate(AIRequest request, Action callback, IAIClient client); + + int GetCooldownTicksLeft(string modId); + int GetQueueDepth(string modId); + void ClearCooldown(string modId); + void ClearAllCooldowns(); + void ClearAllQueues(); + IReadOnlyDictionary GetAllCooldowns(); + IReadOnlyDictionary GetAllQueueDepths(); +} +``` + +### AIDebugLog / AIDebugEntry + +```csharp +class AIDebugLog : GameComponent { + static AIDebugLog? Instance { get; } + IReadOnlyList Entries { get; } + void Clear(); + static void Record(AIRequest request, AIResponse response, int elapsedMs); +} + +class AIDebugEntry { + int GameTick; + string Source; + string ModelName; + string FullSystemPrompt; + string FullUserPrompt; + string FullResponse; + int ElapsedMs; + int TokensUsed; + bool IsError; + string ErrorMsg; + string FormattedTime; // 格式化时间 +} +``` + ### RequestEntry / RequestOverlay -请求审批悬浮窗系统,供子模组注册需要玩家决策的请求: +请求审批悬浮窗系统: ```csharp class RequestEntry { @@ -236,20 +335,67 @@ class RequestEntry { int tick; // 创建时间 int expireTicks; // 过期 tick 数 } + +static class RequestOverlay { + void Register(RequestEntry entry); + IReadOnlyList Pending { get; } + void Remove(RequestEntry entry); + Rect GetWindowRect(); // 获取悬浮窗位置(持久化用) + void SetWindowRect(Rect rect); + void OnGUI(); +} ``` 过期后自动触发最后一个选项(视为"忽略")。 ### SettingsUIHelper -设置 UI 辅助工具类: +```csharp +static class SettingsUIHelper { + DrawSectionHeader(Listing_Standard listing, string label); + DrawCustomPromptSection(Listing_Standard listing, string label, ref string prompt, float height = 80f); + SplitContentArea(inRect); // 分割内容区域 + SplitBottomBar(inRect); // 分割底部栏 + DrawBottomBar(barRect, onReset); // 重置按钮 +} +``` + +### RimMindCoreSettings ```csharp -SettingsUIHelper.DrawSectionHeader(listing, "标题"); -SettingsUIHelper.DrawCustomPromptSection(listing, "标签", ref prompt, height); -SettingsUIHelper.SplitContentArea(inRect); // 分割内容区域 -SettingsUIHelper.SplitBottomBar(inRect); // 分割底部栏 -SettingsUIHelper.DrawBottomBar(barRect, onReset); // 重置按钮 +class RimMindCoreSettings : ModSettings { + string apiKey; + string apiEndpoint; + string modelName; + bool forceJsonMode; + bool useStreaming; // 流式响应(预留) + int maxTokens; + bool debugLogging; + ContextSettings Context; // 上下文过滤器 + string customPawnPrompt; + string customMapPrompt; + bool requestOverlayEnabled; + float requestOverlayX, requestOverlayY, requestOverlayW, requestOverlayH; + + bool IsConfigured(); + override void ExposeData(); +} +``` + +### ContextSettings / ContextPreset + +```csharp +class ContextSettings : IExposable { + // 21 个 Include* bool 字段控制上下文注入 + // 例:IncludePawnSkills, IncludePawnNeeds, IncludeMapWeather ... + int MinSkillLevel; // 技能显示阈值 + List disabledProviders; // 禁用的 Provider category 列表 + + void ExposeData(); + void ApplyPreset(ContextPreset preset); +} + +enum ContextPreset { Minimal, Standard, Full, Custom } ``` ## 线程安全规则 @@ -347,15 +493,17 @@ SettingsUIHelper.DrawBottomBar(barRect, onReset); // 重置按钮 ### 命名空间 -- `RimMind.Core` — 顶层(Mod 入口、API) -- `RimMind.Core.Client` — AI 客户端 -- `RimMind.Core.Client.OpenAI` — OpenAI 实现 -- `RimMind.Core.Internal` — 内部组件(队列、上下文构建、日志、JSON 提取) -- `RimMind.Core.Prompt` — Prompt 组装(段落、预算、排序、结构化构建) -- `RimMind.Core.Settings` — 设置 -- `RimMind.Core.UI` — 界面 -- `RimMind.Core.Patch` — Harmony 补丁 -- `RimMind.Core.Debug` — 调试动作 +| 命名空间 | 目录 | 职责 | +|---------|------|------| +| `RimMind.Core` | Source/ 根目录 | Mod 入口、API | +| `RimMind.Core.Client` | Client/ | AI 客户端接口与数据结构 | +| `RimMind.Core.Client.OpenAI` | Client/OpenAI/ | OpenAI 实现 | +| `RimMind.Core.Internal` | Core/ | 内部组件(队列、上下文构建、日志、JSON 提取) | +| `RimMind.Core.Prompt` | Core/Prompt/ | Prompt 组装(段落、预算、排序、结构化构建) | +| `RimMind.Core.Settings` | Settings/ | 设置 | +| `RimMind.Core.UI` | UI/ | 界面 | +| `RimMind.Core.Patch` | Patch/ | Harmony 补丁 | +| `RimMind.Core.Debug` | Debug/ | 调试动作 | ### 序列化 @@ -376,7 +524,7 @@ RimWorld 1.6 的 GameComponent 基类无参构造,但 `Game.InitNewGame` 仍 ### UI 本地化 -所有 UI 文本通过 `Languages/ChineseSimplified/Keyed/RimMind.xml` 的 Keyed 翻译,禁止硬编码中文。代码中使用 `"Key".Translate()`。 +所有 UI 文本通过 `Languages/ChineseSimplified/Keyed/RimMind_Core.xml` 的 Keyed 翻译,禁止硬编码中文。代码中使用 `"Key".Translate()`。 ### Harmony @@ -386,16 +534,21 @@ RimWorld 1.6 的 GameComponent 基类无参构造,但 `Game.InitNewGame` 仍 ### 构建 -- 目标框架:`net48` -- C# 语言版本:9.0 -- RimWorld 版本:1.6 -- 输出路径:`../1.6/Assemblies/` -- 部署:设置 `RIMWORLD_DIR` 环境变量后自动部署 +| 配置项 | 值 | +|--------|-----| +| 目标框架 | `net48` | +| C# 语言版本 | 9.0 | +| Nullable | enable | +| RimWorld 版本 | 1.6 | +| 输出路径 | `../1.6/Assemblies/` | +| 部署 | 设置 `RIMWORLD_DIR` 环境变量后自动部署 | +| NuGet 依赖 | `Krafs.Rimworld.Ref 1.6.*-*`, `Lib.Harmony.Ref 2.*`, `Newtonsoft.Json 13.0.*` | ### 测试 - 单元测试项目:`Tests/`,使用 xUnit,目标 `net10.0` - 测试纯逻辑层,不依赖 RimWorld +- 测试文件直接 `` 引用源码(不引用主项目 DLL) - 已有测试:`JsonTagExtractorTests` ## 扩展指南(子模组开发) @@ -413,7 +566,7 @@ RimWorld 1.6 的 GameComponent 基类无参构造,但 `Game.InitNewGame` 仍 ### 2. 注册 Provider -在 Mod 构造函数中注册: +在 Mod 构造函数中注册,传入 `modId` 以支持卸载: ```csharp public class MyMod : Mod @@ -423,7 +576,7 @@ public class MyMod : Mod RimMindAPI.RegisterPawnContextProvider("my_category", pawn => { return $"[{pawn.Name.ToStringShort} 自定义信息]\n..."; - }, PromptSection.PriorityMemory); + }, PromptSection.PriorityMemory, modId: "MyMod"); RimMindAPI.RegisterSettingsTab("my_tab", () => "我的设置", rect => { @@ -433,7 +586,17 @@ public class MyMod : Mod } ``` -### 3. 发起 AI 请求 +### 3. 卸载 Provider + +模组卸载时清理注册: + +```csharp +RimMindAPI.UnregisterModProviders("MyMod"); +// 或按 category 卸载: +RimMindAPI.UnregisterPawnContextProvider("my_category"); +``` + +### 4. 发起 AI 请求 ```csharp var request = new AIRequest @@ -454,9 +617,10 @@ RimMindAPI.RequestAsync(request, response => }); ``` -### 4. 使用 StructuredPromptBuilder 构建 System Prompt +### 5. 使用 StructuredPromptBuilder 构建 System Prompt ```csharp +// 方式一:逐项构建 var systemPrompt = new StructuredPromptBuilder() .RoleFromKey("RimMind.MyMod.Role") .GoalFromKey("RimMind.MyMod.Goal") @@ -465,11 +629,18 @@ var systemPrompt = new StructuredPromptBuilder() .OutputFromKey("RimMind.MyMod.Output") .ExampleFromKey("RimMind.MyMod.Example") .FallbackFromKey("RimMind.MyMod.Fallback") - .Custom(customPrompt) .Build(); + +// 方式二:从翻译键前缀一键构建 +var systemPrompt = StructuredPromptBuilder.FromKeyPrefix("RimMind.MyMod") + .Build(); + +// 方式三:转为 PromptSection 注入上下文系统 +var section = StructuredPromptBuilder.FromKeyPrefix("RimMind.MyMod") + .ToSection("my_system_prompt", PromptSection.PriorityCore); ``` -### 5. 注册请求审批 +### 6. 注册请求审批 ```csharp RimMindAPI.RegisterPendingRequest(new RequestEntry @@ -487,7 +658,7 @@ RimMindAPI.RegisterPendingRequest(new RequestEntry }); ``` -### 6. 响应格式约定 +### 7. 响应格式约定 所有子模组统一使用 `{JSON}` 格式,AI 可在标签前后输出思考过程: @@ -498,7 +669,7 @@ RimMindAPI.RegisterPendingRequest(new RequestEntry ``` -### 7. 冷却机制 +### 8. 冷却机制 - Core 层冷却:`globalCooldownTicks`(默认 3600),按 ModId 独立 - 子模组通过 `RegisterModCooldown` 注册自定义冷却 Getter diff --git a/INTRODUCTION.md b/INTRODUCTION.md index f1bd7377..66f33198 100644 --- a/INTRODUCTION.md +++ b/INTRODUCTION.md @@ -10,7 +10,7 @@ RimMind 套件的核心基础设施,为 AI 驱动的 RimWorld 体验提供底 **模块化 API 设计** - 其他 RimMind 组件通过统一接口调用,无需关心底层实现细节。 -**上下文过滤器** - 30+ 个可配置选项,精确控制注入 Prompt 的信息量,节省 Token 开销。 +**上下文过滤器** - 28+ 个可配置选项,精确控制注入 Prompt 的信息量,节省 Token 开销。 **调试工具集** - 内置 AI 请求日志窗口,实时查看每次 AI 调用的输入输出,方便排查问题。 @@ -41,7 +41,7 @@ The core infrastructure of the RimMind suite, providing foundational support for **Modular API Design** - Other RimMind components call through a unified interface without worrying about underlying implementation details. -**Context Filter** - 30+ configurable options for fine-grained control over what information gets injected into AI prompts, saving token costs. +**Context Filter** - 28+ configurable options for fine-grained control over what information gets injected into AI prompts, saving token costs. **Debug Toolkit** - Built-in AI request log window for real-time viewing of inputs and outputs for each AI call, facilitating troubleshooting. diff --git a/INTRODUCTION.txt b/INTRODUCTION.txt new file mode 100644 index 00000000..c4eb0e17 --- /dev/null +++ b/INTRODUCTION.txt @@ -0,0 +1,74 @@ +[h1]RimMind - Core[/h1] +RimMind 套件的核心基础设施,提供 LLM 客户端、异步请求队列和游戏上下文构建器,是所有 RimMind 子模组的前置依赖。 + +[h2] RimMind 是什么 [/h2] +RimMind 是一套 AI 驱动的 RimWorld 模组套件,通过接入大语言模型 (LLM),让殖民者拥有人格、记忆、对话和自主决策能力。 + +[h2] 子模组列表与依赖关系 [/h2] +[table][tr][td][b] 模组 [/b][/td][td][b] 职责 [/b][/td][td][b] 依赖 [/b][/td][td][b] GitHub [/b][/td][/tr][tr][td][b] RimMind-Core [/b][/td][td] API 客户端、请求调度、上下文打包 [/td][td] Harmony [/td][td][url=https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Core] 链接 [/url][/td][/tr][tr][td] RimMind-Actions [/td][td] AI 控制小人的动作执行库 [/td][td] Core [/td][td][url=https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Actions] 链接 [/url][/td][/tr][tr][td] RimMind-Advisor [/td][td] AI 扮演小人做出工作决策 [/td][td] Core, Actions [/td][td][url=https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Advisor] 链接 [/url][/td][/tr][tr][td] RimMind-Dialogue [/td][td] AI 驱动的对话系统 [/td][td] Core [/td][td][url=https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Dialogue] 链接 [/url][/td][/tr][tr][td] RimMind-Memory [/td][td] 记忆采集与上下文注入 [/td][td] Core [/td][td][url=https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Memory] 链接 [/url][/td][/tr][tr][td] RimMind-Personality [/td][td] AI 生成人格与想法 [/td][td] Core [/td][td][url=https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Personality] 链接 [/url][/td][/tr][tr][td] RimMind-Storyteller [/td][td] AI 叙事者,智能选择事件 [/td][td] Core [/td][td][url=https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Storyteller] 链接 [/url][/td][/tr][/table] +[code]Core ── Actions ── Advisor + ├── Dialogue + ├── Memory + ├── Personality + └── Storyteller[/code] + +[h2] 核心能力 [/h2] +[b] 智能上下文构建 [/b] - 自动采集游戏状态:殖民者心情、健康、技能、装备、地图财富、威胁等级、季节天气等,为 AI 决策提供完整情报。 +[b] 异步请求队列 [/b] - 智能管理 LLM 请求,避免阻塞游戏主线程,支持请求优先级和冷却控制。 +[b] 模块化 API 设计 [/b] - 其他 RimMind 组件通过统一接口调用,无需关心底层实现细节。 +[b] 上下文过滤器 [/b] - 28+ 个可配置选项,精确控制注入 Prompt 的信息量,节省 Token 开销。 +[b] 调试工具集 [/b] - 内置 AI 请求日志窗口,实时查看每次 AI 调用的输入输出,方便排查问题。 + +[h2] 技术亮点 [/h2] +[list] +[*] 兼容 OpenAI / DeepSeek / Ollama 等所有 OpenAI Chat Completions 格式的 API +[*] 可配置的上下文过滤器,提供最小 / 标准 / 完整三种预设 +[*] JSON 模式支持,确保 AI 返回结构化数据 +[*] 请求悬浮窗,实时显示 AI 请求状态 +[/list] + +[h2] 支持的 API 端点 [/h2] +[table][tr][td][b] 服务 [/b][/td][td][b] 端点 [/b][/td][td][b] 说明 [/b][/td][/tr][tr][td] OpenAI [/td][td] https://api.openai.com/v1 [/td][td] GPT-4o-mini 等模型 [/td][/tr][tr][td] DeepSeek [/td][td] https://api.deepseek.com/v1 [/td][td] deepseek-chat 等模型 [/td][/tr][tr][td] Ollama (本地) [/td][td] http://localhost:11434/v1 [/td][td] 本地部署的模型 [/td][/tr][tr][td] 其他 [/td][td] 填入 Base URL [/td][td] 任何 OpenAI 兼容接口 [/td][/tr][/table] + +[h2] 核心功能 [/h2] +[b] LLM 客户端 [/b] +兼容 OpenAI / DeepSeek / 本地 Ollama 等所有 OpenAI Chat Completions 格式的 API。支持 JSON 强制模式(response_format: json_object),本地模型可关闭。 + +[b] 异步请求队列 [/b] +所有 AI 请求在后台线程执行,不阻塞游戏主线程。每个请求独立冷却,过期请求自动丢弃。 + +[b] 上下文构建 [/b] +自动采集游戏状态并打包为文本供各模块使用: +[list] +[*] [b]地图上下文[/b]:时间、殖民者、食物、威胁、季节、天气 +[*] [b]小人上下文[/b]:年龄、背景、心情、健康、技能、装备、工作分配、关系 +[/list] + +[b] 上下文过滤器 [/b] +通过"上下文过滤"设置页精确控制哪些游戏信息注入 Prompt,节省 Token。提供最小/标准/完整三种预设,也可自定义勾选 28+ 个选项。 + +[b] 调试工具 [/b] +[list] +[*] [b]AI Debug Log[/b]:浮动窗口,查看每次 AI 调用的完整 Prompt + Response +[*] [b]请求悬浮窗[/b]:右下角实时显示 AI 请求状态 +[*] [b]Dev 菜单[/b]:测试连接、查看上下文、清除冷却等 +[/list] + +[h2] 设置项 [/h2] +[table][tr][td][b] 设置 [/b][/td][td][b] 默认值 [/b][/td][td][b] 说明 [/b][/td][/tr][tr][td] API Key [/td][td] - [/td][td] 你的 API 密钥 [/td][/tr][tr][td] API 端点 [/td][td] https://api.deepseek.com/v1 [/td][td] OpenAI 兼容端点 [/td][/tr][tr][td] 模型名称 [/td][td] deepseek-chat [/td][td] 任意模型 ID [/td][/tr][tr][td] 强制 JSON 模式 [/td][td] 开启 [/td][td] 不支持的本地模型请关闭 [/td][/tr][tr][td] 最大 Token [/td][td] 800 [/td][td] 响应长度上限 [/td][/tr][tr][td] 最大并发请求数 [/td][td] 3 [/td][td] 同时等待响应的请求上限 [/td][/tr][tr][td] 最大重试次数 [/td][td] 2 [/td][td] 请求失败后重试次数 [/td][/tr][tr][td] 请求超时 [/td][td] 120 秒 [/td][td] 单次请求超时时间 [/td][/tr][tr][td] 详细日志 [/td][td] 关闭 [/td][td] 输出到 Player.log [/td][/tr][tr][td] 请求悬浮窗 [/td][td] 开启 [/td][td] 右下角显示请求状态 [/td][/tr][/table] + +[h2] 常见问题 [/h2] +[b] Q: 支持哪些大模型? [/b] +A: 任何兼容 OpenAI Chat Completions API 的模型均可使用,包括 OpenAI GPT 系列、DeepSeek、本地 Ollama 等。 + +[b] Q: 会不会影响游戏帧率? [/b] +A: 不会。所有 AI 请求在后台线程执行,主线程只处理回调结果。 + +[b] Q: API Key 安全吗? [/b] +A: API Key 仅存储在本地 RimWorld 设置文件中;发起 AI 请求时会发送给你配置的 API 端点,不会上传到 RimMind 自有服务器。 + +[b] Q: 不填 API Key 会怎样? [/b] +A: Core 本身不会报错,但所有依赖 AI 的子模组功能将无法工作。 + +[b] Q: 推荐用什么模型? [/b] +A: 推荐使用 gpt-4o-mini 或 deepseek-chat,性价比高且响应速度快。本地 Ollama 用户可使用 qwen2.5:7b 等模型。 diff --git a/Languages/ChineseSimplified/Keyed/RimMind_Core.xml b/Languages/ChineseSimplified/Keyed/RimMind_Core.xml index 34cc0d14..d531ac74 100644 --- a/Languages/ChineseSimplified/Keyed/RimMind_Core.xml +++ b/Languages/ChineseSimplified/Keyed/RimMind_Core.xml @@ -7,6 +7,11 @@ 复制响应 (点击左侧条目查看详情) {0} 条记录 + tok + 重试 + 等待 + 尝试 + 排队等待 (空) @@ -43,6 +48,40 @@ 模型行为 请求设置 调试 + 最大并发请求数 + 同时向 AI 发送请求的上限。增大可提高响应速度,但会增加 API 费用。 + 最大重试次数 + 请求失败后的最大重试次数。设为 0 则不重试。 + 请求超时(秒) + 等待 AI 响应的最大时间,超时后请求将被取消。网络较慢或模型较大时可适当增大。 + 队列:已暂停 + 队列:运行中 + 队列状态已移至「队列」标签页查看 + 队列 + 队列尚未初始化(需进入游戏存档后可用) + 总体状态 + 活跃请求 + 排队请求 + 本地模型 + 忙碌 + 空闲 + 恢复队列 + 暂停队列 + 清空队列 + 清空冷却 + 各 Mod 队列 + 暂无注册的 Mod 队列 + 冷却 + 就绪 + 排队 + 活跃请求详情 + 当前无活跃请求 + 排队请求详情 + 当前无排队请求 + 优先级 + 尝试 + 已用时 + 等待 这是一个测试信息,请用一句话回复。 @@ -64,33 +103,65 @@ 种族 + 将小人的种族信息注入 AI 上下文。 年龄 + 将小人的年龄注入 AI 上下文。 性别 + 将小人的性别注入 AI 上下文。 背景故事 + 将小人的童年和成年背景故事注入 AI 上下文。 意识形态(需 Ideology DLC) + 将小人的意识形态和信念注入 AI 上下文。 特性 + 将小人的性格特性注入 AI 上下文。 技能 + 将小人的技能等级注入 AI 上下文。低于最低技能等级的技能会被过滤。 最低技能等级 + 低于此等级的技能不会注入 AI 上下文,可节省 Token。 健康状况 + 将小人的健康问题(伤口、疾病、义体等)注入 AI 上下文。 + 行动能力 + 将小人的身体行动能力(操纵、移动、视觉等)注入 AI 上下文。 心情 + 将小人的当前心情百分比注入 AI 上下文。 心情因子(前8条) + 将影响心情的前8条因子注入 AI 上下文。 当前任务 + 将小人正在执行的任务注入 AI 上下文。 工作分配 + 将小人的工作优先级分配注入 AI 上下文。 武器与服装 + 将小人装备的武器和服装注入 AI 上下文。 + 携带物品 + 将小人携带的物品(背包)注入 AI 上下文。 位置与温度 + 将小人所在区域(室内/室外/房间类型)和温度注入 AI 上下文。 社交关系 + 将小人与其他人的社交关系注入 AI 上下文。 基因(需 Biotech DLC) + 将小人的基因信息注入 AI 上下文。 周围环境 + 将小人周围的建筑、物品和动物注入 AI 上下文。 战斗状态 + 将小人是否在战斗中、是否被征召等状态注入 AI 上下文。 游戏时间 + 将当前游戏日期和时间注入 AI 上下文。 殖民者数量 + 将殖民者和囚犯数量注入 AI 上下文。 + 殖民者名单 + 将殖民者和囚犯的名字注入 AI 上下文。 财富等级 + 将殖民地财富和威胁等级注入 AI 上下文。 食物储量 + 将殖民地的食物营养总量注入 AI 上下文。 季节 + 将当前季节注入 AI 上下文。 天气 + 将当前天气状况注入 AI 上下文。 威胁等级 + 将当前地图的威胁等级注入 AI 上下文。 [当前地图状态] @@ -129,6 +200,8 @@ 特性:{0} 武器:{0} 服装:{0} + 携带:{0} + 损坏 室内 室外 位置:{0} 温度:{1}°C @@ -166,6 +239,9 @@ 全局地图提示词 追加在地图上下文末尾的自定义提示词(留空则不追加): + + ({0}条更早的记录已省略) + [玩家自定义人物提示词] [玩家自定义地图提示词] diff --git a/Languages/English/Keyed/RimMind_Core.xml b/Languages/English/Keyed/RimMind_Core.xml index 5956994d..bdfa71ab 100644 --- a/Languages/English/Keyed/RimMind_Core.xml +++ b/Languages/English/Keyed/RimMind_Core.xml @@ -7,6 +7,11 @@ Copy Response (Click an entry on the left to view details) {0} records + tok + retry + wait + attempt + queueWait (empty) @@ -43,6 +48,40 @@ Model Behavior Request Settings Debug + Max Concurrent Requests + Maximum number of simultaneous AI requests. Higher values improve responsiveness but increase API costs. + Max Retry Count + Maximum number of retries after a request failure. Set to 0 to disable retries. + Request Timeout (seconds) + Maximum time to wait for an AI response before timing out. Increase for slow models or networks. + Queue: PAUSED + Queue: Running + Queue status has been moved to the "Queue" tab + Queue + Queue not initialized (available after loading a save) + Overall Status + Active Requests + Queued Requests + Local Model + Busy + Idle + Resume Queue + Pause Queue + Clear Queues + Clear Cooldowns + Per-Mod Queues + No registered mod queues + Cooldown + Ready + Queued + Active Request Details + No active requests + Queued Request Details + No queued requests + Priority + Attempt + Elapsed + Waiting This is a test message, please reply in one sentence. @@ -64,33 +103,65 @@ Race + Inject the pawn's race info into AI context. Age + Inject the pawn's age into AI context. Gender + Inject the pawn's gender into AI context. Backstory + Inject the pawn's childhood and adulthood backstories into AI context. Ideology (requires Ideology DLC) + Inject the pawn's ideology and beliefs into AI context. Traits + Inject the pawn's character traits into AI context. Skills + Inject the pawn's skill levels into AI context. Skills below the minimum level are filtered out. Min Skill Level + Skills below this level are not injected into AI context, saving tokens. Health + Inject the pawn's health issues (wounds, diseases, implants, etc.) into AI context. + Capacities + Inject the pawn's body capacities (manipulation, moving, sight, etc.) into AI context. Mood + Inject the pawn's current mood percentage into AI context. Mood Factors (top 8) + Inject the top 8 mood-affecting factors into AI context. Current Job + Inject the pawn's current job into AI context. Work Assignments + Inject the pawn's work priority assignments into AI context. Weapons & Apparel + Inject the pawn's equipped weapons and apparel into AI context. + Carried Items + Inject the pawn's carried items (inventory) into AI context. Location & Temperature + Inject the pawn's location (indoors/outdoors/room type) and local temperature into AI context. Relations + Inject the pawn's social relationships into AI context. Genes (requires Biotech DLC) + Inject the pawn's gene information into AI context. Surroundings + Inject nearby buildings, items, and animals into AI context. Combat Status + Inject whether the pawn is in combat or drafted into AI context. Game Time + Inject the current game date and time into AI context. Colonist Count + Inject the colonist and prisoner counts into AI context. + Colonist Names + Inject the names of colonists and prisoners into AI context. Wealth Level + Inject the colony wealth and threat level into AI context. Food Storage + Inject the colony's total food nutrition into AI context. Season + Inject the current season into AI context. Weather + Inject the current weather conditions into AI context. Threat Level + Inject the current map threat level into AI context. [Current Map Status] @@ -129,6 +200,8 @@ Traits: {0} Weapon: {0} Apparel: {0} + Carried: {0} + damaged Indoors Outdoors Location: {0} Temp: {1}°C @@ -166,6 +239,9 @@ Global Map Prompt Custom prompt appended after map context (leave empty to skip): + + ({0} earlier records omitted) + [Player Custom Pawn Prompt] [Player Custom Map Prompt] diff --git a/README.md b/README.md index c84b8042..bff847c9 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,13 @@ RimMind 是一套 AI 驱动的 RimWorld 模组套件,通过接入大语言模 | 模组 | 职责 | 依赖 | GitHub | |------|------|------|--------| -| **RimMind-Core** | API 客户端、请求调度、上下文打包 | Harmony | [链接](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Core) | -| RimMind-Actions | AI 控制小人的动作执行库 | Core | [链接](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Actions) | -| RimMind-Advisor | AI 扮演小人做出工作决策 | Core, Actions | [链接](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Advisor) | -| RimMind-Dialogue | AI 驱动的对话系统 | Core | [链接](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Dialogue) | -| RimMind-Memory | 记忆采集与上下文注入 | Core | [链接](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Memory) | -| RimMind-Personality | AI 生成人格与想法 | Core | [链接](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Personality) | -| RimMind-Storyteller | AI 叙事者,智能选择事件 | Core | [链接](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Storyteller) | +| **RimMind-Core** | API 客户端、请求调度、上下文打包 | Harmony | [链接](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Core) | +| RimMind-Actions | AI 控制小人的动作执行库 | Core | [链接](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Actions) | +| RimMind-Advisor | AI 扮演小人做出工作决策 | Core, Actions | [链接](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Advisor) | +| RimMind-Dialogue | AI 驱动的对话系统 | Core | [链接](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Dialogue) | +| RimMind-Memory | 记忆采集与上下文注入 | Core | [链接](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Memory) | +| RimMind-Personality | AI 生成人格与想法 | Core | [链接](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Personality) | +| RimMind-Storyteller | AI 叙事者,智能选择事件 | Core | [链接](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Storyteller) | ``` Core ── Actions ── Advisor @@ -32,14 +32,14 @@ Core ── Actions ── Advisor **Linux/macOS:** ```bash -git clone git@github.com:mcocdaa/RimWorld-RimMind-Mod-Core.git +git clone git@github.com:RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Core.git cd RimWorld-RimMind-Mod-Core ./script/deploy-single.sh ``` **Windows:** ```powershell -git clone git@github.com:mcocdaa/RimWorld-RimMind-Mod-Core.git +git clone git@github.com:RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Core.git cd RimWorld-RimMind-Mod-Core ./script/deploy-single.ps1 ``` @@ -70,8 +70,8 @@ cd RimWorld-RimMind-Mod-Core | 服务 | 端点 | 说明 | |------|------|------| +| DeepSeek | `https://api.deepseek.com/v1` | deepseek-chat 等模型(默认) | | OpenAI | `https://api.openai.com/v1` | GPT-4o-mini 等模型 | -| DeepSeek | `https://api.deepseek.com/v1` | deepseek-chat 等模型 | | Ollama (本地) | `http://localhost:11434/v1` | 本地部署的模型 | | 其他 | 填入 Base URL | 任何 OpenAI 兼容接口 | @@ -100,7 +100,7 @@ cd RimWorld-RimMind-Mod-Core ### 上下文过滤器 -通过"上下文过滤"设置页精确控制哪些游戏信息注入 Prompt,节省 Token。提供最小/标准/完整三种预设,也可自定义勾选 30+ 个选项。 +通过"上下文过滤"设置页精确控制哪些游戏信息注入 Prompt,节省 Token。提供最小/标准/完整三种预设,也可自定义勾选 28+ 个选项。 ### 调试工具 @@ -113,12 +113,18 @@ cd RimWorld-RimMind-Mod-Core | 设置 | 默认值 | 说明 | |------|--------|------| | API Key | - | 你的 API 密钥 | -| API 端点 | `https://api.openai.com/v1` | OpenAI 兼容端点 | -| 模型名称 | `gpt-4o-mini` | 任意模型 ID | +| API 端点 | `https://api.deepseek.com/v1` | OpenAI 兼容端点 | +| 模型名称 | `deepseek-chat` | 任意模型 ID | | 强制 JSON 模式 | 开启 | 不支持的本地模型请关闭 | -| 最大 Token | 800 | 响应长度上限 | +| 最大 Token | 800 | 响应长度上限(200-2000) | +| 最大并发请求数 | 3 | 同时发送请求的上限(1-10) | +| 最大重试次数 | 2 | 请求失败后重试次数(0-5) | +| 请求超时 | 120秒 | 等待 AI 响应的最大时间(10-300秒) | | 详细日志 | 关闭 | 输出到 Player.log | | 请求悬浮窗 | 开启 | 右下角显示请求状态 | +| 自定义人物提示词 | 空 | 追加在人物上下文末尾 | +| 自定义地图提示词 | 空 | 追加在地图上下文末尾 | +| 上下文过滤器 | 标准 | 28+ 个可选项,三种预设 | ## 常见问题 @@ -165,13 +171,13 @@ RimMind is an AI-driven RimWorld mod suite that connects to Large Language Model | Module | Role | Depends On | GitHub | |--------|------|------------|--------| -| **RimMind-Core** | API client, request dispatch, context packaging | Harmony | [Link](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Core) | -| RimMind-Actions | AI-controlled pawn action execution | Core | [Link](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Actions) | -| RimMind-Advisor | AI role-plays colonists for work decisions | Core, Actions | [Link](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Advisor) | -| RimMind-Dialogue | AI-driven dialogue system | Core | [Link](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Dialogue) | -| RimMind-Memory | Memory collection & context injection | Core | [Link](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Memory) | -| RimMind-Personality | AI-generated personality & thoughts | Core | [Link](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Personality) | -| RimMind-Storyteller | AI storyteller, smart event selection | Core | [Link](https://github.com/mcocdaa/RimWorld-RimMind-Mod-Storyteller) | +| **RimMind-Core** | API client, request dispatch, context packaging | Harmony | [Link](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Core) | +| RimMind-Actions | AI-controlled pawn action execution | Core | [Link](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Actions) | +| RimMind-Advisor | AI role-plays colonists for work decisions | Core, Actions | [Link](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Advisor) | +| RimMind-Dialogue | AI-driven dialogue system | Core | [Link](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Dialogue) | +| RimMind-Memory | Memory collection & context injection | Core | [Link](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Memory) | +| RimMind-Personality | AI-generated personality & thoughts | Core | [Link](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Personality) | +| RimMind-Storyteller | AI storyteller, smart event selection | Core | [Link](https://github.com/RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Storyteller) | ## Installation @@ -179,14 +185,14 @@ RimMind is an AI-driven RimWorld mod suite that connects to Large Language Model **Linux/macOS:** ```bash -git clone git@github.com:mcocdaa/RimWorld-RimMind-Mod-Core.git +git clone git@github.com:RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Core.git cd RimWorld-RimMind-Mod-Core ./script/deploy-single.sh ``` **Windows:** ```powershell -git clone git@github.com:mcocdaa/RimWorld-RimMind-Mod-Core.git +git clone git@github.com:RimWorld-RimMind-Mod/RimWorld-RimMind-Mod-Core.git cd RimWorld-RimMind-Mod-Core ./script/deploy-single.ps1 ``` @@ -213,8 +219,8 @@ cd RimWorld-RimMind-Mod-Core | Service | Endpoint | Notes | |---------|----------|-------| +| DeepSeek | `https://api.deepseek.com/v1` | deepseek-chat etc. (default) | | OpenAI | `https://api.openai.com/v1` | GPT-4o-mini etc. | -| DeepSeek | `https://api.deepseek.com/v1` | deepseek-chat etc. | | Ollama (local) | `http://localhost:11434/v1` | Locally deployed models | | Others | Enter Base URL | Any OpenAI-compatible API | @@ -223,7 +229,7 @@ cd RimWorld-RimMind-Mod-Core - **LLM Client**: Compatible with OpenAI / DeepSeek / local Ollama and any OpenAI Chat Completions API - **Async Request Queue**: All AI requests run on background threads, never blocking the game - **Context Builder**: Automatically collects game state (colonist stats, map info, etc.) for AI prompts -- **Context Filter**: Fine-grained control over what game info gets sent to AI, with Minimal/Standard/Full presets +- **Context Filter**: Fine-grained control over what game info gets sent to AI, with Minimal/Standard/Full presets and 28+ configurable options - **Debug Tools**: AI Debug Log window, request overlay, Dev menu actions ## FAQ diff --git a/Source/AICoreAPI.cs b/Source/AICoreAPI.cs index 19101de7..0d6bf49c 100644 --- a/Source/AICoreAPI.cs +++ b/Source/AICoreAPI.cs @@ -16,8 +16,8 @@ public static class RimMindAPI { // ── Provider 注册表 ─────────────────────────────────────────────────── - private static readonly Dictionary provider, int priority)> - _staticProviders = new Dictionary, int)>(); + private static readonly Dictionary provider, int priority)> + _staticProviders = new Dictionary, int)>(); private static readonly Dictionary provider, int priority)> _dynamicProviders = new Dictionary, int)>(); @@ -36,6 +36,15 @@ private static readonly Dictionary> _modCooldownGetters private static Action? _dialogueTriggerFn; + private static readonly Dictionary> _dialogueSkipChecks + = new Dictionary>(); + + private static readonly Dictionary> _floatMenuSkipChecks + = new Dictionary>(); + + private static readonly Dictionary> _actionSkipChecks + = new Dictionary>(); + // ── 核心请求 API ────────────────────────────────────────────────────── public static void RequestAsync(AIRequest request, Action onComplete) @@ -72,6 +81,25 @@ public static void RequestImmediate(AIRequest request, Action onComp queue.EnqueueImmediate(request, onComplete, client); } + public static bool CancelRequest(string requestId) + => AIRequestQueue.Instance?.CancelRequest(requestId) ?? false; + + public static void PauseQueue() => AIRequestQueue.Instance?.PauseQueue(); + + public static void ResumeQueue() => AIRequestQueue.Instance?.ResumeQueue(); + + public static bool IsQueuePaused => AIRequestQueue.Instance?.IsPaused ?? false; + + public static int ActiveRequestCount => AIRequestQueue.Instance?.ActiveRequestCount ?? 0; + + public static IReadOnlyList GetActiveRequests() + => AIRequestQueue.Instance?.GetActiveRequests() ?? new List(); + + public static IReadOnlyList GetAllQueuedRequests() + => AIRequestQueue.Instance?.GetAllQueuedRequests() ?? new List(); + + public static int TotalQueuedCount => AIRequestQueue.Instance?.TotalQueuedCount ?? 0; + // ── 上下文构建 ──────────────────────────────────────────────────────── public static string BuildMapContext(Map map, bool brief = false) @@ -237,7 +265,7 @@ public static List BuildFullPawnSections( // ── Provider 注册(去重/覆盖) ────────────────────────────────────────── - public static void RegisterStaticProvider(string category, Func provider, + public static void RegisterStaticProvider(string category, Func provider, int priority = PromptSection.PriorityAuxiliary, string modId = "", bool overrideExisting = true) { if (_staticProviders.ContainsKey(category)) @@ -279,6 +307,49 @@ public static void RegisterPawnContextProvider(string category, Func 0 && !ctx.exposedProviders.Contains(category)) return null; + try { return entry.provider(pawn); } + catch (System.Exception ex) { Log.Warning($"[RimMind] GetProviderData '{category}' error: {ex.Message}"); return null; } + } + + public static string? GetStaticProviderData(string category) + { + if (!_staticProviders.TryGetValue(category, out var entry)) return null; + var ctx = RimMindCoreMod.Settings?.Context; + if (ctx?.exposedProviders.Count > 0 && !ctx.exposedProviders.Contains(category)) return null; + try { return entry.provider(); } + catch (System.Exception ex) { Log.Warning($"[RimMind] GetStaticProviderData '{category}' error: {ex.Message}"); return null; } + } + + public static string? GetDynamicProviderData(string category, string query) + { + if (!_dynamicProviders.TryGetValue(category, out var entry)) return null; + var ctx = RimMindCoreMod.Settings?.Context; + if (ctx?.exposedProviders.Count > 0 && !ctx.exposedProviders.Contains(category)) return null; + try { return entry.provider(query); } + catch (System.Exception ex) { Log.Warning($"[RimMind] GetDynamicProviderData '{category}' error: {ex.Message}"); return null; } + } + + public static List GetRegisteredCategories() + { + var all = new HashSet(); + all.UnionWith(_staticProviders.Keys); + all.UnionWith(_pawnProviders.Keys); + all.UnionWith(_dynamicProviders.Keys); + + var ctx = RimMindCoreMod.Settings?.Context; + if (ctx?.exposedProviders.Count > 0) + all.IntersectWith(ctx.exposedProviders); + + return all.ToList(); + } + // ── Provider 卸载 ────────────────────────────────────────────────────── public static void UnregisterPawnContextProvider(string category) @@ -350,6 +421,127 @@ public static void TriggerDialogue(Pawn pawn, string context, Pawn? recipient = _dialogueTriggerFn(pawn, context, recipient); } + // ── SkipCheck API ────────────────────────────────────────────────── + + public static void RegisterDialogueSkipCheck(string sourceId, Func skipCheck) + { + _dialogueSkipChecks[sourceId] = skipCheck; + } + + public static void UnregisterDialogueSkipCheck(string sourceId) + => _dialogueSkipChecks.Remove(sourceId); + + public static bool ShouldSkipDialogue(Pawn pawn, string triggerType) + { + foreach (var kvp in _dialogueSkipChecks.Values.ToList()) + { + try + { + if (kvp(pawn, triggerType)) return true; + } + catch (System.Exception ex) + { + Log.Warning($"[RimMind] DialogueSkipCheck error: {ex.Message}"); + } + } + return false; + } + + public static void RegisterFloatMenuSkipCheck(string sourceId, Func skipCheck) + { + _floatMenuSkipChecks[sourceId] = skipCheck; + } + + public static void UnregisterFloatMenuSkipCheck(string sourceId) + => _floatMenuSkipChecks.Remove(sourceId); + + public static bool ShouldSkipFloatMenu() + { + foreach (var check in _floatMenuSkipChecks.Values.ToList()) + { + try + { + if (check()) return true; + } + catch (System.Exception ex) + { + Log.Warning($"[RimMind] FloatMenuSkipCheck error: {ex.Message}"); + } + } + return false; + } + + // ── ActionSkipCheck API ────────────────────────────────────────────── + + public static void RegisterActionSkipCheck(string sourceId, Func skipCheck) + { + _actionSkipChecks[sourceId] = skipCheck; + } + + public static void UnregisterActionSkipCheck(string sourceId) + => _actionSkipChecks.Remove(sourceId); + + public static bool ShouldSkipAction(string intentId) + { + foreach (var check in _actionSkipChecks.Values.ToList()) + { + try + { + if (check(intentId)) return true; + } + catch (System.Exception ex) + { + Log.Warning($"[RimMind] ActionSkipCheck error: {ex.Message}"); + } + } + return false; + } + + // ── Incident Cooldown API ──────────────────────────────────────────── + + private static readonly List _incidentExecutedCallbacks = new List(); + + public static void RegisterIncidentExecutedCallback(Action callback) + { + _incidentExecutedCallbacks.Add(callback); + } + + public static void NotifyIncidentExecuted() + { + foreach (var cb in _incidentExecutedCallbacks.ToList()) + { + try { cb(); } + catch (System.Exception ex) { Log.Warning($"[RimMind] IncidentExecuted callback error: {ex.Message}"); } + } + } + + public static void UnregisterIncidentExecutedCallback(Action callback) + { + _incidentExecutedCallbacks.Remove(callback); + } + + private static readonly List> _storytellerIncidentSkipChecks = new List>(); + + public static void RegisterStorytellerIncidentSkipCheck(Func check) + { + _storytellerIncidentSkipChecks.Add(check); + } + + public static void UnregisterStorytellerIncidentSkipCheck(Func check) + { + _storytellerIncidentSkipChecks.Remove(check); + } + + public static bool ShouldSkipStorytellerIncident() + { + foreach (var check in _storytellerIncidentSkipChecks.ToList()) + { + try { if (check()) return true; } + catch (System.Exception ex) { Log.Warning($"[RimMind] StorytellerIncidentSkipCheck error: {ex.Message}"); } + } + return false; + } + // ── RequestOverlay API ──────────────────────────────────────────────── public static void RegisterPendingRequest(RequestEntry entry) diff --git a/Source/Client/AIRequest.cs b/Source/Client/AIRequest.cs index 47212453..f1a2b35b 100644 --- a/Source/Client/AIRequest.cs +++ b/Source/Client/AIRequest.cs @@ -20,11 +20,15 @@ public class AIRequest public int ExpireAtTicks { get; set; } public bool UseJsonMode { get; set; } = true; + + public AIRequestPriority Priority { get; set; } = AIRequestPriority.Normal; + + public int MaxRetryCount { get; set; } = -1; } public class ChatMessage { - public string Role { get; set; } = string.Empty; // "system" | "user" | "assistant" + public string Role { get; set; } = string.Empty; public string Content { get; set; } = string.Empty; } } diff --git a/Source/Client/AIRequestState.cs b/Source/Client/AIRequestState.cs new file mode 100644 index 00000000..01962e55 --- /dev/null +++ b/Source/Client/AIRequestState.cs @@ -0,0 +1,18 @@ +namespace RimMind.Core.Client +{ + public enum AIRequestState + { + Queued = 0, + Processing = 1, + Completed = 2, + Error = 3, + Cancelled = 4 + } + + public enum AIRequestPriority + { + High = 0, + Normal = 1, + Low = 2 + } +} diff --git a/Source/Client/AIResponse.cs b/Source/Client/AIResponse.cs index 9c8c40ee..8783c73c 100644 --- a/Source/Client/AIResponse.cs +++ b/Source/Client/AIResponse.cs @@ -6,15 +6,24 @@ public class AIResponse public string Content { get; set; } = string.Empty; public string Error { get; set; } = string.Empty; public int TokensUsed { get; set; } - - /// 与对应 AIRequest.RequestId 相同,用于日志追踪。 public string RequestId { get; set; } = string.Empty; + public AIRequestState State { get; set; } = AIRequestState.Queued; + public AIRequestPriority Priority { get; set; } = AIRequestPriority.Normal; + public int QueuePosition { get; set; } + public int AttemptCount { get; set; } = 1; + public long QueueWaitMs { get; set; } + public long ProcessingMs { get; set; } + public long HttpStatusCode { get; set; } + public int RequestPayloadBytes { get; set; } + public string CancelReason { get; set; } = string.Empty; + public static AIResponse Failure(string requestId, string error) => new AIResponse { Success = false, Error = error, - RequestId = requestId + RequestId = requestId, + State = AIRequestState.Error }; public static AIResponse Ok(string requestId, string content, int tokens) => new AIResponse @@ -22,7 +31,17 @@ public class AIResponse Success = true, Content = content, TokensUsed = tokens, - RequestId = requestId + RequestId = requestId, + State = AIRequestState.Completed + }; + + public static AIResponse Cancelled(string requestId, string reason) => new AIResponse + { + Success = false, + Error = reason, + RequestId = requestId, + State = AIRequestState.Cancelled, + CancelReason = reason }; } } diff --git a/Source/Client/IAIClient.cs b/Source/Client/IAIClient.cs index b7360343..dffed084 100644 --- a/Source/Client/IAIClient.cs +++ b/Source/Client/IAIClient.cs @@ -4,10 +4,10 @@ namespace RimMind.Core.Client { public interface IAIClient { - /// 发送请求,返回完整响应。线程安全,可在后台线程调用。 Task SendAsync(AIRequest request); - /// API Key 等必要配置是否已填写。 bool IsConfigured(); + + bool IsLocalEndpoint { get; } } } diff --git a/Source/Client/OpenAI/OpenAIClient.cs b/Source/Client/OpenAI/OpenAIClient.cs index a5665e3d..800c1c74 100644 --- a/Source/Client/OpenAI/OpenAIClient.cs +++ b/Source/Client/OpenAI/OpenAIClient.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Linq; +using System.Net; using System.Text; using System.Threading.Tasks; using RimMind.Core.Client; @@ -13,12 +14,18 @@ namespace RimMind.Core.Client.OpenAI { - /// - /// OpenAI 兼容 API 客户端(支持 OpenAI / DeepSeek / 本地 Ollama 等)。 - /// 使用 UnityWebRequest(RimWorld 内置),在协程外通过 async/await + Task.Delay 轮询。 - /// public class OpenAIClient : IAIClient { + private sealed class AIHttpException : Exception + { + public long StatusCode { get; } + + public AIHttpException(long statusCode, string message) : base(message) + { + StatusCode = statusCode; + } + } + private readonly RimMindCoreSettings _settings; public OpenAIClient(RimMindCoreSettings settings) @@ -28,6 +35,19 @@ public OpenAIClient(RimMindCoreSettings settings) public bool IsConfigured() => _settings.IsConfigured(); + public bool IsLocalEndpoint => IsLoopbackEndpoint(_settings.apiEndpoint); + + private static bool IsLoopbackEndpoint(string endpoint) + { + if (string.IsNullOrEmpty(endpoint)) return false; + if (!Uri.TryCreate(endpoint.Trim(), UriKind.Absolute, out var uri)) return false; + if (uri.IsLoopback) return true; + string host = uri.Host; + if (string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase)) return true; + if (string.Equals(host, "host.docker.internal", StringComparison.OrdinalIgnoreCase)) return true; + return false; + } + public async Task SendAsync(AIRequest request) { string endpoint = FormatEndpoint(_settings.apiEndpoint); @@ -39,7 +59,7 @@ public async Task SendAsync(AIRequest request) var sw = Stopwatch.StartNew(); try { - string responseText = await PostAsync(endpoint, json); + (string responseText, long httpStatusCode) = await PostAsync(endpoint, json); var parsed = JsonConvert.DeserializeObject(responseText); string content = parsed?.choices?[0]?.message?.content ?? string.Empty; int tokens = parsed?.usage?.total_tokens ?? 0; @@ -49,36 +69,50 @@ public async Task SendAsync(AIRequest request) AIRequestQueue.LogFromBackground($"[RimMind] ← {request.RequestId} ({tokens} tok)\n{content}"); var response = AIResponse.Ok(request.RequestId, content, tokens); + response.ProcessingMs = sw.ElapsedMilliseconds; + response.HttpStatusCode = httpStatusCode; + response.RequestPayloadBytes = Encoding.UTF8.GetByteCount(json); + response.Priority = request.Priority; + AIDebugLog.Record(request, response, (int)sw.ElapsedMilliseconds); + return response; + } + catch (AIHttpException ex) + { + sw.Stop(); + AIRequestQueue.LogFromBackground($"[RimMind] Request failed ({request.RequestId}): {ex.Message}", isWarning: true); + var response = AIResponse.Failure(request.RequestId, ex.Message); + response.ProcessingMs = sw.ElapsedMilliseconds; + response.HttpStatusCode = ex.StatusCode; + response.RequestPayloadBytes = Encoding.UTF8.GetByteCount(json); + response.Priority = request.Priority; AIDebugLog.Record(request, response, (int)sw.ElapsedMilliseconds); return response; } catch (Exception ex) { sw.Stop(); - // 通过安全队列在主线程输出,避免后台线程直接写 Log 引发枚举冲突 AIRequestQueue.LogFromBackground($"[RimMind] Request failed ({request.RequestId}): {ex.Message}", isWarning: true); var response = AIResponse.Failure(request.RequestId, ex.Message); + response.ProcessingMs = sw.ElapsedMilliseconds; + response.RequestPayloadBytes = Encoding.UTF8.GetByteCount(json); + response.Priority = request.Priority; AIDebugLog.Record(request, response, (int)sw.ElapsedMilliseconds); return response; } } - // ── 请求体构建 ──────────────────────────────────────────────────────────── - private string BuildRequestJson(AIRequest request) { List messages; if (request.Messages != null && request.Messages.Count > 0) { - // 多轮模式(AIDialogue) messages = request.Messages .Select(m => new MessageDto { role = m.Role, content = m.Content }) .ToList(); } else { - // 单轮模式 messages = new List(); if (!string.IsNullOrEmpty(request.SystemPrompt)) messages.Add(new MessageDto { role = "system", content = request.SystemPrompt }); @@ -101,17 +135,15 @@ private string BuildRequestJson(AIRequest request) new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore }); } - // ── HTTP ────────────────────────────────────────────────────────────────── - - private async Task PostAsync(string url, string jsonBody) + private async Task<(string text, long statusCode)> PostAsync(string url, string jsonBody) { - bool isLocal = url.Contains("localhost") || url.Contains("127.0.0.1"); + bool isLocal = IsLoopbackEndpoint(url); float connectTimeout = isLocal ? 300f : 60f; float readTimeout = 60f; using var webRequest = new UnityWebRequest(url, "POST"); webRequest.uploadHandler = new UploadHandlerRaw( - System.Text.Encoding.UTF8.GetBytes(jsonBody)); + Encoding.UTF8.GetBytes(jsonBody)); webRequest.downloadHandler = new DownloadHandlerBuffer(); webRequest.SetRequestHeader("Content-Type", "application/json"); webRequest.SetRequestHeader("Authorization", $"Bearer {_settings.apiKey}"); @@ -144,16 +176,16 @@ private async Task PostAsync(string url, string jsonBody) } } - if (webRequest.result == UnityEngine.Networking.UnityWebRequest.Result.ConnectionError || - webRequest.result == UnityEngine.Networking.UnityWebRequest.Result.ProtocolError) + if (webRequest.result == UnityWebRequest.Result.ConnectionError || + webRequest.result == UnityWebRequest.Result.ProtocolError) { string body = webRequest.downloadHandler.text; string unityErr = webRequest.error ?? ""; string detail = body.Length > 0 ? body : unityErr; - throw new Exception($"HTTP {webRequest.responseCode}: {detail}"); + throw new AIHttpException(webRequest.responseCode, $"HTTP {webRequest.responseCode}: {detail}"); } - return webRequest.downloadHandler.text; + return (webRequest.downloadHandler.text, webRequest.responseCode); } private static string FormatEndpoint(string baseUrl) diff --git a/Source/Core/AIDebugLog.cs b/Source/Core/AIDebugLog.cs index d6d62159..a06a6aae 100644 --- a/Source/Core/AIDebugLog.cs +++ b/Source/Core/AIDebugLog.cs @@ -1,6 +1,5 @@ using System.Collections.Concurrent; using System.Collections.Generic; -using System.Linq; using RimMind.Core.Client; using Verse; @@ -27,6 +26,7 @@ public override void GameComponentTick() { while (_pendingEntries.TryDequeue(out var entry)) { + entry.GameTick = Find.TickManager.TicksGame; if (_entries.Count >= MaxEntries) _entries.RemoveAt(0); _entries.Add(entry); @@ -39,7 +39,6 @@ public static void Record(AIRequest request, AIResponse response, int elapsedMs) { _instance?._pendingEntries.Enqueue(new AIDebugEntry { - GameTick = Find.TickManager.TicksGame, Source = request.RequestId, ModelName = RimMindCoreMod.Settings.modelName, FullSystemPrompt = request.SystemPrompt, @@ -51,6 +50,13 @@ public static void Record(AIRequest request, AIResponse response, int elapsedMs) TokensUsed = response.TokensUsed, IsError = !response.Success, ErrorMsg = response.Error, + Priority = response.Priority, + State = response.State, + AttemptCount = response.AttemptCount, + QueueWaitMs = response.QueueWaitMs, + ProcessingMs = response.ProcessingMs, + HttpStatusCode = response.HttpStatusCode, + RequestPayloadBytes = response.RequestPayloadBytes, }); } } @@ -68,7 +74,14 @@ public class AIDebugEntry public bool IsError { get; set; } public string ErrorMsg { get; set; } = string.Empty; - /// 格式化为游戏时间形式。 + public AIRequestPriority Priority { get; set; } + public AIRequestState State { get; set; } + public int AttemptCount { get; set; } + public long QueueWaitMs { get; set; } + public long ProcessingMs { get; set; } + public long HttpStatusCode { get; set; } + public int RequestPayloadBytes { get; set; } + public string FormattedTime { get diff --git a/Source/Core/AIRequestQueue.cs b/Source/Core/AIRequestQueue.cs index e36ac298..7ad96242 100644 --- a/Source/Core/AIRequestQueue.cs +++ b/Source/Core/AIRequestQueue.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; using System.Threading.Tasks; using RimMind.Core.Client; using RimMind.Core.Settings; @@ -16,11 +18,33 @@ public class AIRequestQueue : GameComponent private readonly ConcurrentQueue<(string msg, bool isWarning)> _pendingLogs = new ConcurrentQueue<(string, bool)>(); + private enum FireResultKind { Complete, Retry } + + private struct PendingFireResult + { + public FireResultKind Kind; + public TrackedRequest Tracked; + public AIResponse Response; + } + + private readonly ConcurrentQueue _pendingFireResults + = new ConcurrentQueue(); + private readonly Dictionary _modCooldowns = new Dictionary(); - private readonly Dictionary> _modQueues = new Dictionary>(); + private readonly Dictionary> _modQueues + = new Dictionary>(); + + private readonly Dictionary _activeRequests + = new Dictionary(); + + private readonly Dictionary _requestIdToActive + = new Dictionary(); private int _lastQueueProcessTick; + private int _nextTrackingId; + private bool _isPaused; + private bool _isProcessingLocalRequest; private const int QueueProcessInterval = 60; @@ -35,8 +59,25 @@ public AIRequestQueue(Game game) _instance = this; } - public override void StartedNewGame() { _modCooldowns.Clear(); ClearAllQueues(); } - public override void LoadedGame() { _modCooldowns.Clear(); ClearAllQueues(); } + public override void StartedNewGame() + { + _modCooldowns.Clear(); + ClearAllQueues(); + _activeRequests.Clear(); + _requestIdToActive.Clear(); + _isProcessingLocalRequest = false; + _isPaused = false; + } + + public override void LoadedGame() + { + _modCooldowns.Clear(); + ClearAllQueues(); + _activeRequests.Clear(); + _requestIdToActive.Clear(); + _isProcessingLocalRequest = false; + _isPaused = false; + } public override void GameComponentTick() { @@ -46,6 +87,11 @@ public override void GameComponentTick() else Log.Message(log.msg); } + while (_pendingFireResults.TryDequeue(out var fireResult)) + { + ProcessFireResult(fireResult); + } + while (_results.TryDequeue(out var item)) { try { item.callback?.Invoke(item.response); } @@ -55,6 +101,8 @@ public override void GameComponentTick() } } + CheckActiveRequestTimeouts(); + int now = Find.TickManager.TicksGame; if (now - _lastQueueProcessTick >= QueueProcessInterval) { @@ -70,81 +118,343 @@ public void Enqueue(AIRequest request, Action callback, IAIClient cl if (!_modQueues.TryGetValue(modId, out var queue)) { - queue = new Queue(); + queue = new List(); _modQueues[modId] = queue; } - queue.Enqueue(new PendingRequest(request, callback, client)); + int trackingId = _nextTrackingId++; + var tracked = new TrackedRequest + { + TrackingId = trackingId, + Request = request, + Callback = callback, + Client = client, + IsLocalEndpointSnapshot = client.IsLocalEndpoint, + State = AIRequestState.Queued, + EnqueuedAtTick = Find.TickManager.TicksGame, + EnqueuedAtMs = Stopwatch.GetTimestamp(), + AttemptCount = 1, + MaxAttempts = request.MaxRetryCount >= 0 ? request.MaxRetryCount + 1 : RimMindCoreMod.Settings.maxRetryCount + 1, + }; + + int insertIdx = queue.FindIndex(t => t.Request.Priority > request.Priority); + if (insertIdx >= 0) + queue.Insert(insertIdx, tracked); + else + queue.Add(tracked); if (settings.debugLogging) - Log.Message($"[RimMind][Core] Enqueued request {request.RequestId} for mod {modId}, queue depth={queue.Count}"); + Log.Message($"[RimMind][Core] Enqueued request {request.RequestId} (track={trackingId}) for mod {modId}, " + + $"priority={request.Priority}, queue depth={queue.Count}"); int now = Find.TickManager.TicksGame; TryProcessModQueue(modId, now); } + public void EnqueueImmediate(AIRequest request, Action callback, IAIClient client) + { + var settings = RimMindCoreMod.Settings; + + if (client.IsLocalEndpoint && _isProcessingLocalRequest) + { + if (settings.debugLogging) + Log.Message($"[RimMind][Core] Immediate request {request.RequestId} deferred: local model busy"); + + Enqueue(request, callback, client); + return; + } + + int trackingId = _nextTrackingId++; + var tracked = new TrackedRequest + { + TrackingId = trackingId, + Request = request, + Callback = callback, + Client = client, + IsLocalEndpointSnapshot = client.IsLocalEndpoint, + State = AIRequestState.Processing, + EnqueuedAtTick = Find.TickManager.TicksGame, + EnqueuedAtMs = Stopwatch.GetTimestamp(), + StartedProcessingAtTick = Find.TickManager.TicksGame, + AttemptCount = 1, + MaxAttempts = 1, + }; + + _activeRequests[trackingId] = tracked; + _requestIdToActive[request.RequestId] = tracked; + + if (client.IsLocalEndpoint) + _isProcessingLocalRequest = true; + + if (settings.debugLogging) + Log.Message($"[RimMind][Core] Immediate request {request.RequestId} (track={trackingId}) for mod {request.ModId}, bypassing queue"); + + FireRequest(tracked); + } + private void ProcessAllQueues(int now) { + if (_isPaused) return; + + var readyRequests = new List<(string modId, TrackedRequest tracked)>(); + foreach (var kvp in _modQueues) { - if (kvp.Value.Count > 0) - TryProcessModQueue(kvp.Key, now); + string modId = kvp.Key; + var queue = kvp.Value; + + if (queue.Count == 0) continue; + if (_modCooldowns.TryGetValue(modId, out int nextAllowed) && now < nextAllowed) + continue; + + while (queue.Count > 0) + { + var tracked = queue[0]; + if (tracked.Request.ExpireAtTicks > 0 && now > tracked.Request.ExpireAtTicks) + { + queue.RemoveAt(0); + if (RimMindCoreMod.Settings.debugLogging) + Log.Message($"[RimMind][Core] Expired request {tracked.Request.RequestId} skipped (expired at {tracked.Request.ExpireAtTicks}, now={now})"); + continue; + } + break; + } + + if (queue.Count > 0) + readyRequests.Add((modId, queue[0])); + } + + readyRequests.Sort((a, b) => + { + int p = (int)a.tracked.Request.Priority - (int)b.tracked.Request.Priority; + if (p != 0) return p; + return a.tracked.EnqueuedAtTick - b.tracked.EnqueuedAtTick; + }); + + int maxConcurrent = RimMindCoreMod.Settings.maxConcurrentRequests; + + foreach (var (modId, tracked) in readyRequests) + { + if (_activeRequests.Count >= maxConcurrent) + break; + + if (tracked.IsLocalEndpointSnapshot && _isProcessingLocalRequest) + continue; + + if (!_modQueues.TryGetValue(modId, out var queue) || queue.Count == 0 || queue[0] != tracked) + continue; + + queue.RemoveAt(0); + + int cooldownTicks = GetModCooldownTicks(modId); + _modCooldowns[modId] = now + cooldownTicks; + + tracked.State = AIRequestState.Processing; + tracked.StartedProcessingAtTick = now; + _activeRequests[tracked.TrackingId] = tracked; + _requestIdToActive[tracked.Request.RequestId] = tracked; + + if (tracked.IsLocalEndpointSnapshot) + _isProcessingLocalRequest = true; + + if (RimMindCoreMod.Settings.debugLogging) + Log.Message($"[RimMind][Core] Processing request {tracked.Request.RequestId} (track={tracked.TrackingId}) " + + $"for mod {modId}, priority={tracked.Request.Priority}, cooldown={cooldownTicks}t, " + + $"active={_activeRequests.Count}/{maxConcurrent}"); + + FireRequest(tracked); } } private void TryProcessModQueue(string modId, int now) { + if (_isPaused) return; + if (_modCooldowns.TryGetValue(modId, out int nextAllowed) && now < nextAllowed) + return; if (!_modQueues.TryGetValue(modId, out var queue) || queue.Count == 0) return; - if (_modCooldowns.TryGetValue(modId, out int nextAllowed) && now < nextAllowed) - return; + ProcessAllQueues(now); + } - var settings = RimMindCoreMod.Settings; + private void FireRequest(TrackedRequest tracked) + { + Task.Run(async () => + { + AIResponse response; + try + { + response = await tracked.Client.SendAsync(tracked.Request); + } + catch (Exception ex) + { + AIRequestQueue.LogFromBackground( + $"[RimMind] SendAsync threw for {tracked.Request.RequestId}: {ex.Message}", isWarning: true); + response = AIResponse.Failure(tracked.Request.RequestId, ex.Message); + } + + long queueWaitMs = 0; + if (tracked.StartedProcessingAtTick > 0 && tracked.EnqueuedAtTick > 0) + { + queueWaitMs = (tracked.StartedProcessingAtTick - tracked.EnqueuedAtTick) * 16L; + } + + response.AttemptCount = tracked.AttemptCount; + response.QueueWaitMs = queueWaitMs; + response.Priority = tracked.Request.Priority; + + bool shouldRetry = !response.Success + && tracked.AttemptCount < tracked.MaxAttempts + && IsTransientError(response.Error); + + _instance!._pendingFireResults.Enqueue(new PendingFireResult + { + Kind = shouldRetry ? FireResultKind.Retry : FireResultKind.Complete, + Tracked = tracked, + Response = response, + }); + }); + } - while (queue.Count > 0) + private void ProcessFireResult(PendingFireResult result) + { + var tracked = result.Tracked; + + _activeRequests.Remove(tracked.TrackingId); + _requestIdToActive.Remove(tracked.Request.RequestId); + + if (tracked.IsLocalEndpointSnapshot) + _isProcessingLocalRequest = false; + + if (result.Kind == FireResultKind.Retry) { - var pending = queue.Peek(); + tracked.AttemptCount++; + tracked.State = AIRequestState.Queued; + tracked.StartedProcessingAtTick = 0; - if (pending.Request.ExpireAtTicks > 0 && now > pending.Request.ExpireAtTicks) + string modId = tracked.Request.ModId; + if (!_modQueues.TryGetValue(modId, out var queue)) { - queue.Dequeue(); - if (settings.debugLogging) - Log.Message($"[RimMind][Core] Expired request {pending.Request.RequestId} skipped (enqueued at tick, expired at {pending.Request.ExpireAtTicks}, now={now})"); - continue; + queue = new List(); + _modQueues[modId] = queue; } - int cooldownTicks = GetModCooldownTicks(modId); - _modCooldowns[modId] = now + cooldownTicks; + int insertIdx = queue.FindIndex(t => t.Request.Priority > tracked.Request.Priority); + if (insertIdx >= 0) + queue.Insert(insertIdx, tracked); + else + queue.Add(tracked); - var req = queue.Dequeue(); + if (RimMindCoreMod.Settings.debugLogging) + Log.Message($"[RimMind] Retrying request {tracked.Request.RequestId} (attempt {tracked.AttemptCount}/{tracked.MaxAttempts})"); + } + else + { + _results.Enqueue((result.Response, tracked.Callback)); + } + } - if (settings.debugLogging) - Log.Message($"[RimMind][Core] Processing request {req.Request.RequestId} for mod {modId}, cooldown={cooldownTicks} ticks"); + private void CheckActiveRequestTimeouts() + { + if (_activeRequests.Count == 0) return; + + int now = Find.TickManager.TicksGame; + int timeoutMs = RimMindCoreMod.Settings.requestTimeoutMs; + int timeoutTicks = timeoutMs / 16; + + var timedOut = new List(); - Task.Run(async () => + foreach (var kvp in _activeRequests) + { + var tracked = kvp.Value; + if (tracked.StartedProcessingAtTick > 0 && now - tracked.StartedProcessingAtTick > timeoutTicks) { - var response = await req.Client.SendAsync(req.Request); - _results.Enqueue((response, req.Callback)); - }); + timedOut.Add(tracked); + } + } + + foreach (var tracked in timedOut) + { + _activeRequests.Remove(tracked.TrackingId); + _requestIdToActive.Remove(tracked.Request.RequestId); + + if (tracked.IsLocalEndpointSnapshot) + _isProcessingLocalRequest = false; - break; + var response = AIResponse.Failure(tracked.Request.RequestId, + $"Request timed out after {timeoutMs}ms"); + response.AttemptCount = tracked.AttemptCount; + response.Priority = tracked.Request.Priority; + response.State = AIRequestState.Error; + + _results.Enqueue((response, tracked.Callback)); + + if (RimMindCoreMod.Settings.debugLogging) + Log.Message($"[RimMind][Core] Request {tracked.Request.RequestId} timed out after {timeoutTicks} ticks"); } } - public void EnqueueImmediate(AIRequest request, Action callback, IAIClient client) + private static bool IsTransientError(string error) { - var settings = RimMindCoreMod.Settings; - if (settings.debugLogging) - Log.Message($"[RimMind][Core] Immediate request {request.RequestId} for mod {request.ModId}, bypassing queue"); + if (string.IsNullOrEmpty(error)) return false; + string lower = error.ToLowerInvariant(); + return lower.Contains("timeout") + || lower.Contains("connection") + || lower.Contains("network") + || lower.Contains("503") + || lower.Contains("502") + || lower.Contains("429") + || lower.Contains("rate limit"); + } - Task.Run(async () => + public bool CancelRequest(string requestId) + { + if (_requestIdToActive.TryGetValue(requestId, out var active)) { - var response = await client.SendAsync(request); - _results.Enqueue((response, callback)); - }); + active.State = AIRequestState.Cancelled; + _activeRequests.Remove(active.TrackingId); + _requestIdToActive.Remove(requestId); + + if (active.IsLocalEndpointSnapshot) + _isProcessingLocalRequest = false; + + var response = AIResponse.Cancelled(requestId, "Cancelled by user"); + response.Priority = active.Request.Priority; + _results.Enqueue((response, active.Callback)); + return true; + } + + foreach (var kvp in _modQueues) + { + var queue = kvp.Value; + int idx = queue.FindIndex(t => t.Request.RequestId == requestId); + if (idx >= 0) + { + var tracked = queue[idx]; + queue.RemoveAt(idx); + var response = AIResponse.Cancelled(requestId, "Cancelled by user"); + response.Priority = tracked.Request.Priority; + _results.Enqueue((response, tracked.Callback)); + return true; + } + } + + return false; } + public void PauseQueue() => _isPaused = true; + + public void ResumeQueue() => _isPaused = false; + + public bool IsPaused => _isPaused; + + public int ActiveRequestCount => _activeRequests.Count; + + public bool IsLocalModelBusy => _isProcessingLocalRequest; + + public IReadOnlyList GetActiveRequests() + => _activeRequests.Values.ToList(); + private int GetModCooldownTicks(string modId) { var getter = RimMindAPI.GetModCooldownGetter(modId); @@ -190,18 +500,35 @@ public IReadOnlyDictionary GetAllQueueDepths() return result; } - private struct PendingRequest + public IReadOnlyList GetQueuedRequests(string modId) { - public AIRequest Request; - public Action Callback; - public IAIClient Client; + if (!_modQueues.TryGetValue(modId, out var queue)) return new List(); + return queue.ToList(); + } - public PendingRequest(AIRequest request, Action callback, IAIClient client) - { - Request = request; - Callback = callback; - Client = client; - } + public IReadOnlyList GetAllQueuedRequests() + { + var result = new List(); + foreach (var kvp in _modQueues) + result.AddRange(kvp.Value); + return result; + } + + public int TotalQueuedCount => _modQueues.Values.Sum(q => q.Count); + + public class TrackedRequest + { + public int TrackingId; + public AIRequest Request = null!; + public Action Callback = null!; + public IAIClient Client = null!; + public bool IsLocalEndpointSnapshot; + public AIRequestState State; + public int EnqueuedAtTick; + public long EnqueuedAtMs; + public int StartedProcessingAtTick; + public int AttemptCount; + public int MaxAttempts; } } } diff --git a/Source/Core/GameContextBuilder.cs b/Source/Core/GameContextBuilder.cs index c8bb1fee..e3278bb8 100644 --- a/Source/Core/GameContextBuilder.cs +++ b/Source/Core/GameContextBuilder.cs @@ -292,17 +292,15 @@ public static string BuildPawnContext(Pawn pawn) { var parts = new List(); - // 主武器 var weapon = pawn.equipment?.Primary; if (weapon != null) - parts.Add("RimMind.Core.Prompt.Weapon".Translate(weapon.LabelCap)); + parts.Add("RimMind.Core.Prompt.Weapon".Translate(LabelWithQuality(weapon))); - // 身上服装(跳过美观层占位符) if (pawn.apparel?.WornApparel != null) { var apparel = new List(); foreach (var a in pawn.apparel.WornApparel) - apparel.Add(a.LabelCap); + apparel.Add(LabelWithQuality(a)); if (apparel.Count > 0) parts.Add("RimMind.Core.Prompt.Apparel".Translate(string.Join(", ", apparel))); } @@ -311,6 +309,31 @@ public static string BuildPawnContext(Pawn pawn) sb.AppendLine(string.Join(" ", parts)); } + if (ctx.IncludeInventory) + { + var innerContainer = pawn.inventory?.innerContainer; + if (innerContainer != null && innerContainer.Count > 0) + { + var items = new Dictionary(); + foreach (var thing in innerContainer) + { + string key = thing.def?.defName ?? thing.Label; + if (!items.ContainsKey(key)) + items[key] = 0; + items[key] += thing.stackCount; + } + var itemStrs = items.OrderByDescending(kv => kv.Value) + .Take(8) + .Select(kv => + { + var def = DefDatabase.GetNamedSilentFail(kv.Key); + string label = def?.LabelCap ?? kv.Key; + return kv.Value > 1 ? $"{label}×{kv.Value}" : label; + }); + sb.AppendLine("RimMind.Core.Prompt.Inventory".Translate(string.Join(", ", itemStrs))); + } + } + // 位置(房间类型 + 温度) if (ctx.IncludeLocation && pawn.Map != null) { @@ -443,6 +466,16 @@ private static string BuildSurroundings(Pawn pawn, int radius = 5, int maxItems return parts.Count > 0 ? string.Join(" ", parts) : string.Empty; } + private static string LabelWithQuality(Thing thing) + { + string label = thing.LabelCap; + if (thing.TryGetQuality(out QualityCategory qc)) + label += $"({qc.GetLabel()})"; + if (thing.def.useHitPoints && thing.HitPoints < thing.MaxHitPoints * 0.5f) + label += " " + "RimMind.Core.Prompt.Damaged".Translate(); + return label; + } + private static string ThreatLabel(float wealth) { return wealth > 200000 ? "RimMind.Core.Prompt.Threat.Extreme".Translate() @@ -527,7 +560,7 @@ public static string BuildCompactPawnContext(Pawn pawn) var weapon = pawn.equipment?.Primary; if (weapon != null) - sb.AppendLine("RimMind.Core.Prompt.CompactWeapon".Translate(weapon.LabelCap)); + sb.AppendLine("RimMind.Core.Prompt.CompactWeapon".Translate(LabelWithQuality(weapon))); if (pawn.Drafted) sb.AppendLine("RimMind.Core.Prompt.Drafted".Translate()); diff --git a/Source/Core/Prompt/ContextComposer.cs b/Source/Core/Prompt/ContextComposer.cs index df1be26c..ae6c13e8 100644 --- a/Source/Core/Prompt/ContextComposer.cs +++ b/Source/Core/Prompt/ContextComposer.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Text; +using Verse; namespace RimMind.Core.Prompt { @@ -47,7 +48,7 @@ public static string BuildFromSections(List sections) if (!string.IsNullOrEmpty(section.Content)) sb.AppendLine(section.Content); } - return sb.ToString().TrimEnd(); + return PromptSanitizer.Sanitize(sb.ToString().TrimEnd()); } public static string CompressHistory(string historyText, int maxLines = 6, string summaryLine = "") @@ -59,7 +60,7 @@ public static string CompressHistory(string historyText, int maxLines = 6, strin var recent = lines.Skip(lines.Length - maxLines).ToArray(); string header = string.IsNullOrEmpty(summaryLine) - ? $"({lines.Length - maxLines}条更早的记录已省略)" + ? "RimMind.Core.Prompt.HistoryOmitted".Translate(lines.Length - maxLines) : summaryLine; var sb = new StringBuilder(); diff --git a/Source/Core/Prompt/PromptSanitizer.cs b/Source/Core/Prompt/PromptSanitizer.cs new file mode 100644 index 00000000..9f7415aa --- /dev/null +++ b/Source/Core/Prompt/PromptSanitizer.cs @@ -0,0 +1,11 @@ +namespace RimMind.Core.Prompt +{ + public static class PromptSanitizer + { + public static string Sanitize(string prompt) + { + if (string.IsNullOrEmpty(prompt)) return prompt; + return prompt.Replace("{{", "{").Replace("}}", "}"); + } + } +} diff --git a/Source/Core/Prompt/StructuredPromptBuilder.cs b/Source/Core/Prompt/StructuredPromptBuilder.cs index fffe458b..167c3c35 100644 --- a/Source/Core/Prompt/StructuredPromptBuilder.cs +++ b/Source/Core/Prompt/StructuredPromptBuilder.cs @@ -95,7 +95,7 @@ public string Build() AppendSection(sb, "Fallback", _fallback); AppendSection(sb, "Custom", _custom); - return sb.ToString().TrimEnd(); + return PromptSanitizer.Sanitize(sb.ToString().TrimEnd()); } public PromptSection ToSection(string tag = "system_prompt", int priority = 0) diff --git a/Source/Debug/AICoreDebugActions.cs b/Source/Debug/AICoreDebugActions.cs index 92a4d680..4b078cfb 100644 --- a/Source/Debug/AICoreDebugActions.cs +++ b/Source/Debug/AICoreDebugActions.cs @@ -6,9 +6,6 @@ namespace RimMind.Core.Debug { - /// - /// 开发模式调试动作(Dev 菜单 → RimMind)。 - /// [StaticConstructorOnStartup] public static class RimMindCoreDebugActions { @@ -30,6 +27,7 @@ public static void TestConnection() RequestId = "Debug_TestConnection", ModId = "Debug", ExpireAtTicks = Find.TickManager.TicksGame + 3600, + Priority = AIRequestPriority.High, }; RimMindAPI.RequestImmediate(request, response => @@ -88,5 +86,51 @@ public static void ShowPawnContext() if (pawn == null) { Log.Warning("[RimMind] Select a pawn first."); return; } Log.Message("[RimMind] Full Pawn Prompt:\n" + RimMindAPI.BuildFullPawnPrompt(pawn)); } + + [DebugAction("RimMind", "Show Queue State", actionType = DebugActionType.Action)] + public static void ShowQueueState() + { + var queue = AIRequestQueue.Instance; + if (queue == null) + { + Log.Warning("[RimMind] AIRequestQueue not initialized."); + return; + } + + var sb = new System.Text.StringBuilder(); + sb.AppendLine("[RimMind] === Queue State ==="); + sb.AppendLine($" Paused: {queue.IsPaused}"); + sb.AppendLine($" Active requests: {queue.ActiveRequestCount}"); + sb.AppendLine($" Local model busy: {queue.IsLocalModelBusy}"); + + var active = queue.GetActiveRequests(); + foreach (var t in active) + { + sb.AppendLine($" [Active] {t.Request.RequestId} mod={t.Request.ModId} " + + $"priority={t.Request.Priority} state={t.State} attempt={t.AttemptCount}"); + } + + foreach (var kvp in queue.GetAllQueueDepths()) + { + int cooldownLeft = queue.GetCooldownTicksLeft(kvp.Key); + sb.AppendLine($" [Queue] {kvp.Key}: depth={kvp.Value}, cooldown={cooldownLeft}t"); + } + + Log.Message(sb.ToString()); + } + + [DebugAction("RimMind", "Pause Queue", actionType = DebugActionType.Action)] + public static void PauseQueue() + { + RimMindAPI.PauseQueue(); + Log.Message("[RimMind] Queue paused."); + } + + [DebugAction("RimMind", "Resume Queue", actionType = DebugActionType.Action)] + public static void ResumeQueue() + { + RimMindAPI.ResumeQueue(); + Log.Message("[RimMind] Queue resumed."); + } } } diff --git a/Source/Settings/AICoreSettings.cs b/Source/Settings/AICoreSettings.cs index 78dd9fda..fd127986 100644 --- a/Source/Settings/AICoreSettings.cs +++ b/Source/Settings/AICoreSettings.cs @@ -4,38 +4,32 @@ namespace RimMind.Core.Settings { public class RimMindCoreSettings : ModSettings { - // ── API 配置 ────────────────────────────────────────── public string apiKey = string.Empty; public string apiEndpoint = "https://api.deepseek.com/v1"; public string modelName = "deepseek-chat"; - // ── 模型行为 ────────────────────────────────────────── - /// - /// 向请求追加 response_format={"type":"json_object"}。 - /// 不支持该参数的本地模型请关闭。 - /// public bool forceJsonMode = true; public bool useStreaming = false; - // ── 性能 ────────────────────────────────────────────── public int maxTokens = 800; public bool debugLogging = false; - // ── 上下文过滤 ───────────────────────────────────────── public ContextSettings Context = new ContextSettings(); - // ── 全局自定义提示词 ──────────────────────────────────── public string customPawnPrompt = string.Empty; public string customMapPrompt = string.Empty; - // ── 悬浮窗 ────────────────────────────────────────────── public bool requestOverlayEnabled = true; public float requestOverlayX = 20f; public float requestOverlayY = 20f; public float requestOverlayW = 300f; public float requestOverlayH = 200f; + public int maxConcurrentRequests = 3; + public int maxRetryCount = 2; + public int requestTimeoutMs = 120000; + public bool IsConfigured() => !string.IsNullOrWhiteSpace(apiKey) && !string.IsNullOrWhiteSpace(apiEndpoint); @@ -58,6 +52,9 @@ public override void ExposeData() Scribe_Values.Look(ref requestOverlayY, "requestOverlayY", 20f); Scribe_Values.Look(ref requestOverlayW, "requestOverlayW", 300f); Scribe_Values.Look(ref requestOverlayH, "requestOverlayH", 200f); + Scribe_Values.Look(ref maxConcurrentRequests, "maxConcurrentRequests", 3); + Scribe_Values.Look(ref maxRetryCount, "maxRetryCount", 2); + Scribe_Values.Look(ref requestTimeoutMs, "requestTimeoutMs", 120000); } } } diff --git a/Source/Settings/ContextSettings.cs b/Source/Settings/ContextSettings.cs index 90b1af9c..fdeb3688 100644 --- a/Source/Settings/ContextSettings.cs +++ b/Source/Settings/ContextSettings.cs @@ -25,6 +25,7 @@ public class ContextSettings : IExposable public bool IncludeCurrentJob = true; public bool IncludeWorkPriorities = true; public bool IncludeEquipment = true; + public bool IncludeInventory = true; public bool IncludeLocation = true; public bool IncludeRelations = true; public bool IncludeGenes = true; @@ -43,6 +44,8 @@ public class ContextSettings : IExposable public HashSet disabledProviders = new HashSet(); + public HashSet exposedProviders = new HashSet(); + public void ExposeData() { Scribe_Values.Look(ref IncludeRace, "IncludeRace", true); @@ -60,6 +63,7 @@ public void ExposeData() Scribe_Values.Look(ref IncludeCurrentJob, "IncludeCurrentJob", true); Scribe_Values.Look(ref IncludeWorkPriorities, "IncludeWorkPriorities", true); Scribe_Values.Look(ref IncludeEquipment, "IncludeEquipment", true); + Scribe_Values.Look(ref IncludeInventory, "IncludeInventory", true); Scribe_Values.Look(ref IncludeLocation, "IncludeLocation", true); Scribe_Values.Look(ref IncludeRelations, "IncludeRelations", true); Scribe_Values.Look(ref IncludeGenes, "IncludeGenes", true); @@ -76,6 +80,9 @@ public void ExposeData() Scribe_Collections.Look(ref disabledProviders, "disabledProviders", LookMode.Value); if (Scribe.mode == LoadSaveMode.LoadingVars && disabledProviders == null) disabledProviders = new HashSet(); + Scribe_Collections.Look(ref exposedProviders, "exposedProviders", LookMode.Value); + if (Scribe.mode == LoadSaveMode.LoadingVars && exposedProviders == null) + exposedProviders = new HashSet(); } /// 应用预设。 @@ -89,7 +96,7 @@ public void ApplyPreset(ContextPreset preset) IncludeTraits = false; IncludeSkills = false; MinSkillLevel = 4; IncludeHealth = true; IncludeCapacities = false; IncludeMood = true; IncludeMoodThoughts = false; IncludeCurrentJob = false; IncludeWorkPriorities = false; - IncludeEquipment = false; IncludeLocation = false; + IncludeEquipment = false; IncludeInventory = false; IncludeLocation = false; IncludeRelations = false; IncludeGenes = false; IncludeSurroundings = false; IncludeCombatStatus = true; IncludeGameTime = false; IncludeColonistCount = true; IncludeColonistNames = false; IncludeWealth = false; @@ -102,7 +109,7 @@ public void ApplyPreset(ContextPreset preset) IncludeTraits = true; IncludeSkills = true; MinSkillLevel = 4; IncludeHealth = true; IncludeCapacities = true; IncludeMood = true; IncludeMoodThoughts = false; IncludeCurrentJob = true; IncludeWorkPriorities = true; - IncludeEquipment = true; IncludeLocation = false; + IncludeEquipment = true; IncludeInventory = false; IncludeLocation = false; IncludeRelations = true; IncludeGenes = true; IncludeSurroundings = false; IncludeCombatStatus = true; IncludeGameTime = true; IncludeColonistCount = true; IncludeColonistNames = true; IncludeWealth = false; @@ -115,7 +122,7 @@ public void ApplyPreset(ContextPreset preset) IncludeTraits = true; IncludeSkills = true; MinSkillLevel = 1; IncludeHealth = true; IncludeCapacities = true; IncludeMood = true; IncludeMoodThoughts = true; IncludeCurrentJob = true; IncludeWorkPriorities = true; - IncludeEquipment = true; IncludeLocation = true; + IncludeEquipment = true; IncludeInventory = true; IncludeLocation = true; IncludeRelations = true; IncludeGenes = true; IncludeSurroundings = true; IncludeCombatStatus = true; IncludeGameTime = true; IncludeColonistCount = true; IncludeColonistNames = true; IncludeWealth = true; diff --git a/Source/UI/AICoreSettingsUI.cs b/Source/UI/AICoreSettingsUI.cs index 9829b807..95f821b0 100644 --- a/Source/UI/AICoreSettingsUI.cs +++ b/Source/UI/AICoreSettingsUI.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Text; using System.Threading.Tasks; using RimMind.Core.Client; @@ -22,8 +23,11 @@ public static class RimMindCoreSettingsUI { private const float TabBarHeight = 32f; private const float TabBarGap = 6f; + private const float TabMinWidth = 120f; + private const float TabGap = 4f; private static string _curTab = "api"; + private static float _cachedTabBarHeight = TabBarHeight; // API tab state private static bool _showApiKey; @@ -38,20 +42,25 @@ public static class RimMindCoreSettingsUI // Prompts tab state private static Vector2 _promptsScroll; + // Queue tab state + private static Vector2 _queueScroll; + // ── 入口 ───────────────────────────────────────────────────────────── public static void Draw(Rect inRect) { - // ── Tab 按钮行(ButtonText 式,不会压住 mod 标题) - DrawTabBar(new Rect(inRect.x, inRect.y, inRect.width, TabBarHeight)); + var tabs = CollectTabs(); + _cachedTabBarHeight = CalcTabBarHeight(inRect.width, tabs.Count); + + DrawTabBar(new Rect(inRect.x, inRect.y, inRect.width, _cachedTabBarHeight), tabs); - // ── 内容区 - Rect content = new Rect(inRect.x, inRect.y + TabBarHeight + TabBarGap, - inRect.width, inRect.height - TabBarHeight - TabBarGap); + Rect content = new Rect(inRect.x, inRect.y + _cachedTabBarHeight + TabBarGap, + inRect.width, inRect.height - _cachedTabBarHeight - TabBarGap); switch (_curTab) { case "api": DrawApiTab(content); break; + case "queue": DrawQueueTab(content); break; case "context": DrawContextTab(content); break; case "prompts": DrawPromptsTab(content); break; default: @@ -61,23 +70,55 @@ public static void Draw(Rect inRect) } } - private static void DrawTabBar(Rect r) + private static List<(string id, string label)> CollectTabs() { - // 收集所有 tab:内置 2 个 + 子 mod 注册的 var tabs = new List<(string id, string label)> { ("api", "RimMind.Core.Settings.Tab.Api".Translate()), + ("queue", "RimMind.Core.Settings.Tab.Queue".Translate()), ("prompts", "RimMind.Core.Settings.Tab.Prompts".Translate()), ("context", "RimMind.Core.Settings.Tab.Context".Translate()), }; foreach (var (id, labelFn, _) in RimMindAPI.SettingsTabs) tabs.Add((id, labelFn())); + return tabs; + } + + private static int CalcMaxPerRow(float availableWidth, int tabCount) + { + if (tabCount <= 0) return 1; + int perRow = Mathf.FloorToInt((availableWidth + TabGap) / (TabMinWidth + TabGap)); + return Mathf.Clamp(perRow, 1, tabCount); + } + + private static float CalcTabBarHeight(float availableWidth, int tabCount) + { + if (tabCount <= 0) return TabBarHeight; + int perRow = CalcMaxPerRow(availableWidth, tabCount); + int rows = Mathf.CeilToInt((float)tabCount / perRow); + return rows * TabBarHeight + (rows - 1) * TabGap; + } + + private static void DrawTabBar(Rect r, List<(string id, string label)> tabs) + { + int count = tabs.Count; + if (count == 0) return; + + int perRow = CalcMaxPerRow(r.width, count); + int rows = Mathf.CeilToInt((float)count / perRow); - float w = r.width / tabs.Count; - for (int i = 0; i < tabs.Count; i++) + for (int i = 0; i < count; i++) { + int row = i / perRow; + int col = i % perRow; + int colsInRow = (row == rows - 1) ? (count - row * perRow) : perRow; + + float w = (r.width - TabGap * (colsInRow - 1)) / colsInRow; + float x = r.x + col * (w + TabGap); + float y = r.y + row * (TabBarHeight + TabGap); + var (id, label) = tabs[i]; - Rect btn = new Rect(r.x + w * i, r.y, w, TabBarHeight); + Rect btn = new Rect(x, y, w, TabBarHeight); bool selected = _curTab == id; GUI.color = selected ? Color.white : Color.gray; @@ -168,23 +209,31 @@ private static void DrawApiTab(Rect inRect) GUI.color = Color.white; s.maxTokens = (int)listing.Slider(s.maxTokens, 200f, 2000f); + listing.Label($"{"RimMind.Core.Settings.MaxConcurrent".Translate()}: {s.maxConcurrentRequests}"); + GUI.color = Color.gray; + listing.Label(" " + "RimMind.Core.Settings.MaxConcurrent.Desc".Translate()); + GUI.color = Color.white; + s.maxConcurrentRequests = (int)listing.Slider(s.maxConcurrentRequests, 1f, 10f); + + listing.Label($"{"RimMind.Core.Settings.MaxRetry".Translate()}: {s.maxRetryCount}"); + GUI.color = Color.gray; + listing.Label(" " + "RimMind.Core.Settings.MaxRetry.Desc".Translate()); + GUI.color = Color.white; + s.maxRetryCount = (int)listing.Slider(s.maxRetryCount, 0f, 5f); + + listing.Label($"{"RimMind.Core.Settings.RequestTimeout".Translate()}: {s.requestTimeoutMs / 1000}s"); + GUI.color = Color.gray; + listing.Label(" " + "RimMind.Core.Settings.RequestTimeout.Desc".Translate()); + GUI.color = Color.white; + s.requestTimeoutMs = (int)listing.Slider(s.requestTimeoutMs / 1000f, 10f, 300f) * 1000; + var queue = AIRequestQueue.Instance; if (queue != null) { - foreach (var kvp in RimMindAPI.ModCooldownGetters) - { - string modId = kvp.Key; - int cooldownLeft = queue.GetCooldownTicksLeft(modId); - int queueDepth = queue.GetQueueDepth(modId); - string status = cooldownLeft > 0 - ? $" ({cooldownLeft} ticks)" - : " (ready)"; - string queueInfo = queueDepth > 0 - ? $" [queue: {queueDepth}]" - : ""; - GUI.color = cooldownLeft > 0 ? Color.gray : new Color(0.4f, 0.9f, 0.4f); - listing.Label($"{modId}{status}{queueInfo}"); - } + listing.Gap(4f); + GUI.color = Color.gray; + listing.Label("RimMind.Core.Settings.QueueSeeTab".Translate()); + GUI.color = Color.white; } GUI.color = Color.white; @@ -303,6 +352,167 @@ private static string FormatEndpoint(string baseUrl) return trimmed + "/v1/chat/completions"; } + // ── 队列状态分页 ────────────────────────────────────────────────────── + + private static void DrawQueueTab(Rect inRect) + { + var queue = AIRequestQueue.Instance; + if (queue == null) + { + var listing0 = new Listing_Standard(); + listing0.Begin(inRect); + GUI.color = Color.yellow; + listing0.Label("RimMind.Core.Settings.QueueNotAvailable".Translate()); + GUI.color = Color.white; + listing0.End(); + return; + } + + var allDepths = queue.GetAllQueueDepths(); + var allCooldowns = queue.GetAllCooldowns(); + var allModIds = new HashSet(allDepths.Keys); + allModIds.UnionWith(allCooldowns.Keys); + allModIds.UnionWith(RimMindAPI.ModCooldownGetters.Keys); + + int modCount = allModIds.Count; + int activeCount = queue.ActiveRequestCount; + int queuedCount = queue.TotalQueuedCount; + float contentH = 60f + 28f + modCount * 26f + 28f + activeCount * 24f + 28f + queuedCount * 24f + 80f; + contentH = Mathf.Max(contentH, inRect.height + 10f); + + Rect viewRect = new Rect(0f, 0f, inRect.width - 16f, contentH); + Widgets.BeginScrollView(inRect, ref _queueScroll, viewRect); + + var listing = new Listing_Standard(); + listing.Begin(viewRect); + + // ── 总体状态 ────────────────────────────────────────────────────── + SettingsUIHelper.DrawSectionHeader(listing, "RimMind.Core.Settings.Queue.Status".Translate()); + + string pauseLabel = queue.IsPaused + ? "RimMind.Core.Settings.QueuePaused".Translate() + : "RimMind.Core.Settings.QueueRunning".Translate(); + GUI.color = queue.IsPaused ? Color.yellow : new Color(0.4f, 0.9f, 0.4f); + listing.Label(pauseLabel); + GUI.color = Color.white; + + listing.Label($"{"RimMind.Core.Settings.Queue.Active".Translate()}: {activeCount} / {RimMindCoreMod.Settings.maxConcurrentRequests}"); + listing.Label($"{"RimMind.Core.Settings.Queue.Queued".Translate()}: {queuedCount}"); + GUI.color = queue.IsLocalModelBusy ? new Color(0.9f, 0.6f, 0.3f) : new Color(0.4f, 0.9f, 0.4f); + listing.Label($"{"RimMind.Core.Settings.Queue.LocalModel".Translate()}: {(queue.IsLocalModelBusy ? "RimMind.Core.Settings.Queue.Busy".Translate() : "RimMind.Core.Settings.Queue.Idle".Translate())}"); + GUI.color = Color.white; + + // ── 操作按钮 ────────────────────────────────────────────────────── + listing.Gap(4f); + Rect btnRow = listing.GetRect(28f); + float btnW = 110f; + float gap = 8f; + + Rect pauseBtn = new Rect(btnRow.x, btnRow.y, btnW, btnRow.height); + Rect clearBtn = new Rect(pauseBtn.xMax + gap, btnRow.y, btnW, btnRow.height); + Rect clearCdBtn = new Rect(clearBtn.xMax + gap, btnRow.y, btnW + 20f, btnRow.height); + + string pauseText = queue.IsPaused + ? "RimMind.Core.Settings.Queue.Resume".Translate() + : "RimMind.Core.Settings.Queue.Pause".Translate(); + if (Widgets.ButtonText(pauseBtn, pauseText)) + { + if (queue.IsPaused) queue.ResumeQueue(); + else queue.PauseQueue(); + } + if (Widgets.ButtonText(clearBtn, "RimMind.Core.Settings.Queue.ClearQueues".Translate())) + queue.ClearAllQueues(); + if (Widgets.ButtonText(clearCdBtn, "RimMind.Core.Settings.Queue.ClearCooldowns".Translate())) + queue.ClearAllCooldowns(); + + // ── 各 Mod 队列 ────────────────────────────────────────────────── + SettingsUIHelper.DrawSectionHeader(listing, "RimMind.Core.Settings.Queue.PerMod".Translate()); + + if (allModIds.Count == 0) + { + GUI.color = Color.gray; + listing.Label("RimMind.Core.Settings.Queue.NoMods".Translate()); + GUI.color = Color.white; + } + else + { + foreach (string modId in allModIds.OrderBy(id => id)) + { + int depth = allDepths.TryGetValue(modId, out var d) ? d : 0; + int cooldownLeft = queue.GetCooldownTicksLeft(modId); + float cooldownSec = cooldownLeft / 60f; + + string cooldownStr = cooldownLeft > 0 + ? $"{"RimMind.Core.Settings.Queue.Cooldown".Translate()}: {cooldownSec:F1}s" + : "RimMind.Core.Settings.Queue.Ready".Translate(); + string depthStr = depth > 0 + ? $" [{"RimMind.Core.Settings.Queue.QueueCount".Translate()}: {depth}]" + : ""; + + GUI.color = cooldownLeft > 0 ? new Color(0.9f, 0.6f, 0.3f) : new Color(0.4f, 0.9f, 0.4f); + listing.Label($"{modId} {cooldownStr}{depthStr}"); + } + } + GUI.color = Color.white; + + // ── 活跃请求 ────────────────────────────────────────────────────── + SettingsUIHelper.DrawSectionHeader(listing, "RimMind.Core.Settings.Queue.ActiveRequests".Translate()); + + var activeRequests = queue.GetActiveRequests(); + if (activeRequests.Count == 0) + { + GUI.color = Color.gray; + listing.Label("RimMind.Core.Settings.Queue.NoActive".Translate()); + GUI.color = Color.white; + } + else + { + foreach (var req in activeRequests) + { + int elapsedTicks = Find.TickManager.TicksGame - req.StartedProcessingAtTick; + float elapsedSec = elapsedTicks / 60f; + string priority = req.Request.Priority.ToString(); + string info = $"[{req.Request.ModId}] {req.Request.RequestId} " + + $"{"RimMind.Core.Settings.Queue.Priority".Translate()}: {priority} " + + $"{"RimMind.Core.Settings.Queue.Attempt".Translate()}: {req.AttemptCount}/{req.MaxAttempts} " + + $"{"RimMind.Core.Settings.Queue.Elapsed".Translate()}: {elapsedSec:F1}s"; + GUI.color = new Color(0.7f, 0.85f, 1f); + listing.Label(info); + } + } + GUI.color = Color.white; + + // ── 排队请求 ────────────────────────────────────────────────────── + SettingsUIHelper.DrawSectionHeader(listing, "RimMind.Core.Settings.Queue.QueuedRequests".Translate()); + + var queuedRequests = queue.GetAllQueuedRequests(); + if (queuedRequests.Count == 0) + { + GUI.color = Color.gray; + listing.Label("RimMind.Core.Settings.Queue.NoQueued".Translate()); + GUI.color = Color.white; + } + else + { + foreach (var req in queuedRequests) + { + int waitTicks = Find.TickManager.TicksGame - req.EnqueuedAtTick; + float waitSec = waitTicks / 60f; + string priority = req.Request.Priority.ToString(); + string info = $"[{req.Request.ModId}] {req.Request.RequestId} " + + $"{"RimMind.Core.Settings.Queue.Priority".Translate()}: {priority} " + + $"{"RimMind.Core.Settings.Queue.Attempt".Translate()}: {req.AttemptCount}/{req.MaxAttempts} " + + $"{"RimMind.Core.Settings.Queue.Waiting".Translate()}: {waitSec:F1}s"; + GUI.color = new Color(0.85f, 0.85f, 0.7f); + listing.Label(info); + } + } + GUI.color = Color.white; + + listing.End(); + Widgets.EndScrollView(); + } + // ── 自定义提示词分页 ────────────────────────────────────────────────── private static void DrawPromptsTab(Rect inRect) @@ -367,29 +577,31 @@ private static void DrawContextTab(Rect inRect) left.Label("RimMind.Core.Context.PawnInfo".Translate()); GUI.color = Color.white; left.Gap(4f); - left.CheckboxLabeled("RimMind.Core.Context.IncludeRace".Translate(), ref ctx.IncludeRace); - left.CheckboxLabeled("RimMind.Core.Context.IncludeAge".Translate(), ref ctx.IncludeAge); - left.CheckboxLabeled("RimMind.Core.Context.IncludeGender".Translate(), ref ctx.IncludeGender); - left.CheckboxLabeled("RimMind.Core.Context.IncludeBackstory".Translate(), ref ctx.IncludeBackstory); - left.CheckboxLabeled("RimMind.Core.Context.IncludeIdeology".Translate(), ref ctx.IncludeIdeology); - left.CheckboxLabeled("RimMind.Core.Context.IncludeTraits".Translate(), ref ctx.IncludeTraits); - left.CheckboxLabeled("RimMind.Core.Context.IncludeSkills".Translate(), ref ctx.IncludeSkills); + left.CheckboxLabeled("RimMind.Core.Context.IncludeRace".Translate(), ref ctx.IncludeRace, "RimMind.Core.Context.IncludeRace.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeAge".Translate(), ref ctx.IncludeAge, "RimMind.Core.Context.IncludeAge.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeGender".Translate(), ref ctx.IncludeGender, "RimMind.Core.Context.IncludeGender.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeBackstory".Translate(), ref ctx.IncludeBackstory, "RimMind.Core.Context.IncludeBackstory.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeIdeology".Translate(), ref ctx.IncludeIdeology, "RimMind.Core.Context.IncludeIdeology.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeTraits".Translate(), ref ctx.IncludeTraits, "RimMind.Core.Context.IncludeTraits.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeSkills".Translate(), ref ctx.IncludeSkills, "RimMind.Core.Context.IncludeSkills.Desc".Translate()); if (ctx.IncludeSkills) { left.Label($" {"RimMind.Core.Context.MinSkillLevel".Translate()}: {ctx.MinSkillLevel}"); ctx.MinSkillLevel = (int)left.Slider(ctx.MinSkillLevel, 1f, 15f); } - left.CheckboxLabeled("RimMind.Core.Context.IncludeHealth".Translate(), ref ctx.IncludeHealth); - left.CheckboxLabeled("RimMind.Core.Context.IncludeMood".Translate(), ref ctx.IncludeMood); - left.CheckboxLabeled("RimMind.Core.Context.IncludeMoodThoughts".Translate(), ref ctx.IncludeMoodThoughts); - left.CheckboxLabeled("RimMind.Core.Context.IncludeCurrentJob".Translate(), ref ctx.IncludeCurrentJob); - left.CheckboxLabeled("RimMind.Core.Context.IncludeWorkPriorities".Translate(), ref ctx.IncludeWorkPriorities); - left.CheckboxLabeled("RimMind.Core.Context.IncludeEquipment".Translate(), ref ctx.IncludeEquipment); - left.CheckboxLabeled("RimMind.Core.Context.IncludeLocation".Translate(), ref ctx.IncludeLocation); - left.CheckboxLabeled("RimMind.Core.Context.IncludeRelations".Translate(), ref ctx.IncludeRelations); - left.CheckboxLabeled("RimMind.Core.Context.IncludeGenes".Translate(), ref ctx.IncludeGenes); - left.CheckboxLabeled("RimMind.Core.Context.IncludeCombatStatus".Translate(), ref ctx.IncludeCombatStatus); - left.CheckboxLabeled("RimMind.Core.Context.IncludeSurroundings".Translate(), ref ctx.IncludeSurroundings); + left.CheckboxLabeled("RimMind.Core.Context.IncludeHealth".Translate(), ref ctx.IncludeHealth, "RimMind.Core.Context.IncludeHealth.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeCapacities".Translate(), ref ctx.IncludeCapacities, "RimMind.Core.Context.IncludeCapacities.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeMood".Translate(), ref ctx.IncludeMood, "RimMind.Core.Context.IncludeMood.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeMoodThoughts".Translate(), ref ctx.IncludeMoodThoughts, "RimMind.Core.Context.IncludeMoodThoughts.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeCurrentJob".Translate(), ref ctx.IncludeCurrentJob, "RimMind.Core.Context.IncludeCurrentJob.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeWorkPriorities".Translate(), ref ctx.IncludeWorkPriorities, "RimMind.Core.Context.IncludeWorkPriorities.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeEquipment".Translate(), ref ctx.IncludeEquipment, "RimMind.Core.Context.IncludeEquipment.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeInventory".Translate(), ref ctx.IncludeInventory, "RimMind.Core.Context.IncludeInventory.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeLocation".Translate(), ref ctx.IncludeLocation, "RimMind.Core.Context.IncludeLocation.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeRelations".Translate(), ref ctx.IncludeRelations, "RimMind.Core.Context.IncludeRelations.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeGenes".Translate(), ref ctx.IncludeGenes, "RimMind.Core.Context.IncludeGenes.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeCombatStatus".Translate(), ref ctx.IncludeCombatStatus, "RimMind.Core.Context.IncludeCombatStatus.Desc".Translate()); + left.CheckboxLabeled("RimMind.Core.Context.IncludeSurroundings".Translate(), ref ctx.IncludeSurroundings, "RimMind.Core.Context.IncludeSurroundings.Desc".Translate()); float leftH = left.CurHeight; left.End(); @@ -399,13 +611,14 @@ private static void DrawContextTab(Rect inRect) right.Label("RimMind.Core.Context.Environment".Translate()); GUI.color = Color.white; right.Gap(4f); - right.CheckboxLabeled("RimMind.Core.Context.IncludeGameTime".Translate(), ref ctx.IncludeGameTime); - right.CheckboxLabeled("RimMind.Core.Context.IncludeColonistCount".Translate(), ref ctx.IncludeColonistCount); - right.CheckboxLabeled("RimMind.Core.Context.IncludeWealth".Translate(), ref ctx.IncludeWealth); - right.CheckboxLabeled("RimMind.Core.Context.IncludeFood".Translate(), ref ctx.IncludeFood); - right.CheckboxLabeled("RimMind.Core.Context.IncludeSeason".Translate(), ref ctx.IncludeSeason); - right.CheckboxLabeled("RimMind.Core.Context.IncludeWeather".Translate(), ref ctx.IncludeWeather); - right.CheckboxLabeled("RimMind.Core.Context.IncludeThreats".Translate(), ref ctx.IncludeThreats); + right.CheckboxLabeled("RimMind.Core.Context.IncludeGameTime".Translate(), ref ctx.IncludeGameTime, "RimMind.Core.Context.IncludeGameTime.Desc".Translate()); + right.CheckboxLabeled("RimMind.Core.Context.IncludeColonistCount".Translate(), ref ctx.IncludeColonistCount, "RimMind.Core.Context.IncludeColonistCount.Desc".Translate()); + right.CheckboxLabeled("RimMind.Core.Context.IncludeColonistNames".Translate(), ref ctx.IncludeColonistNames, "RimMind.Core.Context.IncludeColonistNames.Desc".Translate()); + right.CheckboxLabeled("RimMind.Core.Context.IncludeWealth".Translate(), ref ctx.IncludeWealth, "RimMind.Core.Context.IncludeWealth.Desc".Translate()); + right.CheckboxLabeled("RimMind.Core.Context.IncludeFood".Translate(), ref ctx.IncludeFood, "RimMind.Core.Context.IncludeFood.Desc".Translate()); + right.CheckboxLabeled("RimMind.Core.Context.IncludeSeason".Translate(), ref ctx.IncludeSeason, "RimMind.Core.Context.IncludeSeason.Desc".Translate()); + right.CheckboxLabeled("RimMind.Core.Context.IncludeWeather".Translate(), ref ctx.IncludeWeather, "RimMind.Core.Context.IncludeWeather.Desc".Translate()); + right.CheckboxLabeled("RimMind.Core.Context.IncludeThreats".Translate(), ref ctx.IncludeThreats, "RimMind.Core.Context.IncludeThreats.Desc".Translate()); float rightH = right.CurHeight; right.End(); @@ -482,8 +695,6 @@ private static float EstimateApiHeight() h += 24f + 24f; h += 24f + 24f + 32f; h += 24f; - int modCount = RimMindAPI.ModCooldownGetters.Count; - h += modCount * 24f; h += 24f; h += 24f + 24f; return h + 40f; diff --git a/Source/UI/RequestEntry.cs b/Source/UI/RequestEntry.cs index 5bde6e22..cf6ded91 100644 --- a/Source/UI/RequestEntry.cs +++ b/Source/UI/RequestEntry.cs @@ -10,6 +10,7 @@ public class RequestEntry public string title = ""; public string? description; public string[] options = new string[0]; + public string[]? optionTooltips; public Action? callback; public bool systemBlocked; public int tick; diff --git a/Source/UI/RequestOverlay.cs b/Source/UI/RequestOverlay.cs index 19ce1328..578adb2f 100644 --- a/Source/UI/RequestOverlay.cs +++ b/Source/UI/RequestOverlay.cs @@ -154,6 +154,8 @@ private static void DrawEntries(Rect inRect) _pending.RemoveAt(i); break; } + if (entry.optionTooltips != null && j < entry.optionTooltips.Length && !entry.optionTooltips[j].NullOrEmpty()) + TooltipHandler.TipRegion(btnRect, entry.optionTooltips[j]); } y += entryH; diff --git a/Source/UI/Window_AIDebugLog.cs b/Source/UI/Window_AIDebugLog.cs index fc5f87ea..06a5d177 100644 --- a/Source/UI/Window_AIDebugLog.cs +++ b/Source/UI/Window_AIDebugLog.cs @@ -113,12 +113,17 @@ private void DrawLeftPane(Rect r) string status = entry.IsError ? "✗" : "✓"; Widgets.Label(new Rect(textRect.x, textRect.y, textRect.width, 18f), - $"{status} {entry.FormattedTime} {entry.ElapsedMs}ms"); + $"{status} {entry.FormattedTime} {entry.ElapsedMs}ms [{entry.Priority}]"); Widgets.Label(new Rect(textRect.x, textRect.y + 18f, textRect.width, 18f), entry.Source); GUI.color = Color.gray; + string telemetry = $"{entry.ModelName} {entry.TokensUsed} {"RimMind.Core.UI.DebugLog.Tok".Translate()}"; + if (entry.AttemptCount > 1) + telemetry += $" {"RimMind.Core.UI.DebugLog.Retry".Translate()}={entry.AttemptCount}"; + if (entry.QueueWaitMs > 0) + telemetry += $" {"RimMind.Core.UI.DebugLog.Wait".Translate()}={entry.QueueWaitMs}ms"; Widgets.Label(new Rect(textRect.x, textRect.y + 36f, textRect.width, 16f), - $"{entry.ModelName} {entry.TokensUsed} tok"); + telemetry); GUI.color = Color.white; if (Widgets.ButtonInvisible(row)) _selected = entry; @@ -159,8 +164,14 @@ private void DrawRightPane(Rect r) // 元数据行 GUI.color = Color.gray; - Widgets.Label(new Rect(r.x, r.y + 4f, r.width - 140f, 20f), - $"{_selected.Source} | {_selected.ModelName} | {_selected.TokensUsed} tok | {_selected.ElapsedMs}ms"); + string metaLine = $"{_selected.Source} | {_selected.ModelName} | {_selected.TokensUsed} {"RimMind.Core.UI.DebugLog.Tok".Translate()} | {_selected.ElapsedMs}ms | {_selected.Priority}"; + if (_selected.AttemptCount > 1) + metaLine += $" | {"RimMind.Core.UI.DebugLog.Attempt".Translate()}={_selected.AttemptCount}"; + if (_selected.QueueWaitMs > 0) + metaLine += $" | {"RimMind.Core.UI.DebugLog.QueueWait".Translate()}={_selected.QueueWaitMs}ms"; + if (_selected.HttpStatusCode > 0) + metaLine += $" | HTTP {_selected.HttpStatusCode}"; + Widgets.Label(new Rect(r.x, r.y + 4f, r.width - 140f, 20f), metaLine); GUI.color = Color.white; string emptyLbl = "RimMind.Core.UI.Empty".Translate();