From 4764f67b92495bc368c6683043a3a031fbc4f2a7 Mon Sep 17 00:00:00 2001
From: mcocdaa <2021137961@qq.com>
Date: Sun, 19 Apr 2026 15:30:08 +0800
Subject: [PATCH 1/3] init
---
AGENTS.md | 299 ++++++++++---
About/About.xml | 1 +
CLAUDE.md | 289 ++++++++++---
INTRODUCTION.md | 4 +-
INTRODUCTION.txt | 74 ++++
.../ChineseSimplified/Keyed/RimMind_Core.xml | 71 ++++
Languages/English/Keyed/RimMind_Core.xml | 71 ++++
README.md | 20 +-
Source/AICoreAPI.cs | 183 +++++++-
Source/Client/AIRequest.cs | 6 +-
Source/Client/AIRequestState.cs | 18 +
Source/Client/AIResponse.cs | 27 +-
Source/Client/IAIClient.cs | 4 +-
Source/Client/OpenAI/OpenAIClient.cs | 39 +-
Source/Core/AIDebugLog.cs | 17 +-
Source/Core/AIRequestQueue.cs | 395 +++++++++++++++---
Source/Core/GameContextBuilder.cs | 43 +-
Source/Core/Prompt/ContextComposer.cs | 3 +-
Source/Core/Prompt/PromptSanitizer.cs | 11 +
Source/Core/Prompt/StructuredPromptBuilder.cs | 2 +-
Source/Debug/AICoreDebugActions.cs | 50 ++-
Source/Settings/AICoreSettings.cs | 17 +-
Source/Settings/ContextSettings.cs | 13 +-
Source/UI/AICoreSettingsUI.cs | 313 +++++++++++---
Source/UI/RequestEntry.cs | 1 +
Source/UI/RequestOverlay.cs | 2 +
Source/UI/Window_AIDebugLog.cs | 19 +-
27 files changed, 1711 insertions(+), 281 deletions(-)
create mode 100644 INTRODUCTION.txt
create mode 100644 Source/Client/AIRequestState.cs
create mode 100644 Source/Core/Prompt/PromptSanitizer.cs
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..771c01bc
--- /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/mcocdaa/RimWorld-RimMind-Mod-Core] 链接 [/url][/td][/tr][tr][td] RimMind-Actions [/td][td] AI 控制小人的动作执行库 [/td][td] Core [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Actions] 链接 [/url][/td][/tr][tr][td] RimMind-Advisor [/td][td] AI 扮演小人做出工作决策 [/td][td] Core, Actions [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Advisor] 链接 [/url][/td][/tr][tr][td] RimMind-Dialogue [/td][td] AI 驱动的对话系统 [/td][td] Core [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Dialogue] 链接 [/url][/td][/tr][tr][td] RimMind-Memory [/td][td] 记忆采集与上下文注入 [/td][td] Core [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Memory] 链接 [/url][/td][/tr][tr][td] RimMind-Personality [/td][td] AI 生成人格与想法 [/td][td] Core [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Personality] 链接 [/url][/td][/tr][tr][td] RimMind-Storyteller [/td][td] AI 叙事者,智能选择事件 [/td][td] Core [/td][td][url=https://github.com/mcocdaa/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 设置文件中,不会上传到任何服务器。
+
+[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..f3bd1065 100644
--- a/Languages/ChineseSimplified/Keyed/RimMind_Core.xml
+++ b/Languages/ChineseSimplified/Keyed/RimMind_Core.xml
@@ -43,6 +43,40 @@
模型行为
请求设置
调试
+ 最大并发请求数
+ 同时向 AI 发送请求的上限。增大可提高响应速度,但会增加 API 费用。
+ 最大重试次数
+ 请求失败后的最大重试次数。设为 0 则不重试。
+ 请求超时(秒)
+ 等待 AI 响应的最大时间,超时后请求将被取消。网络较慢或模型较大时可适当增大。
+ 队列:已暂停
+ 队列:运行中
+ 队列状态已移至「队列」标签页查看
+ 队列
+ 队列尚未初始化(需进入游戏存档后可用)
+ 总体状态
+ 活跃请求
+ 排队请求
+ 本地模型
+ 忙碌
+ 空闲
+ 恢复队列
+ 暂停队列
+ 清空队列
+ 清空冷却
+ 各 Mod 队列
+ 暂无注册的 Mod 队列
+ 冷却
+ 就绪
+ 排队
+ 活跃请求详情
+ 当前无活跃请求
+ 排队请求详情
+ 当前无排队请求
+ 优先级
+ 尝试
+ 已用时
+ 等待
这是一个测试信息,请用一句话回复。
@@ -64,33 +98,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 +195,8 @@
特性:{0}
武器:{0}
服装:{0}
+ 携带:{0}
+ 损坏
室内
室外
位置:{0} 温度:{1}°C
@@ -166,6 +234,9 @@
全局地图提示词
追加在地图上下文末尾的自定义提示词(留空则不追加):
+
+ ({0}条更早的记录已省略)
+
[玩家自定义人物提示词]
[玩家自定义地图提示词]
diff --git a/Languages/English/Keyed/RimMind_Core.xml b/Languages/English/Keyed/RimMind_Core.xml
index 5956994d..c8f067be 100644
--- a/Languages/English/Keyed/RimMind_Core.xml
+++ b/Languages/English/Keyed/RimMind_Core.xml
@@ -43,6 +43,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 +98,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 position 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 +195,8 @@
Traits: {0}
Weapon: {0}
Apparel: {0}
+ Carried: {0}
+ damaged
Indoors
Outdoors
Location: {0} Temp: {1}°C
@@ -166,6 +234,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..6a80be92 100644
--- a/README.md
+++ b/README.md
@@ -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+ 个可选项,三种预设 |
## 常见问题
@@ -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..10b721d1 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,44 @@ 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 result = new List();
+ result.AddRange(_staticProviders.Keys);
+ result.AddRange(_pawnProviders.Keys);
+ result.AddRange(_dynamicProviders.Keys);
+ return result;
+ }
+
// ── Provider 卸载 ──────────────────────────────────────────────────────
public static void UnregisterPawnContextProvider(string category)
@@ -350,6 +416,117 @@ 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)
+ {
+ try
+ {
+ if (kvp.Value(pawn, triggerType)) return true;
+ }
+ catch (System.Exception ex)
+ {
+ Log.Warning($"[RimMind] DialogueSkipCheck '{kvp.Key}' 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 kvp in _floatMenuSkipChecks)
+ {
+ try
+ {
+ if (kvp.Value()) return true;
+ }
+ catch (System.Exception ex)
+ {
+ Log.Warning($"[RimMind] FloatMenuSkipCheck '{kvp.Key}' 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 kvp in _actionSkipChecks)
+ {
+ try
+ {
+ if (kvp.Value(intentId)) return true;
+ }
+ catch (System.Exception ex)
+ {
+ Log.Warning($"[RimMind] ActionSkipCheck '{kvp.Key}' 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)
+ {
+ try { cb(); }
+ catch (System.Exception ex) { Log.Warning($"[RimMind] IncidentExecuted callback error: {ex.Message}"); }
+ }
+ }
+
+ private static readonly List> _storytellerIncidentSkipChecks = new List>();
+
+ public static void RegisterStorytellerIncidentSkipCheck(Func check)
+ {
+ _storytellerIncidentSkipChecks.Add(check);
+ }
+
+ public static bool ShouldSkipStorytellerIncident()
+ {
+ foreach (var check in _storytellerIncidentSkipChecks)
+ {
+ 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..15e821cf
--- /dev/null
+++ b/Source/Client/AIRequestState.cs
@@ -0,0 +1,18 @@
+namespace RimMind.Core.Client
+{
+ public enum AIRequestState
+ {
+ Queued,
+ Processing,
+ Completed,
+ Error,
+ Cancelled
+ }
+
+ 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..74fb82d1 100644
--- a/Source/Client/OpenAI/OpenAIClient.cs
+++ b/Source/Client/OpenAI/OpenAIClient.cs
@@ -13,10 +13,6 @@
namespace RimMind.Core.Client.OpenAI
{
- ///
- /// OpenAI 兼容 API 客户端(支持 OpenAI / DeepSeek / 本地 Ollama 等)。
- /// 使用 UnityWebRequest(RimWorld 内置),在协程外通过 async/await + Task.Delay 轮询。
- ///
public class OpenAIClient : IAIClient
{
private readonly RimMindCoreSettings _settings;
@@ -28,6 +24,15 @@ public OpenAIClient(RimMindCoreSettings settings)
public bool IsConfigured() => _settings.IsConfigured();
+ public bool IsLocalEndpoint
+ {
+ get
+ {
+ string ep = _settings.apiEndpoint ?? string.Empty;
+ return ep.Contains("localhost") || ep.Contains("127.0.0.1");
+ }
+ }
+
public async Task SendAsync(AIRequest request)
{
string endpoint = FormatEndpoint(_settings.apiEndpoint);
@@ -39,7 +44,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 +54,38 @@ 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 (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,9 +108,7 @@ 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");
float connectTimeout = isLocal ? 300f : 60f;
@@ -111,7 +116,7 @@ private async Task PostAsync(string url, string jsonBody)
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,8 +149,8 @@ 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 ?? "";
@@ -153,7 +158,7 @@ private async Task PostAsync(string url, string jsonBody)
throw new Exception($"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..d09f9a23 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;
@@ -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..972734e9 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;
@@ -18,9 +20,19 @@ public class AIRequestQueue : GameComponent
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 +47,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()
{
@@ -55,6 +84,8 @@ public override void GameComponentTick()
}
}
+ CheckActiveRequestTimeouts();
+
int now = Find.TickManager.TicksGame;
if (now - _lastQueueProcessTick >= QueueProcessInterval)
{
@@ -70,81 +101,335 @@ 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,
+ 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,
+ 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.Client.IsLocalEndpoint && _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.Client.IsLocalEndpoint)
+ _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 (!_modQueues.TryGetValue(modId, out var queue) || queue.Count == 0)
- return;
-
+ if (_isPaused) return;
if (_modCooldowns.TryGetValue(modId, out int nextAllowed) && now < nextAllowed)
return;
+ if (!_modQueues.TryGetValue(modId, out var queue) || queue.Count == 0)
+ return;
- var settings = RimMindCoreMod.Settings;
+ ProcessAllQueues(now);
+ }
- while (queue.Count > 0)
+ private void FireRequest(TrackedRequest tracked)
+ {
+ Task.Run(async () =>
{
- var pending = queue.Peek();
+ 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);
+ }
- if (pending.Request.ExpireAtTicks > 0 && now > pending.Request.ExpireAtTicks)
+ long queueWaitMs = 0;
+ if (tracked.StartedProcessingAtTick > 0 && tracked.EnqueuedAtTick > 0)
{
- 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;
+ queueWaitMs = (tracked.StartedProcessingAtTick - tracked.EnqueuedAtTick) * 16L;
}
- int cooldownTicks = GetModCooldownTicks(modId);
- _modCooldowns[modId] = now + cooldownTicks;
+ response.AttemptCount = tracked.AttemptCount;
+ response.QueueWaitMs = queueWaitMs;
+ response.Priority = tracked.Request.Priority;
- var req = queue.Dequeue();
+ if (tracked.Client.IsLocalEndpoint)
+ {
+ _instance!._isProcessingLocalRequest = false;
+ }
- if (settings.debugLogging)
- Log.Message($"[RimMind][Core] Processing request {req.Request.RequestId} for mod {modId}, cooldown={cooldownTicks} ticks");
+ bool shouldRetry = !response.Success
+ && tracked.AttemptCount < tracked.MaxAttempts
+ && IsTransientError(response.Error);
- Task.Run(async () =>
+ if (shouldRetry)
{
- var response = await req.Client.SendAsync(req.Request);
- _results.Enqueue((response, req.Callback));
- });
+ tracked.AttemptCount++;
+ tracked.State = AIRequestState.Queued;
+ tracked.StartedProcessingAtTick = 0;
+
+ _instance!._activeRequests.Remove(tracked.TrackingId);
+ _instance!._requestIdToActive.Remove(tracked.Request.RequestId);
+
+ string modId = tracked.Request.ModId;
+ if (!_instance!._modQueues.TryGetValue(modId, out var queue))
+ {
+ queue = new List();
+ _instance!._modQueues[modId] = queue;
+ }
+
+ int insertIdx = queue.FindIndex(t => t.Request.Priority > tracked.Request.Priority);
+ if (insertIdx >= 0)
+ queue.Insert(insertIdx, tracked);
+ else
+ queue.Add(tracked);
+
+ if (RimMindCoreMod.Settings.debugLogging)
+ AIRequestQueue.LogFromBackground(
+ $"[RimMind] Retrying request {tracked.Request.RequestId} (attempt {tracked.AttemptCount}/{tracked.MaxAttempts})");
+
+ return;
+ }
+
+ _instance!._activeRequests.Remove(tracked.TrackingId);
+ _instance!._requestIdToActive.Remove(tracked.Request.RequestId);
+
+ _instance!._results.Enqueue((response, tracked.Callback));
+ });
+ }
+
+ private void CheckActiveRequestTimeouts()
+ {
+ if (_activeRequests.Count == 0) return;
- break;
+ int now = Find.TickManager.TicksGame;
+ int timeoutMs = RimMindCoreMod.Settings.requestTimeoutMs;
+ int timeoutTicks = timeoutMs / 16;
+
+ var timedOut = new List();
+
+ foreach (var kvp in _activeRequests)
+ {
+ var tracked = kvp.Value;
+ if (tracked.StartedProcessingAtTick > 0 && now - tracked.StartedProcessingAtTick > timeoutTicks)
+ {
+ timedOut.Add(tracked);
+ }
+ }
+
+ foreach (var tracked in timedOut)
+ {
+ _activeRequests.Remove(tracked.TrackingId);
+ _requestIdToActive.Remove(tracked.Request.RequestId);
+
+ if (tracked.Client.IsLocalEndpoint)
+ _isProcessingLocalRequest = false;
+
+ 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.Client.IsLocalEndpoint)
+ _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 +475,34 @@ 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 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..eb432d35 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
{
@@ -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..607b2bbf 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;
+ }
+
+ int modCount = queue.GetAllQueueDepths().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());
+
+ var allDepths = queue.GetAllQueueDepths();
+ var allCooldowns = queue.GetAllCooldowns();
+ var allModIds = new HashSet(allDepths.Keys);
+ allModIds.UnionWith(allCooldowns.Keys);
+ allModIds.UnionWith(RimMindAPI.ModCooldownGetters.Keys);
+
+ 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..7f3e0612 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} tok";
+ if (entry.AttemptCount > 1)
+ telemetry += $" retry={entry.AttemptCount}";
+ if (entry.QueueWaitMs > 0)
+ telemetry += $" wait={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} tok | {_selected.ElapsedMs}ms | {_selected.Priority}";
+ if (_selected.AttemptCount > 1)
+ metaLine += $" | attempt={_selected.AttemptCount}";
+ if (_selected.QueueWaitMs > 0)
+ metaLine += $" | queueWait={_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();
From 18231850d0b60a2438a758405139a6f22b2034ab Mon Sep 17 00:00:00 2001
From: mcocdaa <2021137961@qq.com>
Date: Sun, 19 Apr 2026 19:52:11 +0800
Subject: [PATCH 2/3] fixurl
---
INTRODUCTION.txt | 2 +-
README.md | 36 ++++++++++++++++++------------------
2 files changed, 19 insertions(+), 19 deletions(-)
diff --git a/INTRODUCTION.txt b/INTRODUCTION.txt
index 771c01bc..146f9079 100644
--- a/INTRODUCTION.txt
+++ b/INTRODUCTION.txt
@@ -5,7 +5,7 @@ RimMind 套件的核心基础设施,提供 LLM 客户端、异步请求队列
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/mcocdaa/RimWorld-RimMind-Mod-Core] 链接 [/url][/td][/tr][tr][td] RimMind-Actions [/td][td] AI 控制小人的动作执行库 [/td][td] Core [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Actions] 链接 [/url][/td][/tr][tr][td] RimMind-Advisor [/td][td] AI 扮演小人做出工作决策 [/td][td] Core, Actions [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Advisor] 链接 [/url][/td][/tr][tr][td] RimMind-Dialogue [/td][td] AI 驱动的对话系统 [/td][td] Core [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Dialogue] 链接 [/url][/td][/tr][tr][td] RimMind-Memory [/td][td] 记忆采集与上下文注入 [/td][td] Core [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Memory] 链接 [/url][/td][/tr][tr][td] RimMind-Personality [/td][td] AI 生成人格与想法 [/td][td] Core [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Personality] 链接 [/url][/td][/tr][tr][td] RimMind-Storyteller [/td][td] AI 叙事者,智能选择事件 [/td][td] Core [/td][td][url=https://github.com/mcocdaa/RimWorld-RimMind-Mod-Storyteller] 链接 [/url][/td][/tr][/table]
+[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
diff --git a/README.md b/README.md
index 6a80be92..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
```
@@ -171,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
@@ -185,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
```
From 8210a69b604734d006615a52351696d64b1cdcb9 Mon Sep 17 00:00:00 2001
From: mcocdaa <2021137961@qq.com>
Date: Sun, 19 Apr 2026 20:40:03 +0800
Subject: [PATCH 3/3] fix: address CodeRabbit PR#4 review - thread safety, API
correctness, i18n
- AIDebugLog: move Find.TickManager.TicksGame to GameComponentTick (main thread)
- OpenAIClient: add AIHttpException to preserve HTTP status code on failure
- OpenAIClient: replace string.Contains with Uri.IsLoopback for IsLocalEndpoint
- AIRequestQueue: move all state mutations from FireRequest background task to main thread via PendingFireResult queue
- AIRequestQueue: add IsLocalEndpointSnapshot to TrackedRequest for thread safety
- AICoreAPI: filter GetRegisteredCategories by exposedProviders
- AICoreAPI: snapshot iteration for skip checks and callbacks
- AICoreAPI: add UnregisterIncidentExecutedCallback and UnregisterStorytellerIncidentSkipCheck
- INTRODUCTION.txt: fix misleading API Key security statement
- RimMind_Core.xml: fix IncludeLocation.Desc (position -> room/indoors/outdoors)
- AICoreSettingsUI: fix queue tab height calculation to include cooldown-only mods
- Window_AIDebugLog: replace hardcoded English strings with translation keys
- ContextComposer: add PromptSanitizer.Sanitize to BuildFromSections
- AIRequestState: explicit enum values for serialization stability
---
INTRODUCTION.txt | 2 +-
.../ChineseSimplified/Keyed/RimMind_Core.xml | 7 +-
Languages/English/Keyed/RimMind_Core.xml | 7 +-
Source/AICoreAPI.cs | 47 ++++++----
Source/Client/AIRequestState.cs | 10 +-
Source/Client/OpenAI/OpenAIClient.cs | 43 +++++++--
Source/Core/AIDebugLog.cs | 2 +-
Source/Core/AIRequestQueue.cs | 94 ++++++++++++-------
Source/Core/Prompt/ContextComposer.cs | 2 +-
Source/UI/AICoreSettingsUI.cs | 14 +--
Source/UI/Window_AIDebugLog.cs | 12 +--
11 files changed, 159 insertions(+), 81 deletions(-)
diff --git a/INTRODUCTION.txt b/INTRODUCTION.txt
index 146f9079..c4eb0e17 100644
--- a/INTRODUCTION.txt
+++ b/INTRODUCTION.txt
@@ -65,7 +65,7 @@ A: 任何兼容 OpenAI Chat Completions API 的模型均可使用,包括 OpenA
A: 不会。所有 AI 请求在后台线程执行,主线程只处理回调结果。
[b] Q: API Key 安全吗? [/b]
-A: API Key 仅存储在本地 RimWorld 设置文件中,不会上传到任何服务器。
+A: API Key 仅存储在本地 RimWorld 设置文件中;发起 AI 请求时会发送给你配置的 API 端点,不会上传到 RimMind 自有服务器。
[b] Q: 不填 API Key 会怎样? [/b]
A: Core 本身不会报错,但所有依赖 AI 的子模组功能将无法工作。
diff --git a/Languages/ChineseSimplified/Keyed/RimMind_Core.xml b/Languages/ChineseSimplified/Keyed/RimMind_Core.xml
index f3bd1065..d531ac74 100644
--- a/Languages/ChineseSimplified/Keyed/RimMind_Core.xml
+++ b/Languages/ChineseSimplified/Keyed/RimMind_Core.xml
@@ -7,6 +7,11 @@
复制响应
(点击左侧条目查看详情)
{0} 条记录
+ tok
+ 重试
+ 等待
+ 尝试
+ 排队等待
(空)
@@ -130,7 +135,7 @@
携带物品
将小人携带的物品(背包)注入 AI 上下文。
位置与温度
- 将小人的位置坐标和所在区域温度注入 AI 上下文。
+ 将小人所在区域(室内/室外/房间类型)和温度注入 AI 上下文。
社交关系
将小人与其他人的社交关系注入 AI 上下文。
基因(需 Biotech DLC)
diff --git a/Languages/English/Keyed/RimMind_Core.xml b/Languages/English/Keyed/RimMind_Core.xml
index c8f067be..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)
@@ -130,7 +135,7 @@
Carried Items
Inject the pawn's carried items (inventory) into AI context.
Location & Temperature
- Inject the pawn's position and local temperature into AI context.
+ 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)
diff --git a/Source/AICoreAPI.cs b/Source/AICoreAPI.cs
index 10b721d1..0d6bf49c 100644
--- a/Source/AICoreAPI.cs
+++ b/Source/AICoreAPI.cs
@@ -338,11 +338,16 @@ public static void RegisterPawnContextProvider(string category, Func GetRegisteredCategories()
{
- var result = new List();
- result.AddRange(_staticProviders.Keys);
- result.AddRange(_pawnProviders.Keys);
- result.AddRange(_dynamicProviders.Keys);
- return result;
+ 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 卸载 ──────────────────────────────────────────────────────
@@ -428,15 +433,15 @@ public static void UnregisterDialogueSkipCheck(string sourceId)
public static bool ShouldSkipDialogue(Pawn pawn, string triggerType)
{
- foreach (var kvp in _dialogueSkipChecks)
+ foreach (var kvp in _dialogueSkipChecks.Values.ToList())
{
try
{
- if (kvp.Value(pawn, triggerType)) return true;
+ if (kvp(pawn, triggerType)) return true;
}
catch (System.Exception ex)
{
- Log.Warning($"[RimMind] DialogueSkipCheck '{kvp.Key}' error: {ex.Message}");
+ Log.Warning($"[RimMind] DialogueSkipCheck error: {ex.Message}");
}
}
return false;
@@ -452,15 +457,15 @@ public static void UnregisterFloatMenuSkipCheck(string sourceId)
public static bool ShouldSkipFloatMenu()
{
- foreach (var kvp in _floatMenuSkipChecks)
+ foreach (var check in _floatMenuSkipChecks.Values.ToList())
{
try
{
- if (kvp.Value()) return true;
+ if (check()) return true;
}
catch (System.Exception ex)
{
- Log.Warning($"[RimMind] FloatMenuSkipCheck '{kvp.Key}' error: {ex.Message}");
+ Log.Warning($"[RimMind] FloatMenuSkipCheck error: {ex.Message}");
}
}
return false;
@@ -478,15 +483,15 @@ public static void UnregisterActionSkipCheck(string sourceId)
public static bool ShouldSkipAction(string intentId)
{
- foreach (var kvp in _actionSkipChecks)
+ foreach (var check in _actionSkipChecks.Values.ToList())
{
try
{
- if (kvp.Value(intentId)) return true;
+ if (check(intentId)) return true;
}
catch (System.Exception ex)
{
- Log.Warning($"[RimMind] ActionSkipCheck '{kvp.Key}' error: {ex.Message}");
+ Log.Warning($"[RimMind] ActionSkipCheck error: {ex.Message}");
}
}
return false;
@@ -503,13 +508,18 @@ public static void RegisterIncidentExecutedCallback(Action callback)
public static void NotifyIncidentExecuted()
{
- foreach (var cb in _incidentExecutedCallbacks)
+ 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)
@@ -517,9 +527,14 @@ 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)
+ foreach (var check in _storytellerIncidentSkipChecks.ToList())
{
try { if (check()) return true; }
catch (System.Exception ex) { Log.Warning($"[RimMind] StorytellerIncidentSkipCheck error: {ex.Message}"); }
diff --git a/Source/Client/AIRequestState.cs b/Source/Client/AIRequestState.cs
index 15e821cf..01962e55 100644
--- a/Source/Client/AIRequestState.cs
+++ b/Source/Client/AIRequestState.cs
@@ -2,11 +2,11 @@ namespace RimMind.Core.Client
{
public enum AIRequestState
{
- Queued,
- Processing,
- Completed,
- Error,
- Cancelled
+ Queued = 0,
+ Processing = 1,
+ Completed = 2,
+ Error = 3,
+ Cancelled = 4
}
public enum AIRequestPriority
diff --git a/Source/Client/OpenAI/OpenAIClient.cs b/Source/Client/OpenAI/OpenAIClient.cs
index 74fb82d1..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;
@@ -15,6 +16,16 @@ namespace RimMind.Core.Client.OpenAI
{
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)
@@ -24,13 +35,17 @@ public OpenAIClient(RimMindCoreSettings settings)
public bool IsConfigured() => _settings.IsConfigured();
- public bool IsLocalEndpoint
+ public bool IsLocalEndpoint => IsLoopbackEndpoint(_settings.apiEndpoint);
+
+ private static bool IsLoopbackEndpoint(string endpoint)
{
- get
- {
- string ep = _settings.apiEndpoint ?? string.Empty;
- return ep.Contains("localhost") || ep.Contains("127.0.0.1");
- }
+ 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)
@@ -61,6 +76,18 @@ public async Task SendAsync(AIRequest request)
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();
@@ -110,7 +137,7 @@ private string BuildRequestJson(AIRequest request)
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;
@@ -155,7 +182,7 @@ private string BuildRequestJson(AIRequest request)
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, webRequest.responseCode);
diff --git a/Source/Core/AIDebugLog.cs b/Source/Core/AIDebugLog.cs
index d09f9a23..a06a6aae 100644
--- a/Source/Core/AIDebugLog.cs
+++ b/Source/Core/AIDebugLog.cs
@@ -26,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);
@@ -38,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,
diff --git a/Source/Core/AIRequestQueue.cs b/Source/Core/AIRequestQueue.cs
index 972734e9..7ad96242 100644
--- a/Source/Core/AIRequestQueue.cs
+++ b/Source/Core/AIRequestQueue.cs
@@ -18,6 +18,18 @@ 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
@@ -75,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); }
@@ -112,6 +129,7 @@ public void Enqueue(AIRequest request, Action callback, IAIClient cl
Request = request,
Callback = callback,
Client = client,
+ IsLocalEndpointSnapshot = client.IsLocalEndpoint,
State = AIRequestState.Queued,
EnqueuedAtTick = Find.TickManager.TicksGame,
EnqueuedAtMs = Stopwatch.GetTimestamp(),
@@ -153,6 +171,7 @@ public void EnqueueImmediate(AIRequest request, Action callback, IAI
Request = request,
Callback = callback,
Client = client,
+ IsLocalEndpointSnapshot = client.IsLocalEndpoint,
State = AIRequestState.Processing,
EnqueuedAtTick = Find.TickManager.TicksGame,
EnqueuedAtMs = Stopwatch.GetTimestamp(),
@@ -219,7 +238,7 @@ private void ProcessAllQueues(int now)
if (_activeRequests.Count >= maxConcurrent)
break;
- if (tracked.Client.IsLocalEndpoint && _isProcessingLocalRequest)
+ if (tracked.IsLocalEndpointSnapshot && _isProcessingLocalRequest)
continue;
if (!_modQueues.TryGetValue(modId, out var queue) || queue.Count == 0 || queue[0] != tracked)
@@ -235,7 +254,7 @@ private void ProcessAllQueues(int now)
_activeRequests[tracked.TrackingId] = tracked;
_requestIdToActive[tracked.Request.RequestId] = tracked;
- if (tracked.Client.IsLocalEndpoint)
+ if (tracked.IsLocalEndpointSnapshot)
_isProcessingLocalRequest = true;
if (RimMindCoreMod.Settings.debugLogging)
@@ -284,49 +303,55 @@ private void FireRequest(TrackedRequest tracked)
response.QueueWaitMs = queueWaitMs;
response.Priority = tracked.Request.Priority;
- if (tracked.Client.IsLocalEndpoint)
- {
- _instance!._isProcessingLocalRequest = false;
- }
-
bool shouldRetry = !response.Success
&& tracked.AttemptCount < tracked.MaxAttempts
&& IsTransientError(response.Error);
- if (shouldRetry)
+ _instance!._pendingFireResults.Enqueue(new PendingFireResult
{
- tracked.AttemptCount++;
- tracked.State = AIRequestState.Queued;
- tracked.StartedProcessingAtTick = 0;
+ Kind = shouldRetry ? FireResultKind.Retry : FireResultKind.Complete,
+ Tracked = tracked,
+ Response = response,
+ });
+ });
+ }
- _instance!._activeRequests.Remove(tracked.TrackingId);
- _instance!._requestIdToActive.Remove(tracked.Request.RequestId);
+ private void ProcessFireResult(PendingFireResult result)
+ {
+ var tracked = result.Tracked;
- string modId = tracked.Request.ModId;
- if (!_instance!._modQueues.TryGetValue(modId, out var queue))
- {
- queue = new List();
- _instance!._modQueues[modId] = queue;
- }
+ _activeRequests.Remove(tracked.TrackingId);
+ _requestIdToActive.Remove(tracked.Request.RequestId);
- int insertIdx = queue.FindIndex(t => t.Request.Priority > tracked.Request.Priority);
- if (insertIdx >= 0)
- queue.Insert(insertIdx, tracked);
- else
- queue.Add(tracked);
+ if (tracked.IsLocalEndpointSnapshot)
+ _isProcessingLocalRequest = false;
- if (RimMindCoreMod.Settings.debugLogging)
- AIRequestQueue.LogFromBackground(
- $"[RimMind] Retrying request {tracked.Request.RequestId} (attempt {tracked.AttemptCount}/{tracked.MaxAttempts})");
+ if (result.Kind == FireResultKind.Retry)
+ {
+ tracked.AttemptCount++;
+ tracked.State = AIRequestState.Queued;
+ tracked.StartedProcessingAtTick = 0;
- return;
+ string modId = tracked.Request.ModId;
+ if (!_modQueues.TryGetValue(modId, out var queue))
+ {
+ queue = new List();
+ _modQueues[modId] = queue;
}
- _instance!._activeRequests.Remove(tracked.TrackingId);
- _instance!._requestIdToActive.Remove(tracked.Request.RequestId);
+ int insertIdx = queue.FindIndex(t => t.Request.Priority > tracked.Request.Priority);
+ if (insertIdx >= 0)
+ queue.Insert(insertIdx, tracked);
+ else
+ queue.Add(tracked);
- _instance!._results.Enqueue((response, tracked.Callback));
- });
+ if (RimMindCoreMod.Settings.debugLogging)
+ Log.Message($"[RimMind] Retrying request {tracked.Request.RequestId} (attempt {tracked.AttemptCount}/{tracked.MaxAttempts})");
+ }
+ else
+ {
+ _results.Enqueue((result.Response, tracked.Callback));
+ }
}
private void CheckActiveRequestTimeouts()
@@ -353,7 +378,7 @@ private void CheckActiveRequestTimeouts()
_activeRequests.Remove(tracked.TrackingId);
_requestIdToActive.Remove(tracked.Request.RequestId);
- if (tracked.Client.IsLocalEndpoint)
+ if (tracked.IsLocalEndpointSnapshot)
_isProcessingLocalRequest = false;
var response = AIResponse.Failure(tracked.Request.RequestId,
@@ -390,7 +415,7 @@ public bool CancelRequest(string requestId)
_activeRequests.Remove(active.TrackingId);
_requestIdToActive.Remove(requestId);
- if (active.Client.IsLocalEndpoint)
+ if (active.IsLocalEndpointSnapshot)
_isProcessingLocalRequest = false;
var response = AIResponse.Cancelled(requestId, "Cancelled by user");
@@ -497,6 +522,7 @@ public class TrackedRequest
public AIRequest Request = null!;
public Action Callback = null!;
public IAIClient Client = null!;
+ public bool IsLocalEndpointSnapshot;
public AIRequestState State;
public int EnqueuedAtTick;
public long EnqueuedAtMs;
diff --git a/Source/Core/Prompt/ContextComposer.cs b/Source/Core/Prompt/ContextComposer.cs
index eb432d35..ae6c13e8 100644
--- a/Source/Core/Prompt/ContextComposer.cs
+++ b/Source/Core/Prompt/ContextComposer.cs
@@ -48,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 = "")
diff --git a/Source/UI/AICoreSettingsUI.cs b/Source/UI/AICoreSettingsUI.cs
index 607b2bbf..95f821b0 100644
--- a/Source/UI/AICoreSettingsUI.cs
+++ b/Source/UI/AICoreSettingsUI.cs
@@ -368,7 +368,13 @@ private static void DrawQueueTab(Rect inRect)
return;
}
- int modCount = queue.GetAllQueueDepths().Count;
+ 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;
@@ -422,12 +428,6 @@ private static void DrawQueueTab(Rect inRect)
// ── 各 Mod 队列 ──────────────────────────────────────────────────
SettingsUIHelper.DrawSectionHeader(listing, "RimMind.Core.Settings.Queue.PerMod".Translate());
- var allDepths = queue.GetAllQueueDepths();
- var allCooldowns = queue.GetAllCooldowns();
- var allModIds = new HashSet(allDepths.Keys);
- allModIds.UnionWith(allCooldowns.Keys);
- allModIds.UnionWith(RimMindAPI.ModCooldownGetters.Keys);
-
if (allModIds.Count == 0)
{
GUI.color = Color.gray;
diff --git a/Source/UI/Window_AIDebugLog.cs b/Source/UI/Window_AIDebugLog.cs
index 7f3e0612..06a5d177 100644
--- a/Source/UI/Window_AIDebugLog.cs
+++ b/Source/UI/Window_AIDebugLog.cs
@@ -117,11 +117,11 @@ private void DrawLeftPane(Rect r)
Widgets.Label(new Rect(textRect.x, textRect.y + 18f, textRect.width, 18f),
entry.Source);
GUI.color = Color.gray;
- string telemetry = $"{entry.ModelName} {entry.TokensUsed} tok";
+ string telemetry = $"{entry.ModelName} {entry.TokensUsed} {"RimMind.Core.UI.DebugLog.Tok".Translate()}";
if (entry.AttemptCount > 1)
- telemetry += $" retry={entry.AttemptCount}";
+ telemetry += $" {"RimMind.Core.UI.DebugLog.Retry".Translate()}={entry.AttemptCount}";
if (entry.QueueWaitMs > 0)
- telemetry += $" wait={entry.QueueWaitMs}ms";
+ telemetry += $" {"RimMind.Core.UI.DebugLog.Wait".Translate()}={entry.QueueWaitMs}ms";
Widgets.Label(new Rect(textRect.x, textRect.y + 36f, textRect.width, 16f),
telemetry);
GUI.color = Color.white;
@@ -164,11 +164,11 @@ private void DrawRightPane(Rect r)
// 元数据行
GUI.color = Color.gray;
- string metaLine = $"{_selected.Source} | {_selected.ModelName} | {_selected.TokensUsed} tok | {_selected.ElapsedMs}ms | {_selected.Priority}";
+ string metaLine = $"{_selected.Source} | {_selected.ModelName} | {_selected.TokensUsed} {"RimMind.Core.UI.DebugLog.Tok".Translate()} | {_selected.ElapsedMs}ms | {_selected.Priority}";
if (_selected.AttemptCount > 1)
- metaLine += $" | attempt={_selected.AttemptCount}";
+ metaLine += $" | {"RimMind.Core.UI.DebugLog.Attempt".Translate()}={_selected.AttemptCount}";
if (_selected.QueueWaitMs > 0)
- metaLine += $" | queueWait={_selected.QueueWaitMs}ms";
+ 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);