From 3866a3ba2f6a89003fc27c0dd0ab7562d6898cda Mon Sep 17 00:00:00 2001
From: mcocdaa <2021137961@qq.com>
Date: Fri, 24 Apr 2026 12:37:23 +0800
Subject: [PATCH 001/312] fix(AgentBus): use ConcurrentDictionary for thread
safety, remove dead code
- _handlers changed from Dictionary to ConcurrentDictionary
- Added lock on handler list for thread-safe Subscribe/Unsubscribe/Dispatch
- DispatchToHandlers takes snapshot before iterating to avoid concurrent modification
- Removed IAgentBusHandler interface and Subscribe/Unsubscribe overloads
- Removed BackgroundQueueCount dead code property
---
AGENTS.md | 112 +++-
.../ChineseSimplified/Keyed/RimMind_Core.xml | 25 +
Languages/English/Keyed/RimMind_Core.xml | 25 +
Source/AICoreAPI.cs | 378 +++++++-----
Source/Client/AIRequest.cs | 10 +
Source/Client/AIResponse.cs | 2 +-
Source/Client/IAIClient.cs | 3 +
Source/Client/OpenAI/OpenAIClient.cs | 175 +++++-
Source/Client/OpenAI/OpenAIDto.cs | 56 +-
Source/Client/Player2/Player2Client.cs | 292 ++++++++-
Source/Client/Player2/Player2Models.cs | 84 +++
Source/Client/QuotaExceededException.cs | 20 +
Source/Client/StructuredTool.cs | 19 +
Source/Comps/CompPawnAgent.cs | 129 ++++
Source/Core/AIRequestQueue.cs | 21 +
Source/Core/Agent/AgentGoal.cs | 40 ++
Source/Core/Agent/AgentGoalStack.cs | 111 ++++
Source/Core/Agent/AgentIdentity.cs | 22 +
Source/Core/Agent/AgentState.cs | 10 +
Source/Core/Agent/AgentStateTransition.cs | 20 +
Source/Core/Agent/BehaviorRecord.cs | 39 ++
Source/Core/Agent/CooldownFilter.cs | 51 ++
Source/Core/Agent/DedupFilter.cs | 55 ++
Source/Core/Agent/DefaultAgentProvider.cs | 52 ++
Source/Core/Agent/GoalGenerator.cs | 150 +++++
Source/Core/Agent/IAgentActionBridge.cs | 12 +
Source/Core/Agent/IAgentProvider.cs | 15 +
Source/Core/Agent/IPerceptionFilter.cs | 9 +
Source/Core/Agent/PawnAgent.cs | 494 +++++++++++++++
Source/Core/Agent/PerceptionBuffer.cs | 45 ++
Source/Core/Agent/PerceptionPipeline.cs | 22 +
Source/Core/Agent/PriorityFilter.cs | 26 +
Source/Core/Agent/RiskLevel.cs | 10 +
Source/Core/Agent/StrategyOptimizer.cs | 61 ++
Source/Core/AgentBus/AgentBus.cs | 109 ++++
Source/Core/AgentBus/AgentBusEvent.cs | 19 +
Source/Core/AgentBus/AgentBusEventType.cs | 12 +
Source/Core/AgentBus/EventBusAdapter.cs | 22 +
Source/Core/AgentBus/Events/ActionEvent.cs | 25 +
.../AgentBus/Events/AgentLifecycleEvent.cs | 17 +
Source/Core/AgentBus/Events/DecisionEvent.cs | 19 +
Source/Core/AgentBus/Events/GoalEvent.cs | 19 +
Source/Core/AgentBus/Events/MemoryEvent.cs | 19 +
.../Core/AgentBus/Events/PerceptionEvent.cs | 19 +
Source/Core/AgentBus/IEventBus.cs | 13 +
Source/Core/Context/BudgetScheduleResult.cs | 16 +
Source/Core/Context/BudgetScheduler.cs | 147 +++++
Source/Core/Context/BudgetSchedulerConfig.cs | 12 +
Source/Core/Context/ContextDiff.cs | 19 +
Source/Core/Context/ContextEngine.cs | 574 ++++++++++++++++++
Source/Core/Context/ContextEntry.cs | 18 +
Source/Core/Context/ContextKeyRegistry.cs | 163 +++++
Source/Core/Context/ContextLayer.cs | 11 +
Source/Core/Context/ContextRequest.cs | 15 +
Source/Core/Context/ContextSnapshot.cs | 28 +
Source/Core/Context/EmbedCache.cs | 174 ++++++
Source/Core/Context/HistoryEntry.cs | 30 +
Source/Core/Context/HistoryGameComponent.cs | 28 +
Source/Core/Context/HistoryManager.cs | 98 +++
Source/Core/Context/KeyMeta.cs | 45 ++
Source/Core/Context/RelevanceTable.cs | 106 ++++
Source/Core/Context/ScenarioRegistry.cs | 97 +++
Source/Core/Context/SemanticEmbedding.cs | 230 +++++++
Source/Core/Extensions/IAgentModeProvider.cs | 10 +
Source/Core/Extensions/IAudioPlayer.cs | 12 +
Source/Core/Extensions/IParameterTuner.cs | 10 +
Source/Core/Extensions/ISensorProvider.cs | 11 +
.../Extensions/IStreamingResponseHandler.cs | 10 +
Source/Core/GameContextBuilder.cs | 220 ++++++-
Source/Core/Perception/PerceptionBridge.cs | 37 ++
Source/Core/Prompt/ContextComposer.cs | 60 +-
Source/Core/Prompt/PromptBudget.cs | 90 ++-
Source/Core/Prompt/PromptSection.cs | 1 +
Source/Debug/AICoreDebugActions.cs | 20 +-
Source/Npc/HybridStorageDriver.cs | 136 +++++
Source/Npc/IStorageDriver.cs | 33 +
Source/Npc/LocalStorageDriver.cs | 187 ++++++
Source/Npc/MapNpcComponent.cs | 46 ++
Source/Npc/NpcManager.cs | 70 +++
Source/Npc/NpcModels.cs | 74 +++
Source/Npc/NpcProfileBuilder.cs | 111 ++++
Source/Npc/Patch_MapNpcLifecycle.cs | 36 ++
Source/Npc/Player2StorageDriver.cs | 255 ++++++++
Source/Npc/ResponseDispatcher.cs | 93 +++
Source/Npc/StorageDriverFactory.cs | 49 ++
Source/Patch/PerceptionBridge_PatchDowned.cs | 32 +
Source/Patch/PerceptionBridge_PatchHealth.cs | 26 +
.../PerceptionBridge_PatchMentalState.cs | 23 +
Source/Patch/PerceptionBridge_PatchMood.cs | 39 ++
Source/Patch/PerceptionBridge_PatchRaid.cs | 21 +
Source/Settings/AICoreSettings.cs | 4 +-
Source/Settings/ContextSettings.cs | 25 +-
Source/UI/AICoreSettingsUI.cs | 29 +-
Source/UI/Window_AgentDialogue.cs | 119 ++++
94 files changed, 6264 insertions(+), 324 deletions(-)
create mode 100644 Source/Client/QuotaExceededException.cs
create mode 100644 Source/Client/StructuredTool.cs
create mode 100644 Source/Comps/CompPawnAgent.cs
create mode 100644 Source/Core/Agent/AgentGoal.cs
create mode 100644 Source/Core/Agent/AgentGoalStack.cs
create mode 100644 Source/Core/Agent/AgentIdentity.cs
create mode 100644 Source/Core/Agent/AgentState.cs
create mode 100644 Source/Core/Agent/AgentStateTransition.cs
create mode 100644 Source/Core/Agent/BehaviorRecord.cs
create mode 100644 Source/Core/Agent/CooldownFilter.cs
create mode 100644 Source/Core/Agent/DedupFilter.cs
create mode 100644 Source/Core/Agent/DefaultAgentProvider.cs
create mode 100644 Source/Core/Agent/GoalGenerator.cs
create mode 100644 Source/Core/Agent/IAgentActionBridge.cs
create mode 100644 Source/Core/Agent/IAgentProvider.cs
create mode 100644 Source/Core/Agent/IPerceptionFilter.cs
create mode 100644 Source/Core/Agent/PawnAgent.cs
create mode 100644 Source/Core/Agent/PerceptionBuffer.cs
create mode 100644 Source/Core/Agent/PerceptionPipeline.cs
create mode 100644 Source/Core/Agent/PriorityFilter.cs
create mode 100644 Source/Core/Agent/RiskLevel.cs
create mode 100644 Source/Core/Agent/StrategyOptimizer.cs
create mode 100644 Source/Core/AgentBus/AgentBus.cs
create mode 100644 Source/Core/AgentBus/AgentBusEvent.cs
create mode 100644 Source/Core/AgentBus/AgentBusEventType.cs
create mode 100644 Source/Core/AgentBus/EventBusAdapter.cs
create mode 100644 Source/Core/AgentBus/Events/ActionEvent.cs
create mode 100644 Source/Core/AgentBus/Events/AgentLifecycleEvent.cs
create mode 100644 Source/Core/AgentBus/Events/DecisionEvent.cs
create mode 100644 Source/Core/AgentBus/Events/GoalEvent.cs
create mode 100644 Source/Core/AgentBus/Events/MemoryEvent.cs
create mode 100644 Source/Core/AgentBus/Events/PerceptionEvent.cs
create mode 100644 Source/Core/AgentBus/IEventBus.cs
create mode 100644 Source/Core/Context/BudgetScheduleResult.cs
create mode 100644 Source/Core/Context/BudgetScheduler.cs
create mode 100644 Source/Core/Context/BudgetSchedulerConfig.cs
create mode 100644 Source/Core/Context/ContextDiff.cs
create mode 100644 Source/Core/Context/ContextEngine.cs
create mode 100644 Source/Core/Context/ContextEntry.cs
create mode 100644 Source/Core/Context/ContextKeyRegistry.cs
create mode 100644 Source/Core/Context/ContextLayer.cs
create mode 100644 Source/Core/Context/ContextRequest.cs
create mode 100644 Source/Core/Context/ContextSnapshot.cs
create mode 100644 Source/Core/Context/EmbedCache.cs
create mode 100644 Source/Core/Context/HistoryEntry.cs
create mode 100644 Source/Core/Context/HistoryGameComponent.cs
create mode 100644 Source/Core/Context/HistoryManager.cs
create mode 100644 Source/Core/Context/KeyMeta.cs
create mode 100644 Source/Core/Context/RelevanceTable.cs
create mode 100644 Source/Core/Context/ScenarioRegistry.cs
create mode 100644 Source/Core/Context/SemanticEmbedding.cs
create mode 100644 Source/Core/Extensions/IAgentModeProvider.cs
create mode 100644 Source/Core/Extensions/IAudioPlayer.cs
create mode 100644 Source/Core/Extensions/IParameterTuner.cs
create mode 100644 Source/Core/Extensions/ISensorProvider.cs
create mode 100644 Source/Core/Extensions/IStreamingResponseHandler.cs
create mode 100644 Source/Core/Perception/PerceptionBridge.cs
create mode 100644 Source/Npc/HybridStorageDriver.cs
create mode 100644 Source/Npc/IStorageDriver.cs
create mode 100644 Source/Npc/LocalStorageDriver.cs
create mode 100644 Source/Npc/MapNpcComponent.cs
create mode 100644 Source/Npc/NpcManager.cs
create mode 100644 Source/Npc/NpcModels.cs
create mode 100644 Source/Npc/NpcProfileBuilder.cs
create mode 100644 Source/Npc/Patch_MapNpcLifecycle.cs
create mode 100644 Source/Npc/Player2StorageDriver.cs
create mode 100644 Source/Npc/ResponseDispatcher.cs
create mode 100644 Source/Npc/StorageDriverFactory.cs
create mode 100644 Source/Patch/PerceptionBridge_PatchDowned.cs
create mode 100644 Source/Patch/PerceptionBridge_PatchHealth.cs
create mode 100644 Source/Patch/PerceptionBridge_PatchMentalState.cs
create mode 100644 Source/Patch/PerceptionBridge_PatchMood.cs
create mode 100644 Source/Patch/PerceptionBridge_PatchRaid.cs
create mode 100644 Source/UI/Window_AgentDialogue.cs
diff --git a/AGENTS.md b/AGENTS.md
index 06da3a89..1a982198 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,12 +9,14 @@ RimMind-Core 是 RimMind AI 模组套件的核心基础设施层。所有子模
1. **LLM 客户端**:OpenAI Chat Completions 兼容 + Player2 服务,通过 `UnityWebRequest` 发送
2. **异步请求队列**:后台线程发请求,主线程回调,`ConcurrentQueue` 桥接,支持重试/暂停/取消
3. **游戏上下文构建**:将游戏状态打包为文本,供 AI Prompt 使用
-4. **Prompt 组装系统**:`StructuredPromptBuilder` + `PromptSection` + `PromptBudget` + `ContextComposer`,支持优先级排序、压缩与 Token 预算裁剪
-5. **Provider 注册机制**:子模组通过注册 API 注入上下文段,支持卸载与覆盖,实现解耦
-6. **请求审批悬浮窗**:`RequestOverlay` + `RequestEntry`,子模组可注册待审批请求供玩家选择
-7. **SkipCheck 机制**:子模组可注册对话/浮菜单/动作/叙事者的跳过检查,实现互斥控制
-8. **设置 UI**:多分页设置界面(API/队列/提示词/上下文),子模组可注册额外分页
-9. **调试工具**:AI Debug Log 窗口、Dev DebugAction
+4. **统一上下文引擎**:`ContextEngine` + `ContextKeyRegistry` + `BudgetScheduler`,L0-L4 分层构建,Diff 注入与合并,LRU 缓存
+5. **Provider 注册机制**:子模组通过 `ContextKeyRegistry.Register` 注入上下文 Provider,支持卸载与覆盖,实现解耦
+6. **Agent 认知架构**:`PawnAgent` 统一认知主体(Perceive→Think→Act→Record),`AgentBus` 事件总线,`AgentGoalStack` 目标栈
+7. **双路径 API**:`RimMindAPI.Chat()` (NpcChat 对话) + `RimMindAPI.RequestStructuredAsync()` (结构化/工具调用)
+8. **请求审批悬浮窗**:`RequestOverlay` + `RequestEntry`,子模组可注册待审批请求供玩家选择
+9. **SkipCheck 机制**:子模组可注册对话/浮菜单/动作/叙事者的跳过检查,实现互斥控制
+10. **设置 UI**:多分页设置界面(API/队列/提示词/上下文),子模组可注册额外分页
+11. **调试工具**:AI Debug Log 窗口、Dev DebugAction
## 源码结构
@@ -35,15 +37,44 @@ Source/
│ └── Player2Models.cs Player2 请求/响应 DTO(internal)
├── Core/
│ ├── AIRequestQueue.cs GameComponent,异步请求队列 + 冷却 + 重试 + 暂停/取消
-│ ├── GameContextBuilder.cs 静态工具类,构建地图/Pawn 上下文文本(含 Section 和精简版本)
+│ ├── GameContextBuilder.cs 静态工具类,构建地图/Pawn 上下文文本
│ ├── JsonTagExtractor.cs 从 AI 响应提取 JSON 内容
-│ ├── AIDebugLog.cs GameComponent,存储最近 200 条请求记录(含 AIDebugEntry)
+│ ├── AIDebugLog.cs GameComponent,存储最近 200 条请求记录
+│ ├── Agent/
+│ │ ├── PawnAgent.cs 统一认知主体(Perceive→Think→Act→Record)
+│ │ ├── IAgentProvider.cs Agent 状态查询接口
+│ │ ├── DefaultAgentProvider.cs 默认实现,委托 CompPawnAgent
+│ │ ├── AgentGoalStack.cs 目标栈(3活跃/10总上限)
+│ │ ├── AgentIdentity.cs 身份(动机/特质/价值观)
+│ │ ├── GoalGenerator.cs 目标生成器(身份/状态/事件)
+│ │ ├── PerceptionBuffer.cs 感知缓冲区(容量20,FIFO)
+│ │ ├── PerceptionPipeline.cs 感知过滤管道
+│ │ ├── IPerceptionFilter.cs 感知过滤器接口
+│ │ ├── DedupFilter.cs 去重过滤器
+│ │ ├── PriorityFilter.cs 优先级过滤器
+│ │ └── CooldownFilter.cs 冷却过滤器
+│ ├── AgentBus/
+│ │ ├── AgentBus.cs 事件总线(6类事件,主线程同步+后台队列)
+│ │ ├── IEventBus.cs 事件总线接口
+│ │ ├── EventBusAdapter.cs 适配器,委托到 AgentBus
+│ │ └── Events/ 6种事件类
+│ ├── Context/
+│ │ ├── ContextEngine.cs 统一上下文引擎(L0-L4分层,分块缓存,Diff注入)
+│ │ ├── ContextKeyRegistry.cs 上下文Key注册表(per-key Provider)
+│ │ ├── BudgetScheduler.cs 预算调度器(Score = 0.4*P + 0.6*E)
+│ │ ├── KeyMeta.cs Key元数据(优先级/自适应/更新计数)
+│ │ ├── HistoryManager.cs 对话历史管理(独立于状态缓存)
+│ │ ├── ContextSnapshot.cs 上下文快照
+│ │ ├── ContextRequest.cs 上下文请求
+│ │ ├── ContextLayer.cs 层级枚举(L0-L4)
+│ │ ├── ContextScenario.cs 场景枚举
+│ │ ├── ContextDiff.cs Diff数据结构
+│ │ ├── RelevanceTable.cs 场景-键相关性表
+│ │ └── SemanticEmbedding.cs 语义向量(硬编码+动态扩展)
│ └── Prompt/
-│ ├── StructuredPromptBuilder.cs 流式 Prompt 构建器(链式 API + FromKeyPrefix + ToSection)
-│ ├── PromptSection.cs Prompt 段落(Tag/Content/Priority/EstimatedTokens/Compress)
-│ ├── PromptBudget.cs Token 预算管理(先压缩后裁剪)
-│ ├── ContextComposer.cs 段落排序 + 历史压缩
-│ └── PromptSanitizer.cs Prompt 清理(去除双花括号)
+│ ├── StructuredPromptBuilder.cs 流式 Prompt 构建器
+│ ├── PromptSection.cs Prompt 段落
+│ └── PromptSanitizer.cs Prompt 清理
├── Settings/
│ ├── AICoreSettings.cs 模组设置(Provider 选择 + API 配置 + 请求控制 + 调试)
│ ├── AIProvider.cs AI 提供者枚举(OpenAI / Player2)
@@ -81,20 +112,31 @@ RimMindAPI.TotalQueuedCount // 排队中请求总数
RimMindAPI.GetActiveRequests() // 获取活跃请求列表
RimMindAPI.GetAllQueuedRequests() // 获取所有排队请求
-// ── 上下文构建 ──
-RimMindAPI.BuildMapContext(Map map, bool brief = false)
-RimMindAPI.BuildPawnContext(Pawn pawn)
-RimMindAPI.BuildStaticContext()
-RimMindAPI.BuildHistoryContext(int maxEntries = 10)
-RimMindAPI.BuildFullPawnPrompt(Pawn pawn, string? currentQuery, string[]? excludeProviders)
-RimMindAPI.BuildFullPawnPrompt(Pawn pawn, PromptBudget budget, string? currentQuery, string[]? excludeProviders)
-RimMindAPI.BuildFullPawnSections(Pawn pawn, string? currentQuery, string[]? excludeProviders)
+// ── 上下文构建(新路径:ContextEngine)──
+RimMindAPI.GetContextEngine() // 获取共享 ContextEngine 实例(public)
+RimMindAPI.Chat(ContextRequest request, CancellationToken ct) // NpcChat 对话路径
+RimMindAPI.RequestStructuredAsync(AIRequest request, string jsonSchema, Action onComplete, List? tools) // 结构化路径
+
+// ── 上下文构建(旧路径,已废弃)──
+// BuildFullPawnPrompt / BuildFullPawnSections 已删除,请使用 ContextEngine.BuildSnapshot
// ── 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)
+// ── ContextKeyRegistry 注册(新路径,推荐使用)──
+ContextKeyRegistry.Register(string key, ContextLayer layer, float priority, Func provider, string ownerMod)
+ContextKeyRegistry.GetAll() // 获取所有已注册的 Key
+
+// ── AgentIdentity 注册(子模组向 Core 注册)──
+RimMindAPI.RegisterAgentIdentityProvider(Func provider)
+RimMindAPI.GetAgentIdentity(Pawn pawn) // Core 读取 AgentIdentity
+
+// ── Agent 扩展接口 ──
+RimMindAPI.GetAgentProvider() / RegisterAgentProvider(IAgentProvider)
+RimMindAPI.GetEventBus() / RegisterEventBus(IEventBus)
+
// ── Provider 查询(供外部 Mod 读取 RimMind 数据)──
RimMindAPI.GetProviderData(string category, Pawn pawn) // Pawn 级 Provider 数据
RimMindAPI.GetStaticProviderData(string category) // 静态 Provider 数据
@@ -357,18 +399,21 @@ static class GameContextBuilder {
}
```
-### BuildFullPawnPrompt 组装顺序
+### ContextEngine 上下文构建流程
```
-1. 静态段 → RegisterStaticProvider 注册的段(Rules、Skills 等)
-2. Pawn段 → RegisterPawnContextProvider 注册的段(人格、记忆等)
-3. 游戏状态 → BuildPawnContextSection(pawn)(带压缩回调)
-4. 地图状态 → BuildMapContextSection(pawn.Map)(带压缩回调)
-5. 动态段 → RegisterDynamicProvider 注册的段(Memory 语义检索等)
-6. 自定义提示词 → customPawnPrompt + customMapPrompt
+1. L0_Static → 缓存层,system_instruction + npc_identity + npc_commands + world_rules
+2. L1_Baseline → 版本化缓存 + Diff 注入,map_structure + pawn_base_info + fixed_relations + ideology + skills_summary + memory_narrator
+3. Diff → [环境突变] 消息,L1 key 值变化时记录,4轮后合并回 L1 block
+4. L2_Environment → current_area + weather + time_of_day + nearby_pawns + season + colony_status
+5. L3_State → health + mood + current_job + combat_status + target_info + task_progress + memory_pawn + working_memory
+6. L4_History → HistoryManager 对话历史(按 Budget 调整轮数)
+7. CurrentQuery → 当前用户输入
```
-所有段落以 `PromptSection` 形式传递,按 `Priority` 排序后拼接。带 `PromptBudget` 的版本会先压缩再裁剪。
+BudgetScheduler: Score = 0.4 * GetEffectivePriority() + 0.6 * ComputeRelevance(scenario, key)
+- 有 IRelevanceProvider 时:ComputeRelevance = 0.6 * RelevanceTable + 0.4 * SemanticEmbedding
+- 无 IRelevanceProvider 时:ComputeRelevance = RelevanceTable
### JsonTagExtractor
@@ -617,8 +662,8 @@ enum ContextPreset { Minimal, Standard, Full, Custom }
| 子模组 | 注入方式 | 注册的 Provider |
|--------|---------|----------------|
-| RimMind-Personality | `RegisterPawnContextProvider` | personality_profile + personality_state + personality_shaping |
-| RimMind-Memory | `RegisterPawnContextProvider` + `RegisterStaticProvider` | memory_pawn + memory_narrator |
+| RimMind-Personality | `ContextKeyRegistry.Register` + `RegisterPawnContextProvider` | personality_profile + personality_state + personality_shaping + **AgentIdentity** |
+| RimMind-Memory | `ContextKeyRegistry.Register` + `RegisterPawnContextProvider` + `RegisterStaticProvider` | memory_pawn (L3) + working_memory (L3) + memory_narrator (L1) |
| RimMind-Dialogue | `RegisterPawnContextProvider` | dialogue_state + dialogue_relation |
| RimMind-Advisor | `RegisterPawnContextProvider` | advisor_history |
| RimMind-Actions | 以记忆方式注入(通过 Memory) | - |
@@ -632,9 +677,10 @@ enum ContextPreset { Minimal, Standard, Full, Custom }
└── 想法和记忆反哺人格
```
-- 人格通过 `RegisterPawnContextProvider` 注入上下文
+- 人格通过 `ContextKeyRegistry.Register` + `RegisterPawnContextProvider` 注入上下文
+- AgentIdentity 通过 `RegisterAgentIdentityProvider` 向 Core 注册,GoalGenerator 通过 `GetAgentIdentity` 读取
- 想法自动打包进入上下文(Thought 系统)
-- 记忆通过 `RegisterPawnContextProvider` + `RegisterStaticProvider` 注入上下文
+- 记忆通过 `ContextKeyRegistry.Register` (memory_pawn/working_memory/memory_narrator) 自动注入上下文
- 行动以记忆方式注入上下文
- 对话状态通过 `RegisterPawnContextProvider` 注入上下文
- Advisor 历史通过 `RegisterPawnContextProvider` 注入上下文
diff --git a/Languages/ChineseSimplified/Keyed/RimMind_Core.xml b/Languages/ChineseSimplified/Keyed/RimMind_Core.xml
index fa8d96c4..95cc8f49 100644
--- a/Languages/ChineseSimplified/Keyed/RimMind_Core.xml
+++ b/Languages/ChineseSimplified/Keyed/RimMind_Core.xml
@@ -54,6 +54,17 @@
Player2 优先使用本地桌面应用自动认证。仅在未安装本地应用时需要手动输入 API Key。
检测本地 Player2 应用
Player2 不可用:未检测到本地应用且未配置 API Key
+ 远程 API 地址
+ Player2 远程服务地址。默认为 https://api.player2.game,一般无需修改。
+ 余额:未知
+ 刷新余额
+
+
+ 激活 Agent
+ 停用 Agent
+ 切换 PawnAgent 的激活状态
+ Dev: 查看 Agent
+ 在日志中输出 Agent 内部状态
RimMind: 已检测到 Player2 桌面应用!使用自动认证(无需 API Key)。
RimMind: 未检测到 Player2 桌面应用。请启动应用或手动输入 API Key。
@@ -175,6 +186,8 @@
将当前天气状况注入 AI 上下文。
威胁等级
将当前地图的威胁等级注入 AI 上下文。
+ 上下文预算
+ 控制 AI 请求中包含的上下文信息量。更高的值提供更多细节,但会消耗更多 token。
[当前地图状态]
@@ -236,6 +249,8 @@
任务:{0}
位置:{0} 温度:{1}°C
武器:{0}
+ [环境突变]
+ [状态变更] {0}: {1} → {2}
日{0} {1}:{2}
@@ -287,4 +302,14 @@
审批与请求
全部清除
{0} 条待审批
+
+
+ Agent 对话
+ 发送
+ 玩家
+ Agent
+
+
+ 对话
+ 与 AI 接管的小人对话
diff --git a/Languages/English/Keyed/RimMind_Core.xml b/Languages/English/Keyed/RimMind_Core.xml
index 1b8c680d..9e0a225e 100644
--- a/Languages/English/Keyed/RimMind_Core.xml
+++ b/Languages/English/Keyed/RimMind_Core.xml
@@ -54,6 +54,17 @@
Player2 prioritizes local desktop app auto-authentication. Only enter an API Key manually if the local app is not installed.
Detect Local Player2 App
Player2 not available: local app not detected and no API Key configured
+ Remote API URL
+ Player2 remote service URL. Defaults to https://api.player2.game, usually no need to change.
+ Balance: unknown
+ Refresh Balance
+
+
+ Activate Agent
+ Deactivate Agent
+ Toggle PawnAgent active state
+ Dev: View Agent
+ Dump Agent internal state to log
RimMind: Player2 desktop app detected! Using automatic authentication (no API key needed).
RimMind: Player2 desktop app not found. Please start the app or add an API key manually.
@@ -175,6 +186,8 @@
Inject the current weather conditions into AI context.
Threat Level
Inject the current map threat level into AI context.
+ Context Budget
+ Controls how much context information is included in AI requests. Higher values provide more detail but use more tokens.
[Current Map Status]
@@ -236,6 +249,8 @@
Job: {0}
Location: {0} Temp: {1}°C
Weapon: {0}
+ [Environment Change]
+ {0}: {1} → {2}
D{0} {1}:{2}
@@ -287,4 +302,14 @@
Approval & Requests
Clear All
{0} pending
+
+
+ Agent Dialogue
+ Send
+ Player
+ Agent
+
+
+ Chat
+ Chat with the AI-controlled pawn
diff --git a/Source/AICoreAPI.cs b/Source/AICoreAPI.cs
index 4688a309..df1b2019 100644
--- a/Source/AICoreAPI.cs
+++ b/Source/AICoreAPI.cs
@@ -2,14 +2,21 @@
using System.Collections.Generic;
using System.Linq;
using System.Text;
+using System.Threading;
using System.Threading.Tasks;
+using RimMind.Core.Agent;
+using RimMind.Core.AgentBus;
using RimMind.Core.Client;
using RimMind.Core.Client.OpenAI;
using RimMind.Core.Client.Player2;
+using RimMind.Core.Context;
+using RimMind.Core.Extensions;
using RimMind.Core.Internal;
+using RimMind.Core.Npc;
using RimMind.Core.Prompt;
using RimMind.Core.Settings;
using RimMind.Core.UI;
+using RimWorld;
using Verse;
namespace RimMind.Core
@@ -47,24 +54,15 @@ private static readonly Dictionary> _floatMenuSkipChecks
private static readonly Dictionary> _actionSkipChecks
= new Dictionary>();
- // ── 核心请求 API ──────────────────────────────────────────────────────
+ private static IAgentProvider? _agentProvider;
+ private static IEventBus? _eventBus;
- public static void RequestAsync(AIRequest request, Action onComplete)
- {
- var queue = AIRequestQueue.Instance;
- if (queue == null)
- {
- Log.Error("[RimMind] AIRequestQueue not initialized.");
- return;
- }
- var client = GetClient();
- if (client == null)
- {
- onComplete?.Invoke(AIResponse.Failure(request.RequestId, "AI client not configured."));
- return;
- }
- queue.Enqueue(request, onComplete, client);
- }
+ private static readonly HistoryManager _historyManager = HistoryManager.Instance;
+ private static readonly ContextEngine _contextEngine = new ContextEngine(_historyManager);
+ internal static HistoryManager GetHistoryManager() => _historyManager;
+ public static ContextEngine GetContextEngine() => _contextEngine;
+
+ // ── 核心请求 API ──────────────────────────────────────────────────────
public static void RequestImmediate(AIRequest request, Action onComplete)
{
@@ -102,163 +100,128 @@ public static bool CancelRequest(string requestId)
public static int TotalQueuedCount => AIRequestQueue.Instance?.TotalQueuedCount ?? 0;
- // ── 上下文构建 ────────────────────────────────────────────────────────
-
- public static string BuildMapContext(Map map, bool brief = false)
- => GameContextBuilder.BuildMapContext(map, brief);
-
- public static string BuildPawnContext(Pawn pawn)
- => GameContextBuilder.BuildPawnContext(pawn);
-
- public static string BuildHistoryContext(int maxEntries = 10)
- => GameContextBuilder.BuildHistoryContext(maxEntries);
+ // ── NPC Chat API ─────────────────────────────────────────────────────
- public static string BuildStaticContext()
+ public static async Task Chat(ContextRequest request, CancellationToken ct = default)
{
- var sb = new StringBuilder();
- foreach (var kvp in _staticProviders)
- {
- try
- {
- string? seg = kvp.Value.provider();
- if (!string.IsNullOrEmpty(seg)) sb.AppendLine(seg);
- }
- catch (Exception ex) { Log.Warning($"[RimMind] StaticProvider '{kvp.Key}' error: {ex.Message}"); }
- }
- return sb.ToString().TrimEnd();
+ var snapshot = _contextEngine.BuildSnapshot(request);
+ var driver = StorageDriverFactory.GetDriver();
+ return await driver.ChatAsync(snapshot, ct);
}
- public static string BuildFullPawnPrompt(
- Pawn pawn,
- string? currentQuery = null,
- string[]? excludeProviders = null)
- {
- var sb = new StringBuilder();
+ // ── Structured Request API ───────────────────────────────────────────
- foreach (var kvp in _staticProviders)
+ public static void RequestStructuredAsync(AIRequest request, string? jsonSchema, Action onComplete, List? tools = null)
+ {
+ var s = RimMindCoreMod.Settings;
+ if (s == null || !s.IsConfigured())
{
- try
- {
- string? seg = kvp.Value.provider();
- if (!string.IsNullOrEmpty(seg)) sb.AppendLine(seg);
- }
- catch (Exception ex) { Log.Warning($"[RimMind] StaticProvider '{kvp.Key}' error: {ex.Message}"); }
+ onComplete?.Invoke(AIResponse.Failure(request.RequestId, "AI client not configured."));
+ return;
}
- var ctx = RimMindCoreMod.Settings?.Context;
- foreach (var kvp in _pawnProviders)
+ if (s.provider == AIProvider.Player2)
{
- if (ctx?.disabledProviders?.Contains(kvp.Key) == true) continue;
- if (excludeProviders != null && Array.IndexOf(excludeProviders, kvp.Key) >= 0) continue;
- try
+ var p2Client = EnsurePlayer2Client(s);
+ if (p2Client != null && p2Client.IsConfigured())
{
- string? seg = kvp.Value.provider(pawn);
- if (!string.IsNullOrEmpty(seg)) sb.AppendLine(seg);
+ Task.Run(async () =>
+ {
+ var response = await p2Client.SendStructuredAsync(request, jsonSchema, tools);
+ onComplete?.Invoke(response);
+ });
+ return;
}
- catch (Exception ex) { Log.Warning($"[RimMind] PawnProvider '{kvp.Key}' error: {ex.Message}"); }
}
- sb.AppendLine(GameContextBuilder.BuildPawnContext(pawn));
-
- if (pawn.Map != null)
- sb.AppendLine(GameContextBuilder.BuildMapContext(pawn.Map, brief: false));
-
- foreach (var kvp in _dynamicProviders)
+ var openAIClient = new OpenAIClient(s);
+ if (openAIClient.IsConfigured())
{
- try
+ Task.Run(async () =>
{
- string? seg = kvp.Value.provider(currentQuery ?? string.Empty);
- if (!string.IsNullOrEmpty(seg)) sb.AppendLine(seg);
- }
- catch (Exception ex) { Log.Warning($"[RimMind] DynamicProvider '{kvp.Key}' error: {ex.Message}"); }
+ var response = await openAIClient.SendStructuredAsync(request, jsonSchema, tools);
+ onComplete?.Invoke(response);
+ });
+ return;
}
- var settings = RimMindCoreMod.Settings;
- if (settings == null) return sb.ToString().TrimEnd();
- string? customPawn = settings.customPawnPrompt?.Trim();
- if (!string.IsNullOrEmpty(customPawn))
- sb.AppendLine("\n" + "RimMind.Core.Prompt.CustomPawnHeader".Translate() + "\n" + customPawn);
- string? customMap = settings.customMapPrompt?.Trim();
- if (!string.IsNullOrEmpty(customMap))
- sb.AppendLine("\n" + "RimMind.Core.Prompt.CustomMapHeader".Translate() + "\n" + customMap);
-
- return sb.ToString().TrimEnd();
+ onComplete?.Invoke(AIResponse.Failure(request.RequestId, "AI client not configured."));
}
- public static string BuildFullPawnPrompt(
- Pawn pawn,
- PromptBudget budget,
- string? currentQuery = null,
- string[]? excludeProviders = null)
+ public static ContextSnapshot BuildContextSnapshot(ContextRequest request)
{
- var sections = BuildFullPawnSections(pawn, currentQuery, excludeProviders);
- return budget.ComposeToString(sections);
+ return _contextEngine.BuildSnapshot(request);
}
- public static List BuildFullPawnSections(
- Pawn pawn,
- string? currentQuery = null,
- string[]? excludeProviders = null)
+ public static void RequestStructured(ContextRequest request, string schema,
+ Action onComplete, List? tools = null)
{
- var sections = new List();
- var ctx = RimMindCoreMod.Settings?.Context;
-
- foreach (var kvp in _staticProviders)
+ var snapshot = _contextEngine.BuildSnapshot(request);
+ // 从 snapshot 构建 AIRequest
+ var aiRequest = new AIRequest
{
- try
- {
- string? seg = kvp.Value.provider();
- if (!string.IsNullOrEmpty(seg))
- sections.Add(new PromptSection(kvp.Key, seg!, kvp.Value.priority));
- }
- catch (Exception ex) { Log.Warning($"[RimMind] StaticProvider '{kvp.Key}' error: {ex.Message}"); }
+ SystemPrompt = null,
+ Messages = snapshot.Messages,
+ MaxTokens = snapshot.MaxTokens,
+ Temperature = snapshot.Temperature,
+ RequestId = $"Structured_{request.NpcId}",
+ ModId = request.Scenario.ToString(),
+ ExpireAtTicks = Find.TickManager.TicksGame + 600,
+ UseJsonMode = true,
+ Priority = AIRequestPriority.Normal,
+ };
+ try
+ {
+ RequestStructuredAsync(aiRequest, schema, onComplete, tools);
}
-
- foreach (var kvp in _pawnProviders)
+ catch
{
- if (ctx?.disabledProviders?.Contains(kvp.Key) == true) continue;
- if (excludeProviders != null && Array.IndexOf(excludeProviders, kvp.Key) >= 0) continue;
- try
+ // 降级: 结构化请求失败,走基础请求入队
+ var fallbackRequest = new AIRequest
{
- string? seg = kvp.Value.provider(pawn);
- if (!string.IsNullOrEmpty(seg))
- sections.Add(new PromptSection(kvp.Key, seg!, kvp.Value.priority));
- }
- catch (Exception ex) { Log.Warning($"[RimMind] PawnProvider '{kvp.Key}' error: {ex.Message}"); }
+ SystemPrompt = null,
+ Messages = snapshot.Messages,
+ MaxTokens = snapshot.MaxTokens,
+ Temperature = snapshot.Temperature,
+ RequestId = aiRequest.RequestId,
+ ModId = aiRequest.ModId,
+ ExpireAtTicks = aiRequest.ExpireAtTicks,
+ UseJsonMode = true,
+ Priority = aiRequest.Priority,
+ };
+ var queue = AIRequestQueue.Instance;
+ var client = GetClient();
+ if (queue != null && client != null)
+ queue.Enqueue(fallbackRequest, onComplete, client);
+ else
+ onComplete?.Invoke(AIResponse.Failure(fallbackRequest.RequestId, "No AI client available"));
}
+ }
+
+ // ── 上下文构建 ────────────────────────────────────────────────────────
+
+ public static string BuildMapContext(Map map, bool brief = false)
+ => GameContextBuilder.BuildMapContext(map, brief);
- sections.Add(GameContextBuilder.BuildPawnContextSection(pawn));
+ public static string BuildPawnContext(Pawn pawn)
+ => GameContextBuilder.BuildPawnContext(pawn);
- if (pawn.Map != null)
- sections.Add(GameContextBuilder.BuildMapContextSection(pawn.Map, brief: false));
+ public static string BuildHistoryContext(int maxEntries = 10)
+ => GameContextBuilder.BuildHistoryContext(maxEntries);
- foreach (var kvp in _dynamicProviders)
+ public static string BuildStaticContext()
+ {
+ var sb = new StringBuilder();
+ foreach (var kvp in _staticProviders)
{
try
{
- string? seg = kvp.Value.provider(currentQuery ?? string.Empty);
- if (!string.IsNullOrEmpty(seg))
- sections.Add(new PromptSection(kvp.Key, seg!, kvp.Value.priority));
+ string? seg = kvp.Value.provider();
+ if (!string.IsNullOrEmpty(seg)) sb.AppendLine(seg);
}
- catch (Exception ex) { Log.Warning($"[RimMind] DynamicProvider '{kvp.Key}' error: {ex.Message}"); }
- }
-
- var settings = RimMindCoreMod.Settings;
- if (settings != null)
- {
- string? customPawn = settings.customPawnPrompt?.Trim();
- if (!string.IsNullOrEmpty(customPawn))
- sections.Add(new PromptSection("custom_pawn",
- "RimMind.Core.Prompt.CustomPawnHeader".Translate() + "\n" + customPawn,
- PromptSection.PriorityCustom));
- string? customMap = settings.customMapPrompt?.Trim();
- if (!string.IsNullOrEmpty(customMap))
- sections.Add(new PromptSection("custom_map",
- "RimMind.Core.Prompt.CustomMapHeader".Translate() + "\n" + customMap,
- PromptSection.PriorityCustom));
+ catch (Exception ex) { Log.Warning($"[RimMind] StaticProvider '{kvp.Key}' error: {ex.Message}"); }
}
-
- return sections;
+ return sb.ToString().TrimEnd();
}
// ── 状态查询 ──────────────────────────────────────────────────────────
@@ -279,6 +242,14 @@ public static void RegisterStaticProvider(string category, Func provide
{
_staticProviders[category] = (modId, provider, priority);
}
+ float priorityFloat = 1.0f - (priority / 10.0f);
+ ContextLayer layer = InferLayer(priority);
+ var wrappedProvider = new Func>(_ =>
+ {
+ string? val = provider();
+ return string.IsNullOrEmpty(val) ? new List() : new List { new ContextEntry(val) };
+ });
+ ContextKeyRegistry.Register(category, layer, priorityFloat, wrappedProvider, modId);
}
public static void RegisterDynamicProvider(string category, Func provider,
@@ -293,6 +264,14 @@ public static void RegisterDynamicProvider(string category, Func
{
_dynamicProviders[category] = (modId, provider, priority);
}
+ float priorityFloat = 1.0f - (priority / 10.0f);
+ ContextLayer layer = InferLayer(priority);
+ var wrappedProvider = new Func>(pawn =>
+ {
+ string val = provider(pawn.Name?.ToStringShort ?? pawn.LabelShort);
+ return string.IsNullOrEmpty(val) ? new List() : new List { new ContextEntry(val) };
+ });
+ ContextKeyRegistry.Register(category, layer, priorityFloat, wrappedProvider, modId);
}
public static void RegisterPawnContextProvider(string category, Func provider,
@@ -307,6 +286,14 @@ public static void RegisterPawnContextProvider(string category, Func>(pawn =>
+ {
+ string? val = provider(pawn);
+ return string.IsNullOrEmpty(val) ? new List() : new List { new ContextEntry(val) };
+ });
+ ContextKeyRegistry.Register(category, layer, priorityFloat, wrappedProvider, modId);
}
// ── Provider 查询(供外部 Mod 读取 RimMind 数据) ──────────────────────
@@ -314,8 +301,6 @@ 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; }
}
@@ -323,8 +308,6 @@ public static void RegisterPawnContextProvider(string category, Func 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; }
}
@@ -332,8 +315,6 @@ public static void RegisterPawnContextProvider(string category, Func 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; }
}
@@ -345,35 +326,40 @@ public static List GetRegisteredCategories()
all.UnionWith(_pawnProviders.Keys);
all.UnionWith(_dynamicProviders.Keys);
- var ctx = RimMindCoreMod.Settings?.Context;
- if (ctx?.exposedProviders.Count > 0)
- all.IntersectWith(ctx.exposedProviders);
-
return all.ToList();
}
// ── Provider 卸载 ──────────────────────────────────────────────────────
public static void UnregisterPawnContextProvider(string category)
- => _pawnProviders.Remove(category);
+ {
+ _pawnProviders.Remove(category);
+ ContextKeyRegistry.Unregister(category);
+ }
public static void UnregisterStaticProvider(string category)
- => _staticProviders.Remove(category);
+ {
+ _staticProviders.Remove(category);
+ ContextKeyRegistry.Unregister(category);
+ }
public static void UnregisterDynamicProvider(string category)
- => _dynamicProviders.Remove(category);
+ {
+ _dynamicProviders.Remove(category);
+ ContextKeyRegistry.Unregister(category);
+ }
public static void UnregisterModProviders(string modId)
{
if (string.IsNullOrEmpty(modId)) return;
var staticKeys = _staticProviders.Where(kvp => kvp.Value.modId == modId).Select(kvp => kvp.Key).ToList();
- foreach (var key in staticKeys) _staticProviders.Remove(key);
+ foreach (var key in staticKeys) { _staticProviders.Remove(key); ContextKeyRegistry.Unregister(key); }
var dynamicKeys = _dynamicProviders.Where(kvp => kvp.Value.modId == modId).Select(kvp => kvp.Key).ToList();
- foreach (var key in dynamicKeys) _dynamicProviders.Remove(key);
+ foreach (var key in dynamicKeys) { _dynamicProviders.Remove(key); ContextKeyRegistry.Unregister(key); }
var pawnKeys = _pawnProviders.Where(kvp => kvp.Value.modId == modId).Select(kvp => kvp.Key).ToList();
- foreach (var key in pawnKeys) _pawnProviders.Remove(key);
+ foreach (var key in pawnKeys) { _pawnProviders.Remove(key); ContextKeyRegistry.Unregister(key); }
}
// ── Settings / Toggle / Cooldown ──────────────────────────────────────
@@ -544,6 +530,48 @@ public static bool ShouldSkipStorytellerIncident()
return false;
}
+ // ── AgentProvider / EventBus API ──────────────────────────────────────
+
+ public static IAgentProvider GetAgentProvider()
+ => _agentProvider ?? new DefaultAgentProvider();
+
+ public static void RegisterAgentProvider(IAgentProvider provider)
+ => _agentProvider = provider;
+
+ public static IEventBus GetEventBus()
+ => _eventBus ?? new EventBusAdapter();
+
+ public static void RegisterEventBus(IEventBus eventBus)
+ => _eventBus = eventBus;
+
+ // ── AgentIdentity Provider API ──────────────────────────────────────
+
+ private static Func? _agentIdentityProvider;
+
+ public static void RegisterAgentIdentityProvider(Func provider)
+ => _agentIdentityProvider = provider;
+
+ public static AgentIdentity? GetAgentIdentity(Pawn pawn)
+ => _agentIdentityProvider?.Invoke(pawn);
+
+ // ── AgentAction Bridge API ─────────────────────────────────────────
+
+ private static IAgentActionBridge? _agentActionBridge;
+
+ public static void RegisterAgentActionBridge(IAgentActionBridge bridge)
+ => _agentActionBridge = bridge;
+
+ public static IAgentActionBridge? GetAgentActionBridge()
+ => _agentActionBridge;
+
+ // ── Perception API ──────────────────────────────────────────────────
+
+ public static void PublishPerception(int pawnId, string type, string content, float importance = 0.5f)
+ => Perception.PerceptionBridge.PublishPerception(pawnId, type, content, importance);
+
+ public static void PublishBroadcastPerception(string type, string content, float importance = 0.5f, Verse.Map? map = null)
+ => Perception.PerceptionBridge.PublishBroadcast(type, content, importance, map);
+
// ── RequestOverlay API ────────────────────────────────────────────────
public static void RegisterPendingRequest(RequestEntry entry)
@@ -557,7 +585,15 @@ public static void RemovePendingRequest(RequestEntry entry)
// ── 内部 ──────────────────────────────────────────────────────────────
- private static IAIClient? GetClient()
+ private static ContextLayer InferLayer(int priority)
+ {
+ if (priority <= 1) return ContextLayer.L0_Static;
+ if (priority <= 3) return ContextLayer.L1_Baseline;
+ if (priority <= 5) return ContextLayer.L2_Environment;
+ return ContextLayer.L3_State;
+ }
+
+ internal static IAIClient? GetClient()
{
var s = RimMindCoreMod.Settings;
if (!s.IsConfigured()) return null;
@@ -622,5 +658,37 @@ public static void InvalidateClientCache()
_cachedProvider = default;
}
}
+
+ public static Player2Client? GetPlayer2Client()
+ {
+ lock (_player2Lock)
+ {
+ return (_cachedPlayer2Client != null && _cachedPlayer2Client.IsConfigured())
+ ? _cachedPlayer2Client : null;
+ }
+ }
+
+ private static readonly List _parameterTuners = new List();
+ private static readonly List _sensorProviders = new List();
+ private static readonly List _agentModeProviders = new List();
+ private static readonly List _streamingHandlers = new List();
+
+ public static void RegisterParameterTuner(IParameterTuner tuner) { _parameterTuners.Add(tuner); }
+ public static void UnregisterParameterTuner(string tunerId) { _parameterTuners.RemoveAll(t => t.TunerId == tunerId); }
+ public static IReadOnlyList ParameterTuners => _parameterTuners;
+
+ public static void RegisterSensorProvider(ISensorProvider provider) { _sensorProviders.Add(provider); }
+ public static void UnregisterSensorProvider(string sensorId) { _sensorProviders.RemoveAll(s => s.SensorId == sensorId); }
+ public static IReadOnlyList SensorProviders => _sensorProviders;
+
+ public static void RegisterAgentModeProvider(IAgentModeProvider provider) { _agentModeProviders.Add(provider); }
+ public static void UnregisterAgentModeProvider(string providerId) { _agentModeProviders.RemoveAll(p => p.ProviderId == providerId); }
+ public static IReadOnlyList AgentModeProviders => _agentModeProviders;
+
+ public static bool IsPawnAgentControlled(Pawn pawn) => _agentModeProviders.Any(p => p.IsAgentControlled(pawn));
+
+ public static void RegisterStreamingHandler(IStreamingResponseHandler handler) { _streamingHandlers.Add(handler); }
+ public static void UnregisterStreamingHandler(string handlerId) { _streamingHandlers.RemoveAll(h => h.HandlerId == handlerId); }
+ public static IReadOnlyList StreamingHandlers => _streamingHandlers;
}
}
diff --git a/Source/Client/AIRequest.cs b/Source/Client/AIRequest.cs
index f1a2b35b..8e3808e9 100644
--- a/Source/Client/AIRequest.cs
+++ b/Source/Client/AIRequest.cs
@@ -30,5 +30,15 @@ public class ChatMessage
{
public string Role { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
+ public string? ToolCallId { get; set; }
+ public List? ToolCalls { get; set; }
+ public string? LayerTag { get; set; }
+ }
+
+ public class ChatToolCall
+ {
+ public string Id { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string Arguments { get; set; } = string.Empty;
}
}
diff --git a/Source/Client/AIResponse.cs b/Source/Client/AIResponse.cs
index 8783c73c..10030703 100644
--- a/Source/Client/AIResponse.cs
+++ b/Source/Client/AIResponse.cs
@@ -10,13 +10,13 @@ public class AIResponse
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 string? ToolCallsJson;
public static AIResponse Failure(string requestId, string error) => new AIResponse
{
diff --git a/Source/Client/IAIClient.cs b/Source/Client/IAIClient.cs
index dffed084..9d656cfd 100644
--- a/Source/Client/IAIClient.cs
+++ b/Source/Client/IAIClient.cs
@@ -1,3 +1,4 @@
+using System.Collections.Generic;
using System.Threading.Tasks;
namespace RimMind.Core.Client
@@ -6,6 +7,8 @@ public interface IAIClient
{
Task SendAsync(AIRequest request);
+ Task SendStructuredAsync(AIRequest request, string? jsonSchema, List? tools);
+
bool IsConfigured();
bool IsLocalEndpoint { get; }
diff --git a/Source/Client/OpenAI/OpenAIClient.cs b/Source/Client/OpenAI/OpenAIClient.cs
index 800c1c74..8dd7d36b 100644
--- a/Source/Client/OpenAI/OpenAIClient.cs
+++ b/Source/Client/OpenAI/OpenAIClient.cs
@@ -101,23 +101,86 @@ public async Task SendAsync(AIRequest request)
}
}
- private string BuildRequestJson(AIRequest request)
+ public async Task SendStructuredAsync(AIRequest request, string? jsonSchema, List? tools)
{
- List messages;
+ string endpoint = FormatEndpoint(_settings.apiEndpoint);
+ string json = BuildStructuredRequestJson(request, jsonSchema, tools);
- if (request.Messages != null && request.Messages.Count > 0)
+ if (_settings.debugLogging)
+ AIRequestQueue.LogFromBackground($"[RimMind] → Structured {request.RequestId}\n{json}");
+
+ var sw = Stopwatch.StartNew();
+ try
+ {
+ (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;
+ var toolCallsDto = parsed?.choices?[0]?.message?.tool_calls;
+ sw.Stop();
+
+ if (_settings.debugLogging)
+ AIRequestQueue.LogFromBackground($"[RimMind] ← Structured {request.RequestId} ({tokens} tok)\n{content}");
+
+ var response = new AIResponse
+ {
+ Success = true,
+ Content = content,
+ RequestId = request.RequestId,
+ TokensUsed = tokens,
+ State = AIRequestState.Completed,
+ };
+ response.ProcessingMs = sw.ElapsedMilliseconds;
+ response.HttpStatusCode = httpStatusCode;
+ response.RequestPayloadBytes = Encoding.UTF8.GetByteCount(json);
+ response.Priority = request.Priority;
+
+ if (toolCallsDto != null && toolCallsDto.Count > 0)
+ {
+ var converted = toolCallsDto.Select(tc => new
+ {
+ id = tc.Id,
+ type = tc.Type,
+ function = new
+ {
+ name = tc.Function?.Name,
+ arguments = tc.Function?.Arguments,
+ }
+ }).ToList();
+ response.ToolCallsJson = JsonConvert.SerializeObject(converted);
+ }
+
+ AIDebugLog.Record(request, response, (int)sw.ElapsedMilliseconds);
+ return response;
+ }
+ catch (AIHttpException ex)
{
- messages = request.Messages
- .Select(m => new MessageDto { role = m.Role, content = m.Content })
- .ToList();
+ sw.Stop();
+ AIRequestQueue.LogFromBackground($"[RimMind] Structured 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;
}
- else
+ catch (Exception ex)
{
- messages = new List();
- if (!string.IsNullOrEmpty(request.SystemPrompt))
- messages.Add(new MessageDto { role = "system", content = request.SystemPrompt });
- messages.Add(new MessageDto { role = "user", content = request.UserPrompt });
+ sw.Stop();
+ AIRequestQueue.LogFromBackground($"[RimMind] Structured 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 = BuildMessages(request);
var body = new OpenAIRequestDto
{
@@ -135,6 +198,96 @@ private string BuildRequestJson(AIRequest request)
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
}
+ private string BuildStructuredRequestJson(AIRequest request, string? jsonSchema, List? tools)
+ {
+ List messages = BuildMessages(request);
+
+ var body = new OpenAIRequestDto
+ {
+ model = _settings.modelName,
+ messages = messages,
+ max_tokens = request.MaxTokens > 0 ? request.MaxTokens : _settings.maxTokens,
+ temperature = request.Temperature,
+ stream = false,
+ };
+
+ if (!string.IsNullOrEmpty(jsonSchema))
+ {
+ body.response_format = new ResponseFormatDto
+ {
+ type = "json_schema",
+ json_schema = new { name = "response", schema = JsonConvert.DeserializeObject(jsonSchema) },
+ };
+ }
+ else if (_settings.forceJsonMode && request.UseJsonMode)
+ {
+ body.response_format = new ResponseFormatDto { type = "json_object" };
+ }
+
+ if (tools != null && tools.Count > 0)
+ {
+ body.tools = new List();
+ foreach (var t in tools)
+ {
+ body.tools.Add(new ToolDto
+ {
+ Function = new ToolFunctionDto
+ {
+ Name = t.Name,
+ Description = t.Description,
+ Parameters = t.Parameters != null
+ ? JsonConvert.DeserializeObject(t.Parameters)
+ : new { type = "object", properties = new { } },
+ },
+ });
+ }
+ if (tools.Any(t => t.ToolChoice == "required"))
+ body.tool_choice = "required";
+ else
+ body.tool_choice = "auto";
+ }
+
+ return JsonConvert.SerializeObject(body, Formatting.None,
+ new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
+ }
+
+ private List BuildMessages(AIRequest request)
+ {
+ var messages = new List();
+
+ if (request.Messages != null && request.Messages.Count > 0)
+ {
+ foreach (var m in request.Messages)
+ {
+ var dto = new MessageDto { role = m.Role, content = m.Content };
+ if (!string.IsNullOrEmpty(m.ToolCallId))
+ dto.tool_call_id = m.ToolCallId;
+ if (m.ToolCalls != null && m.ToolCalls.Count > 0)
+ {
+ dto.tool_calls = m.ToolCalls.Select(tc => new ToolCallDto
+ {
+ Id = tc.Id,
+ Type = "function",
+ Function = new ToolCallFunctionDto
+ {
+ Name = tc.Name,
+ Arguments = tc.Arguments,
+ },
+ }).ToList();
+ }
+ messages.Add(dto);
+ }
+ }
+ else
+ {
+ if (!string.IsNullOrEmpty(request.SystemPrompt))
+ messages.Add(new MessageDto { role = "system", content = request.SystemPrompt });
+ messages.Add(new MessageDto { role = "user", content = request.UserPrompt });
+ }
+
+ return messages;
+ }
+
private async Task<(string text, long statusCode)> PostAsync(string url, string jsonBody)
{
bool isLocal = IsLoopbackEndpoint(url);
diff --git a/Source/Client/OpenAI/OpenAIDto.cs b/Source/Client/OpenAI/OpenAIDto.cs
index 0bc04654..dcddba72 100644
--- a/Source/Client/OpenAI/OpenAIDto.cs
+++ b/Source/Client/OpenAI/OpenAIDto.cs
@@ -1,9 +1,8 @@
using System.Collections.Generic;
+using Newtonsoft.Json;
namespace RimMind.Core.Client.OpenAI
{
- // ── 请求 DTO ──────────────────────────────────────────────────────────────
-
internal class OpenAIRequestDto
{
public string model { get; set; } = string.Empty;
@@ -12,20 +11,66 @@ internal class OpenAIRequestDto
public float temperature { get; set; }
public bool stream { get; set; }
public ResponseFormatDto? response_format { get; set; }
+ public List? tools { get; set; }
+ public object? tool_choice { get; set; }
+ }
+
+ internal class ToolDto
+ {
+ [JsonProperty("type")]
+ public string Type { get; set; } = "function";
+
+ [JsonProperty("function")]
+ public ToolFunctionDto? Function { get; set; }
+ }
+
+ internal class ToolFunctionDto
+ {
+ [JsonProperty("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonProperty("description")]
+ public string Description { get; set; } = string.Empty;
+
+ [JsonProperty("parameters")]
+ public object? Parameters { get; set; }
}
internal class MessageDto
{
public string role { get; set; } = string.Empty;
- public string content { get; set; } = string.Empty;
+ public string? content { get; set; }
+ public string? tool_call_id { get; set; }
+ public List? tool_calls { get; set; }
+ }
+
+ internal class ToolCallDto
+ {
+ [JsonProperty("id")]
+ public string Id { get; set; } = string.Empty;
+
+ [JsonProperty("type")]
+ public string Type { get; set; } = "function";
+
+ [JsonProperty("function")]
+ public ToolCallFunctionDto? Function { get; set; }
+ }
+
+ internal class ToolCallFunctionDto
+ {
+ [JsonProperty("name")]
+ public string Name { get; set; } = string.Empty;
+
+ [JsonProperty("arguments")]
+ public string Arguments { get; set; } = string.Empty;
}
internal class ResponseFormatDto
{
public string type { get; set; } = "json_object";
- }
- // ── 响应 DTO ──────────────────────────────────────────────────────────────
+ public object? json_schema { get; set; }
+ }
internal class OpenAIResponseDto
{
@@ -41,6 +86,7 @@ internal class ChoiceDto
internal class AssistantMessageDto
{
public string content { get; set; } = string.Empty;
+ public List? tool_calls { get; set; }
}
internal class UsageDto
diff --git a/Source/Client/Player2/Player2Client.cs b/Source/Client/Player2/Player2Client.cs
index 3e5a86e1..da12e9c7 100644
--- a/Source/Client/Player2/Player2Client.cs
+++ b/Source/Client/Player2/Player2Client.cs
@@ -14,11 +14,10 @@
namespace RimMind.Core.Client.Player2
{
- public class Player2Client : IAIClient
+ public partial class Player2Client : IAIClient
{
- private const string GameClientId = "019a8368-b00b-72bc-b367-2825079dc6fb";
+ public const string GameClientId = "019a8368-b00b-72bc-b367-2825079dc6fb";
private const string LocalUrl = "http://localhost:4315";
- private const string RemoteUrl = "https://api.player2.game";
private readonly string _apiKey;
private readonly bool _isLocalConnection;
@@ -27,6 +26,10 @@ public class Player2Client : IAIClient
private static DateTime _lastHealthCheck = DateTime.MinValue;
private static bool _healthCheckActive;
+ private string RemoteUrl => string.IsNullOrWhiteSpace(_settings.player2RemoteUrl)
+ ? "https://api.player2.game"
+ : _settings.player2RemoteUrl.Trim().TrimEnd('/');
+
private string CurrentApiUrl => _isLocalConnection ? LocalUrl : RemoteUrl;
private Player2Client(string apiKey, bool isLocal, RimMindCoreSettings settings)
@@ -134,6 +137,8 @@ private string BuildRequestJson(AIRequest request)
messages.Add(new Player2MessageDto { Role = "user", Content = request.UserPrompt });
}
+ messages = MergeConsecutiveSameRole(messages);
+
var body = new Player2RequestDto
{
Model = "default",
@@ -147,6 +152,34 @@ private string BuildRequestJson(AIRequest request)
new JsonSerializerSettings { NullValueHandling = NullValueHandling.Ignore });
}
+ private static List MergeConsecutiveSameRole(List messages)
+ {
+ if (messages == null || messages.Count <= 1) return messages;
+
+ var merged = new List(messages.Count);
+ var current = messages[0];
+
+ for (int i = 1; i < messages.Count; i++)
+ {
+ if (string.Equals(current.Role, messages[i].Role, StringComparison.OrdinalIgnoreCase))
+ {
+ current = new Player2MessageDto
+ {
+ Role = current.Role,
+ Content = current.Content + "\n" + messages[i].Content
+ };
+ }
+ else
+ {
+ merged.Add(current);
+ current = messages[i];
+ }
+ }
+ merged.Add(current);
+
+ return merged;
+ }
+
private async Task<(string text, long statusCode)> PostAsync(string url, string jsonBody)
{
bool isLocal = _isLocalConnection;
@@ -340,5 +373,258 @@ private static async Task IsPlayer2LocalAppAvailableAsync()
}
catch { return false; }
}
+
+ public async Task GetJoulesBalanceAsync()
+ {
+ if (string.IsNullOrEmpty(_apiKey)) return -1f;
+
+ try
+ {
+ string endpoint = $"{CurrentApiUrl}/v1/balance";
+ using var webRequest = UnityWebRequest.Get(endpoint);
+ webRequest.downloadHandler = new DownloadHandlerBuffer();
+ webRequest.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
+ webRequest.SetRequestHeader("player2-game-key", GameClientId);
+ webRequest.timeout = 10;
+
+ var asyncOp = webRequest.SendWebRequest();
+ while (!asyncOp.isDone)
+ {
+ if (Current.Game == null) return -1f;
+ await Task.Delay(100);
+ }
+
+ if (webRequest.result == UnityWebRequest.Result.ConnectionError ||
+ webRequest.result == UnityWebRequest.Result.ProtocolError)
+ return -1f;
+
+ var balance = JsonConvert.DeserializeObject(
+ webRequest.downloadHandler.text);
+ return balance?.Balance ?? -1f;
+ }
+ catch
+ {
+ return -1f;
+ }
+ }
+
+ private static float _cachedJoulesBalance = -1f;
+ private static DateTime _lastBalanceCheck = DateTime.MinValue;
+
+ public static float CachedJoulesBalance => _cachedJoulesBalance;
+
+ public static void RefreshJoulesBalance()
+ {
+ var s = RimMindCoreMod.Settings;
+ if (s == null || s.provider != AIProvider.Player2) return;
+
+ Task.Run(async () =>
+ {
+ var client = await CreateAsync(s);
+ if (client?.IsConfigured() == true)
+ {
+ float balance = await client.GetJoulesBalanceAsync();
+ _cachedJoulesBalance = balance;
+ _lastBalanceCheck = DateTime.Now;
+ }
+ });
+ }
+
+ public static bool ShouldRefreshBalance()
+ {
+ return (DateTime.Now - _lastBalanceCheck).TotalMinutes > 5;
+ }
+
+ public async Task SendRawAsync(string path, string jsonBody)
+ {
+ string endpoint = $"{CurrentApiUrl}{path}";
+ var result = new RawResponse();
+ try
+ {
+ using var webRequest = new UnityWebRequest(endpoint, "POST");
+ byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonBody ?? "{}");
+ webRequest.uploadHandler = new UploadHandlerRaw(bodyRaw);
+ webRequest.downloadHandler = new DownloadHandlerBuffer();
+ webRequest.SetRequestHeader("Content-Type", "application/json");
+ webRequest.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
+ webRequest.SetRequestHeader("player2-game-key", GameClientId);
+ webRequest.timeout = 30;
+
+ var asyncOp = webRequest.SendWebRequest();
+ while (!asyncOp.isDone) { if (Current.Game == null) { result.Error = "Game exiting"; return result; } await Task.Delay(50); }
+
+ result.Content = webRequest.downloadHandler?.text;
+ result.Success = webRequest.result != UnityWebRequest.Result.ConnectionError
+ && webRequest.result != UnityWebRequest.Result.ProtocolError;
+ if (!result.Success) result.Error = webRequest.error;
+ }
+ catch (System.Exception ex) { result.Error = ex.Message; }
+ return result;
+ }
+
+ public async Task GetRawAsync(string path)
+ {
+ string endpoint = $"{CurrentApiUrl}{path}";
+ var result = new RawResponse();
+ try
+ {
+ using var webRequest = UnityWebRequest.Get(endpoint);
+ webRequest.downloadHandler = new DownloadHandlerBuffer();
+ webRequest.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
+ webRequest.SetRequestHeader("player2-game-key", GameClientId);
+ webRequest.timeout = 30;
+
+ var asyncOp = webRequest.SendWebRequest();
+ while (!asyncOp.isDone) { if (Current.Game == null) { result.Error = "Game exiting"; return result; } await Task.Delay(50); }
+
+ result.Content = webRequest.downloadHandler?.text;
+ result.Success = webRequest.result != UnityWebRequest.Result.ConnectionError
+ && webRequest.result != UnityWebRequest.Result.ProtocolError;
+ if (!result.Success) result.Error = webRequest.error;
+ }
+ catch (System.Exception ex) { result.Error = ex.Message; }
+ return result;
+ }
+
+ public async Task DeleteRawAsync(string path)
+ {
+ string endpoint = $"{CurrentApiUrl}{path}";
+ var result = new RawResponse();
+ try
+ {
+ using var webRequest = UnityWebRequest.Delete(endpoint);
+ webRequest.downloadHandler = new DownloadHandlerBuffer();
+ webRequest.SetRequestHeader("Authorization", $"Bearer {_apiKey}");
+ webRequest.SetRequestHeader("player2-game-key", GameClientId);
+ webRequest.timeout = 30;
+
+ var asyncOp = webRequest.SendWebRequest();
+ while (!asyncOp.isDone) { if (Current.Game == null) { result.Error = "Game exiting"; return result; } await Task.Delay(50); }
+
+ result.Content = webRequest.downloadHandler?.text;
+ result.Success = webRequest.result != UnityWebRequest.Result.ConnectionError
+ && webRequest.result != UnityWebRequest.Result.ProtocolError;
+ if (!result.Success) result.Error = webRequest.error;
+ }
+ catch (System.Exception ex) { result.Error = ex.Message; }
+ return result;
+ }
+ }
+
+ public class RawResponse
+ {
+ public bool Success;
+ public string? Content;
+ public string? Error;
+ }
+
+ public partial class Player2Client
+ {
+ public async Task SendStructuredAsync(AIRequest request, string? jsonSchema, List? tools)
+ {
+ try
+ {
+ var messages = new List();
+ if (request.Messages != null && request.Messages.Count > 0)
+ messages = request.Messages.Select(m => new Player2MessageDto { Role = m.Role, Content = m.Content }).ToList();
+ else
+ {
+ if (!string.IsNullOrEmpty(request.SystemPrompt))
+ messages.Add(new Player2MessageDto { Role = "system", Content = request.SystemPrompt });
+ messages.Add(new Player2MessageDto { Role = "user", Content = request.UserPrompt });
+ }
+
+ messages = MergeConsecutiveSameRole(messages);
+
+ var body = new Dictionary
+ {
+ ["model"] = "default",
+ ["messages"] = messages,
+ ["max_tokens"] = request.MaxTokens > 0 ? request.MaxTokens : _settings.maxTokens,
+ ["temperature"] = request.Temperature,
+ };
+
+ if (!string.IsNullOrEmpty(jsonSchema))
+ {
+ body["response_format"] = new
+ {
+ type = "json_schema",
+ json_schema = new { name = "response", schema = JsonConvert.DeserializeObject(jsonSchema) },
+ };
+ }
+
+ if (tools != null && tools.Count > 0)
+ {
+ var toolList = new List