diff --git a/.gitignore b/.gitignore index 3c85cf4d64..938704f835 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,7 @@ tests/data/crypto/out* *.ox *.Plo *.so +/.solers # Binutils tmp linker output of the form "stXXXXXX" where "X" is alphanumeric st[A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9][A-Za-z0-9] diff --git a/README.md b/README.md index 1bf70548d7..7bd0818be8 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,7 @@ | **Solers AI** | Agent loop, context, tools, providers, permissions, editor UI, and MCP-compatible surface. | [`modules/solers_ai/`](modules/solers_ai/) | | **Engine** | The Godot 4.7.1 editor and runtime maintained as the Solers fork. | [`/`](.) | | **Architecture** | Runtime boundaries, native write contracts, and development guidance. | [`docs/SOLERS_ARCHITECTURE.md`](docs/SOLERS_ARCHITECTURE.md) | +| **AI-assisted design** | Human/AI workflow, design theory, verification, safety, and delivery roadmap. | [`docs/AI_ASSISTED_GAME_DESIGN.md`](docs/AI_ASSISTED_GAME_DESIGN.md) | | **Upstream** | Deterministic protocol for tracking and validating new Godot releases. | [`docs/UPSTREAM.md`](docs/UPSTREAM.md) | | **Tests** | Unit contracts and real editor behavior projects for Solers. | [`modules/solers_ai/tests/`](modules/solers_ai/tests/) | diff --git a/docs/AI_ASSISTED_GAME_DESIGN.md b/docs/AI_ASSISTED_GAME_DESIGN.md new file mode 100644 index 0000000000..4e10e819c5 --- /dev/null +++ b/docs/AI_ASSISTED_GAME_DESIGN.md @@ -0,0 +1,399 @@ +# Solers AI 辅助游戏设计:流程、理论与实现细节 + +状态:工作设计文档 +适用基线:Solers(Godot 4.7.1 派生版) +读者:产品设计、游戏设计、引擎开发、Agent/LLM 开发、测试与安全人员 + +## 1. 文档目的 + +本文回答三个问题: + +1. AI 在游戏设计中应当承担什么角色,人与 AI 如何分工; +2. 一次设计任务从模糊想法到可运行、可验证的 Godot 项目应经过哪些步骤; +3. Solers 当前代码如何支撑这一流程,尚缺什么,以及后续应怎样演进。 + +这里的“设计”不仅指生成 GDScript,也包括玩法规则、关卡与场景、视觉和声音、交互、数值、资源组织、调试、试玩、可访问性和导出约束。AI 的最终产物不是一段回答,而是带有证据、可撤销且仍兼容 Godot 的项目状态变化。 + +## 2. 产品定位与边界 + +Solers 的定位是“AI 原生的 Godot 协作式编辑器”,不是把聊天网页嵌入编辑器,也不是让模型直接接管整个工程。 + +人类主要负责: + +- 给出创作意图、审美判断、目标玩家和不可突破的边界; +- 决定哪些方案值得保留,对高风险操作进行审批; +- 对“是否有趣、是否符合表达”作最终判断。 + +AI 主要负责: + +- 把模糊意图转化为假设、约束、计划和可验收任务; +- 读取真实项目状态,执行重复且可结构化的编辑器操作; +- 快速产生多个低成本原型,运行、观察、诊断并迭代; +- 保存设计依据、操作记录和验证证据,降低上下文切换成本。 + +AI 不应被视为事实权威、美术总监或自动发布者。涉及删除、覆盖、网络、第三方代码、导出和外部成本的动作必须经过明确的能力与权限边界。 + +## 3. 理论基础 + +### 3.1 人本 AI 与混合主动协作 + +Solers 采用 human-in-the-loop 与 mixed-initiative 思想:人和 AI 都能提出下一步,但控制权始终可见且可收回。系统不只提供“自动/手动”两个极端,而应形成分级自主性: + +| 模式 | AI 能做什么 | 人的控制点 | +|---|---|---| +| Ask | 解释、检索、诊断,不修改 | 判断答案 | +| Plan | 形成方案、风险和验收标准 | 批准或修改计划 | +| Edit | 在给定边界内修改并验证 | 审阅变更和结果 | +| Autonomous | 连续完成多步任务 | 在风险检查点审批,可随时中止 | +| Playtest | 运行、观察、诊断、提出或执行修复 | 判断体验与是否接受修复 | +| Export | 校验与构建交付物 | 批准目标、签名和发布动作 | + +这避免两个常见失败:AI 只能聊天、无法完成工作;或者 AI 权限过大、用户无法理解工程为何变成现在这样。 + +### 3.2 设计即“假设—原型—证据—修正” + +游戏设计不是一次性生成问题,而是经验性搜索问题。可以把每轮设计表达为: + +```text +设计假设 H + -> 可运行原型 P + -> 观测证据 E(结构、日志、截图、性能、人工试玩) + -> 与验收标准 A 比较 + -> 保留、修正或放弃 +``` + +例如“冲刺让移动更爽”只是 H;带冷却、速度曲线和动画反馈的角色是 P;输入响应时间、碰撞错误、截图和玩家反馈是 E。只有 E 满足 A,任务才算完成。Solers 的 `done` 应表示“目标与验证闭环完成”,而不是“模型已经输出代码”。 + +### 3.3 受约束的生成式搜索 + +模型擅长扩展方案空间,但游戏项目存在硬约束:Godot 类型与生命周期、场景所有权、资源依赖、性能预算、目标平台、团队规范和已有设计决策。可将候选方案的选择近似为多目标优化: + +```text +Score = w1 * 玩法目标达成度 + + w2 * 风格一致性 + + w3 * 可维护性 + + w4 * 可访问性 + - w5 * 技术风险 + - w6 * 性能与制作成本 +``` + +权重不是让模型自行猜测的隐藏参数,而应来自用户目标、项目记忆和当前任务的验收条件。创意阶段允许发散,实现阶段必须收敛到少量可验证的候选。 + +### 3.4 Grounding:编辑器状态是事实源 + +LLM 的文字不是项目事实。Solers 应按以下优先级取证: + +1. Godot 编辑器中的实时对象、场景和资源状态; +2. 已保存的项目文件与导入元数据; +3. 运行时状态、Debugger、日志、截图和性能数据; +4. 项目记忆、设计文档和用户提供的参考资料; +5. 模型的一般知识与推断。 + +当这些来源冲突时,系统应说明冲突并重新观察,不能用模型记忆覆盖实时状态。任何会写入的操作都应带状态前提或版本信息,避免 AI 基于旧观察覆盖用户刚完成的编辑。 + +### 3.5 Affordance:模型操作能力而非猜文件格式 + +对模型暴露的是有 schema 的领域工具,而不是无限制文件系统。场景变更优先调用 Godot 原生对象和 UndoRedo API;脚本及少量文本配置使用带哈希前置条件的补丁;资源通过 Godot importer 和 Resource API 处理。 + +这相当于给 AI 一组明确的编辑器“可供性”:它知道能观察什么、能改变什么、操作需要什么参数、失败如何恢复。工具越明确,模型越少依赖脆弱的 `.tscn` 文本猜测。 + +### 3.6 可逆事务与信任形成 + +用户对 Agent 的信任来自可预测性,而不是模型自称可靠。每个写操作至少需要: + +- 操作前状态与并发前置条件; +- 权限等级和必要审批; +- 原子或 Godot 原生事务; +- 受影响对象、资源和文件的 receipt; +- UndoRedo、文件 checkpoint 或明确标记“不可逆”; +- 操作后的重新观察与验证。 + +权限、撤销策略、执行线程和敏感参数脱敏应当是工具定义的结构化元数据,不能靠工具名字符串推断。 + +## 4. 端到端工作流 + +```mermaid +flowchart LR + I[意图与素材] --> C[澄清目标和约束] + C --> O[观察项目真实状态] + O --> P[形成计划和验收标准] + P --> X[执行原生工具事务] + X --> V[静态/运行时/视觉验证] + V --> D{达到验收标准?} + D -- 否 --> R[诊断并缩小改动] + R --> O + D -- 是 --> H[用户审阅与接管] + H --> M[沉淀设计决策和证据] +``` + +### 4.1 意图输入 + +用户可以提交自然语言、选中节点、当前场景、脚本、参考图、已有素材或错误日志。输入应尽量包含: + +- 目标玩家与体验目标; +- 核心机制及明确排除项; +- 2D/3D、输入设备、分辨率和目标平台; +- 美术、声音、叙事参考,以及版权或许可边界; +- 性能预算和交付范围; +- 什么现象算成功。 + +若信息不完整,Agent 只询问会实质改变方案的少量问题;其余内容以显式假设继续推进,并记录假设。 + +### 4.2 任务框定 + +Agent 把请求整理为一份短任务契约: + +```text +目标:玩家可使用键盘和手柄完成带冲刺的 2D 移动。 +范围:Player 场景、输入映射、脚本、基础反馈;不制作正式美术。 +约束:Godot 4.x 标准节点;不引入第三方 addon;60 FPS。 +验收:无脚本错误;四向移动可用;冲刺有冷却且不穿墙;键盘/手柄均可触发。 +假设:角色为 CharacterBody2D,已有碰撞体。 +``` + +模糊的审美目标也需要转成可观察代理,例如“压迫感”可落到视野范围、对比度、环境声密度、敌人出现节奏等,但系统必须标注这些只是代理,不等同于人的体验判断。 + +### 4.3 上下文采集与预算 + +上下文不应把整个项目无差别塞给模型。建议分层: + +- 始终携带:任务契约、当前计划、权限、项目版本/场景 revision; +- 高优先级:选中对象、活动场景树、相关脚本符号和近期错误; +- 按需检索:资源依赖、ClassDB 属性、输入映射、项目设置、导出 preset; +- 证据附件:最新且与当前 revision 绑定的截图、日志摘要和测试结果; +- 长期记忆:已确认的设计原则、命名规范、平台预算和被否决方案。 + +Solers 当前已实现 Micro/Full Compaction、typed transcript、参考图保留和大型工具结果压缩。压缩必须保存未完成计划、重要约束、变更 receipt、验证失败和下一步,不能只生成聊天摘要。 + +### 4.4 计划与风险分解 + +计划应按“可独立验证的垂直切片”拆分,而不是按文件罗列。典型顺序是: + +1. 观察现状并确认前置条件; +2. 建立最小机制; +3. 保存并做静态检查; +4. 运行并采集证据; +5. 修复阻断问题; +6. 添加反馈和可访问性细节; +7. 回归验证并总结。 + +每步包含预期状态、可能写入、风险、验证方法和失败回退。Agent 可通过 `update_plan` 更新可恢复快照;计划是动态控制结构,不是开工前写完便不再变化的说明文字。 + +### 4.5 执行 + +推荐的工具选择顺序: + +1. 先查询对象、ClassDB 或资源契约; +2. 场景和资源优先走原生事务; +3. 脚本采用精确 patch,并提供 observed hash; +4. 每个逻辑批次后保存和重新观察; +5. 工具返回 pending 时使用原调用的 wait/resume 契约,不重复启动后台任务; +6. 网络素材、插件安装、运行和导出走对应权限门。 + +不要在一个巨大调用里同时创建场景、改脚本、下载素材并导出。较小的事务更容易定位失败、撤销和复用证据。 + +### 4.6 验证闭环 + +验证应从廉价、确定的检查逐步升级: + +| 层级 | 检查 | 典型证据 | +|---|---|---| +| 结构 | 节点类型、属性、资源依赖、信号连接 | 对象查询结果、receipt | +| 静态 | 脚本解析、类型与项目设置 | diagnostics、错误日志 | +| 行为 | 启动场景、状态变化、断言 | smoke test、运行日志 | +| 视觉 | 构图、遮挡、材质、UI 边界 | 与 scene revision 绑定的截图 | +| 性能 | 帧时间、draw calls、内存、加载时间 | 性能采样与预算比较 | +| 体验 | 可读性、节奏、趣味、情绪 | 人工试玩记录、遥测或 A/B 结果 | +| 交付 | preset、依赖和目标平台 | export validation/build receipt | + +视觉模型只能辅助发现明显问题,不能证明“好看”或“有趣”。截图也必须满足时效性:若捕获期间场景已变化,旧图不能作为当前版本的通过证据。当前 `SolersObservationService` 已对捕获源变化、运行时 epoch、渲染收敛帧和调试显示模式给出检查或警告。 + +### 4.7 结果交付 + +完成报告至少说明: + +- 做了什么,以及为什么; +- 影响了哪些场景、资源、脚本或设置; +- 哪些验收项已通过,对应证据是什么; +- 仍有哪些警告、主观判断或未验证平台; +- 如何撤销,用户接下来最值得人工检查什么。 + +只有全部必需验收项完成或用户明确接受剩余风险时才调用 `done`。普通说明文字或“代码看起来正确”不构成完成。 + +## 5. 按设计领域展开 + +### 5.1 玩法与脚本 + +流程是“输入/状态/规则建模 → 最小可玩机制 → 边界条件 → 反馈 → 回归”。Agent 应先确认节点职责和状态机,再写脚本;避免把移动、动画、音效、UI 和存档塞进单一脚本。关键参数应暴露为导出属性或 Resource,方便设计师手调。 + +验证重点包括输入丢失、帧率相关逻辑、碰撞穿透、状态不可达、信号重复连接、暂停行为以及键鼠/手柄一致性。 + +### 5.2 场景与关卡 + +AI 更适合先生成灰盒和约束满足的布局,再逐步装饰。流程为:建立尺度与动线约束、生成房间/平台、检查可达性和碰撞、从多个相机视角捕获、人工评估节奏,然后才替换正式资产。 + +程序化生成应记录 seed、生成参数和规则版本,保证问题可重现。导航烘焙成功只能证明路径存在,不能证明路线有趣。 + +### 5.3 美术与资产 + +资产工作应区分“搜索已有合法资产、生成临时资产、生成正式候选、Godot 导入和场景落位”。每个外部资产需要来源、许可证、模型/服务、生成参数、修改历史和允许用途。远程生成是有成本的网络操作,必须可预览、可取消、可重试且避免重复提交。 + +导入后仍需检查比例、pivot、碰撞、材质通道、纹理压缩、LOD、动画命名和目标平台预算。视觉一致性比单个资产质量更重要,应由项目风格约束和人工审美把关。 + +### 5.4 UI、文本与可访问性 + +AI 可生成控件树、主题候选、响应式布局和本地化占位,但应在多分辨率、长文本、字体回退、键盘/手柄焦点和缩放条件下验证。颜色对比、字幕、减少动态效果和输入重映射应进入验收标准,而不是最后补丁。 + +### 5.5 音频、动画与反馈 + +设计重点是事件与反馈语义:何时触发、能否中断、混音优先级、循环边界、动画状态转换和 gameplay 状态是否一致。AI 可建立连接和基础参数,但节奏与力度最终需要人在运行环境中判断。 + +### 5.6 调试、试玩与平衡 + +Playtest Agent 的闭环是:复现步骤 → 运行 → 获取日志/截图/状态 → 提出最小解释 → 做最小修复 → 用相同步骤回归。一次只改变少量变量,避免“修好但不知道为什么”。 + +数值平衡可用模拟和遥测缩小搜索空间,但模型不应根据少量局部数据宣称全局平衡。要保存参数集、随机种子、样本规模和评价指标,并将玩家定性反馈与数值指标分开呈现。 + +## 6. Solers 当前实现映射 + +以下是基于当前仓库代码的现状,不是未来愿景: + +| 流程能力 | 当前实现位置 | 作用 | +|---|---|---| +| 编辑器入口 | `editor/solers_editor_plugin.*`、`solers_dock.*` | 原生 Dock、会话与用户交互 | +| Agent 循环 | `core/solers_agent_session.*` | 流式响应、工具队列、审批、等待、压缩、完成语义 | +| 上下文 | `core/solers_context_manager.*` | token 预算、Micro/Full Compaction、附件投影 | +| 工具契约 | `core/solers_tool.h`、`solers_tool_registry.*` | schema、暴露级别、执行线程、资源访问、撤销和权限元数据 | +| 真实观察 | `core/solers_observation_service.*`、`solers_reflection_service.*` | 编辑器/运行时状态、对象关系、日志、性能与版本化截图 | +| 项目修改 | `solers_script_service.*`、`solers_resource_service.*` | 脚本、文件、资源及状态前置条件 | +| 可恢复性 | `solers_file_checkpoint.*`、`solers_action_timeline.*` | 文件 checkpoint、事件审计及 Godot UndoRedo 对接 | +| 权限安全 | `solers_permission_manager.*`、`solers_secret_store.*` | 分级能力、一次性批准、敏感信息保护 | +| 模型接入 | `llm/`、`solers_provider_registry.*` | 多 provider、协议适配、重试、BYOK 与本地模型 | +| 素材扩展 | `solers_asset_service.*`、`plugins/` | 素材能力、异步任务及外部服务插件 | +| 专业知识 | `skills/`、`solers_builtin_skills.*` | 按 Godot 领域按需加载指导,减少基础 prompt 膨胀 | +| 外部互操作 | `protocol/solers_mcp_adapter.*`、`solers_rpc_server.*` | MCP-compatible 工具面和显式启用的本地 RPC | + +当前体系的关键优点是:编辑器主线程仍是场景事实的权威写者;工具明确声明资源读写集合,能够安全调度;后台素材和截图采用 pending/resume;场景 receipt 与渲染证据绑定 revision,降低“看的是旧画面”问题。 + +需要继续验证和完善的部分包括:完整 action-scoped rollback、自动化输入与可重放试玩、设计记忆的显式 UI、跨平台视觉/性能矩阵、资产许可账本、成本预算、体验型验收的人工反馈入口,以及生产级发布审批链。 + +## 7. 权限、安全与隐私 + +当前权限域包括 observe、edit_scene、edit_files、run_project、export_build、network、install_plugin 和 shell。建议产品层采用以下规则: + +- 观察默认允许,但读取内容仍限制在项目边界并遵守敏感文件规则; +- 场景和文件编辑按会话或任务授权,receipt 必须可见; +- 网络请求显示 provider、目的、预计上传内容和可能成本; +- 第三方插件安装始终逐次批准,并在启用前检查来源和代码; +- shell 不因“自主模式”自动放开;命令、工作目录和输出都进入审计; +- export 与签名/上传分离,构建成功不等于允许发布; +- API key 不进入 prompt、timeline、普通日志或工具返回; +- 本地模型模式应明确哪些数据仍可能由素材插件或遥测发送到网络。 + +提示词注入不仅来自聊天,也可能藏在 README、导入资产、插件说明或远程搜索结果中。外部内容只能作为数据,不能自行改变系统权限、审批规则和工具契约。 + +## 8. 失败模式与处理策略 + +| 失败模式 | 表现 | 防护 | +|---|---|---| +| 幻觉 API/属性 | 写入不存在的 Godot 类或属性 | 先查询 ClassDB/对象契约,再调用类型化工具 | +| 旧状态覆盖 | 用户编辑后 AI 仍按旧场景写入 | revision/hash 前置条件,冲突后重新观察 | +| 只生成不验证 | 代码存在但项目不能运行 | `done` 前强制匹配验收证据 | +| 无边界重构 | 为小功能修改大量无关文件 | 任务范围、资源访问集、小事务与 diff/receipt | +| 反复启动异步任务 | 重复付费或产生多个资产 | pending id、wait/resume、幂等键 | +| 截图误判 | 旧帧、调试视图或未收敛渲染被当成结果 | revision/epoch receipt、帧门控和警告 | +| 撤销不完整 | 场景撤销了但文件或外部任务未恢复 | 每工具声明撤销策略;不可逆动作提前提示 | +| 上下文漂移 | 长任务忘记目标或已拒绝方案 | typed transcript、计划快照、压缩保留关键决策 | +| 提示注入 | 项目文件要求泄露密钥或绕过审批 | 权限与 prompt 分离、内容来源标记、秘密隔离 | +| “指标正确但不好玩” | 技术测试通过,体验仍差 | 把人工试玩设为体验型任务的必需验收项 | + +## 9. 评价指标 + +不要只统计模型回答满意度。建议同时追踪: + +- 任务完成率:全部必需验收项有证据的比例; +- 首轮闭环率:无需人工修复即可运行并验证的任务比例; +- 人工接管成本:完成任务所需手动操作数与修改时间; +- 回归率:AI 修改导致既有测试、场景或导出失败的比例; +- 撤销成功率:用户能恢复到操作前一致状态的比例; +- 错误审批率:不必要审批与漏审批的比例; +- 状态冲突率:被 revision/hash 拦截的过期写入比例; +- 证据新鲜度:完成时证据是否对应最终 revision; +- 延迟、token、远程调用和素材生成成本; +- 设计质量:由目标玩家试玩量表、留存/完成率等项目指标衡量,而非模型自评。 + +测试集应覆盖固定微任务、真实纵向切片和破坏性红队场景,并固定 Godot 版本、项目快照、模型配置、随机种子及验收器版本,保证结果可比较。 + +## 10. 推荐演进路线 + +### 阶段 A:可靠的协作编辑 + +- 固化 Ask/Plan/Edit 的产品语义; +- 为高频玩法、场景、UI 任务建立可执行验收模板; +- 完善 action-scoped rollback 与 receipt 展示; +- 用真实项目回归测试 revision、UndoRedo、脚本 patch 和截图证据。 + +### 阶段 B:可重放 Playtest + +- 增加输入序列录制/回放、运行时断言和稳定截图点; +- 把日志、对象状态、截图和性能样本归一为一次 playtest report; +- 允许用户对主观问题作结构化标注,反馈进入下一轮计划。 + +### 阶段 C:设计记忆与多候选实验 + +- 建立用户可编辑的项目设计记忆,而非不可见的向量记忆; +- 支持分支式原型、参数集、seed 和候选对比; +- 为资产来源、许可证、生成成本建立项目级账本。 + +### 阶段 D:交付与团队治理 + +- 建立平台构建矩阵、性能预算和发布审批链; +- 支持团队角色权限、共享决策记录和 CI 证据; +- 保持 Solers 改动集中在模块与少量明确内核接口,持续跟踪 Godot upstream。 + +## 11. 建议的用户任务模板 + +```text +目标体验: +目标玩家: +当前场景/选中对象: +必须实现: +明确不做: +玩法与美术参考: +目标平台和输入设备: +性能/尺寸/依赖约束: +允许使用网络素材或生成服务:是/否 +允许修改的目录或场景: +验收步骤: +需要我审批的节点: +``` + +示例: + +```text +为当前 3D 测试场景制作一个灰盒巡逻敌人。玩家进入 8 米范围后追击, +丢失视线 3 秒后返回巡逻。只修改 res://prototype/enemy/,不下载素材, +不改全局渲染设置。验收时运行测试场景,证明巡逻、追击、丢失和返回四种 +状态可达;无脚本错误;导航失败时不能穿墙。先给计划,场景与文件写入可按 +本任务自动批准,任何插件、网络或 shell 操作仍需逐次批准。 +``` + +## 12. 设计准则总结 + +Solers 的 AI 辅助设计能力应围绕一个原则建设:**把生成能力包在真实状态、明确权限、可逆事务和验证证据之中**。 + +其最小可信闭环不是“用户提问 → 模型回答”,而是: + +```text +用户意图 +→ 明确目标、约束与验收 +→ 读取实时 Godot 状态 +→ 形成可恢复计划 +→ 调用类型化原生工具 +→ 保存 receipt 与撤销点 +→ 运行并收集结构/日志/视觉/性能证据 +→ 未通过则诊断迭代 +→ 人工审阅后完成 +``` + +创意的开放性与工程的确定性并不冲突:模型负责扩大可能性,Godot 状态和测试负责收敛,人类负责价值与审美判断。三者缺一,系统都只能是聊天助手,而不是可信的 AI 原生游戏设计环境。 diff --git a/modules/solers_ai/core/solers_agent_session.cpp b/modules/solers_ai/core/solers_agent_session.cpp index 23792f36cb..5d30871e76 100644 --- a/modules/solers_ai/core/solers_agent_session.cpp +++ b/modules/solers_ai/core/solers_agent_session.cpp @@ -133,6 +133,8 @@ static Dictionary _update_plan_schema() { void SolersAgentSession::_bind_methods() { ClassDB::bind_method(D_METHOD("start_turn", "args"), &SolersAgentSession::start_turn); ClassDB::bind_method(D_METHOD("queue_user_message", "args"), &SolersAgentSession::queue_user_message); + ClassDB::bind_method(D_METHOD("branch_from_event", "event_id"), &SolersAgentSession::branch_from_event); + ClassDB::bind_method(D_METHOD("rewind_to_event", "event_id"), &SolersAgentSession::rewind_to_event); ClassDB::bind_method(D_METHOD("poll"), &SolersAgentSession::poll); ClassDB::bind_method(D_METHOD("abort"), &SolersAgentSession::abort); ClassDB::bind_method(D_METHOD("reset_conversation"), &SolersAgentSession::reset_conversation); @@ -161,6 +163,108 @@ Dictionary SolersAgentSession::_ok(const Variant &p_data) const { return result; } +Dictionary SolersAgentSession::branch_from_event(int64_t p_event_id) { + if (running) { + return _error("AGENT_BUSY", "Stop the current turn before branching the conversation."); + } + struct BranchScan { + String project_path; + int64_t target_id = 0; + Array prefix; + Dictionary target; + } scan; + scan.project_path = project_path; + scan.target_id = p_event_id; + solers_transcript_foreach_session(session_id, &scan, [](void *p_userdata, const String &p_record) -> bool { + BranchScan &state = *static_cast(p_userdata); + Dictionary event; + if (!solers_transcript_parse_record(p_record.strip_edges(), event) || String(event.get("project_path", String())) != state.project_path) { + return true; + } + const int64_t id = event.get("event_id", 0); + if (id == state.target_id) { + state.target = event.duplicate(true); + return true; + } + if (id < state.target_id) { + const String type = event.get("event_type", String()); + if (type != "checkpoint_created" && type != "checkpoint_consumed" && type != "checkpoint_cleared") { + state.prefix.push_back(event.duplicate(true)); + } + } + return true; + }); + if (scan.target.is_empty() || String(scan.target.get("event_type", String())) != "message" || String(scan.target.get("role", String())) != SolersLLMRole::USER) { + return _error("MESSAGE_NOT_FOUND", "The selected user message is no longer available in this session."); + } + const String source_session = session_id; + const String new_session = _make_session_id(); + for (int i = 0; i < scan.prefix.size(); i++) { + Dictionary event = Dictionary(scan.prefix[i]).duplicate(true); + event["session_id"] = new_session; + event["branch_source_session_id"] = source_session; + solers_transcript_write(event); + } + solers_transcript_flush(new_session); + set_session(project_path, new_session); + Dictionary data; + data["session_id"] = new_session; + data["source_session_id"] = source_session; + data["prompt"] = scan.target.get("content", String()); + data["mentions"] = scan.target.get("mentions", Array()); + data["attachments"] = scan.target.get("attachments", Array()); + data["target_revision"] = scan.target.get("session_revision", 0); + return _ok(data); +} + +Dictionary SolersAgentSession::rewind_to_event(int64_t p_event_id) { + if (running) { + return _error("AGENT_BUSY", "Stop the current turn before rewinding the conversation."); + } + Dictionary selected; + bool irreversible_after = false; + struct FindScan { int64_t id; Dictionary *selected; bool *irreversible_after; } scan{ p_event_id, &selected, &irreversible_after }; + solers_transcript_foreach_session(session_id, &scan, [](void *p_userdata, const String &p_record) -> bool { + FindScan &state = *static_cast(p_userdata); + Dictionary event; + if (solers_transcript_parse_record(p_record.strip_edges(), event) && (int64_t)event.get("event_id", 0) == state.id) { + *state.selected = event.duplicate(true); + return true; + } + if (!state.selected->is_empty() && String(event.get("event_type", String())) == "checkpoint_cleared" && (int64_t)event.get("session_revision", 0) > (int64_t)state.selected->get("session_revision", 0)) { + *state.irreversible_after = true; + } + return true; + }); + if (selected.is_empty() || String(selected.get("role", String())) != SolersLLMRole::USER) { + return _error("MESSAGE_NOT_FOUND", "The selected user message is no longer available in this session."); + } + if (irreversible_after) { + return _error("REWIND_IRREVERSIBLE", "A later Agent action has no reversible checkpoint. The conversation can be branched, but project changes cannot be safely rewound."); + } + if (!tool_registry) { + return _error("REWIND_UNAVAILABLE", "The project history service is unavailable."); + } + const uint64_t revision = (int64_t)selected.get("session_revision", 0); + const Dictionary reverted = tool_registry->rewind_session_to_revision(project_path, session_id, revision); + if (!(bool)reverted.get("ok", false)) { + return reverted; + } + const Array consumed_checkpoints = Dictionary(reverted.get("data", Dictionary())).get("consumed_checkpoints", Array()); + for (int i = 0; i < consumed_checkpoints.size(); i++) { + Dictionary payload; + payload["checkpoint"] = consumed_checkpoints[i]; + _write_transcript_event("checkpoint_consumed", payload); + } + Dictionary branched = branch_from_event(p_event_id); + if ((bool)branched.get("ok", false)) { + Dictionary data = branched.get("data", Dictionary()); + data["reverted_count"] = Dictionary(reverted.get("data", Dictionary())).get("reverted_count", 0); + branched["data"] = data; + } + return branched; +} + Dictionary SolersAgentSession::_error(const String &p_code, const String &p_message) const { Dictionary error; error["code"] = p_code; @@ -403,7 +507,7 @@ Dictionary SolersAgentSession::_read_transcript_state(const String &p_project_pa int restored_turn_id = 0; uint64_t restored_authored_revision = 0; uint64_t restored_observed_revision = 0; - Dictionary restored_reversal; + Array restored_reversals; HashSet restored_model_attachments; if (p_project_path.is_empty() || p_session_id.is_empty()) { Dictionary empty; @@ -425,7 +529,7 @@ Dictionary SolersAgentSession::_read_transcript_state(const String &p_project_pa uint64_t *restored_authored_revision = nullptr; uint64_t *restored_observed_revision = nullptr; int64_t *event_sequence = nullptr; - Dictionary *restored_reversal = nullptr; + Array *restored_reversals = nullptr; HashSet *restored_model_attachments = nullptr; } restore_state; restore_state.project_path = &p_project_path; @@ -440,7 +544,7 @@ Dictionary SolersAgentSession::_read_transcript_state(const String &p_project_pa restore_state.restored_authored_revision = &restored_authored_revision; restore_state.restored_observed_revision = &restored_observed_revision; restore_state.event_sequence = &transcript_event_sequence; - restore_state.restored_reversal = &restored_reversal; + restore_state.restored_reversals = &restored_reversals; restore_state.restored_model_attachments = &restored_model_attachments; solers_transcript_foreach_session(p_session_id, &restore_state, [](void *p_userdata, const String &p_record) -> bool { ScanState &scan = *static_cast(p_userdata); @@ -478,11 +582,21 @@ Dictionary SolersAgentSession::_read_transcript_state(const String &p_project_pa } if (event_type == "checkpoint_created") { Dictionary checkpoint = event.get("checkpoint", Dictionary()); - *scan.restored_reversal = checkpoint.duplicate(true); + scan.restored_reversals->push_back(checkpoint.duplicate(true)); + return true; + } + if (event_type == "checkpoint_cleared") { + scan.restored_reversals->clear(); return true; } - if (event_type == "checkpoint_cleared" || event_type == "checkpoint_consumed") { - scan.restored_reversal->clear(); + if (event_type == "checkpoint_consumed") { + const String consumed_id = String(Dictionary(event.get("checkpoint", Dictionary())).get("id", String())); + for (int i = scan.restored_reversals->size() - 1; i >= 0; i--) { + if (consumed_id.is_empty() || String(Dictionary((*scan.restored_reversals)[i]).get("id", String())) == consumed_id) { + scan.restored_reversals->remove_at(i); + break; + } + } return true; } if (event_type == "tool_result") { @@ -592,7 +706,7 @@ Dictionary SolersAgentSession::_read_transcript_state(const String &p_project_pa delivered_attachments.push_back(identity); } state["delivered_model_attachments"] = delivered_attachments; - state["reversal"] = restored_reversal; + state["reversals"] = restored_reversals; return state; } @@ -1096,11 +1210,12 @@ String SolersAgentSession::_default_system_prompt() const { "- Inspect unfamiliar classes with engine.describe; ClassDB property metadata and native documentation are the authority for names, types, units, and usage.\n" "- Read live state with object.query. Edit scenes/resources with object.transaction and its native state/hash preconditions. Use script.compute for isolated bulk generation with declared file outputs.\n" "- Scene transactions are one EditorUndoRedo action and Solers persists successful live-scene changes. Resource transactions are file-checkpointed. Tool results carry native state receipts and persisted file hashes; do not issue a separate save.\n" - "- The 3D editor viewport is the live edited scene tree. Use object.transaction for visible scene construction; scripts own runtime behavior. Establish appearance only with render.capture target=editor|camera tied to the source_state receipt (runtime proves Play only). Use object.query target=relations for world-space relations; a screenshot is not a geometry measurement.\n" + "- The 3D editor viewport is the live edited scene tree. Use object.transaction for visible scene construction; scripts own runtime behavior. Use object.query target=relations for world-space relations; a screenshot is not a geometry measurement.\n" "- Background tools return stable job ids immediately. Continue independent work; when nothing else is runnable, call job.wait once with the required ids and stop issuing tools. Solers parks this turn and resumes it with a background job delta when any requested job reaches a project-import terminal state; do not poll asset.status for progress.\n" "- Tool errors carry the native cause; read it, change what it names, and retry. Repeating an identical failed call wastes a step.\n" "- Before each non-trivial tool call or group of related calls, write one short sentence saying what you are about to do and why. Group related actions under one preamble; skip it for trivial reads. This narration is how the user follows your progress.\n" "- Use update_plan only as a concise optional progress display. Text without tool calls ends the task, so keep progress notes attached to tool-calling turns and finish with a clear final summary."; + prompt += "\n- When render.capture is available, establish appearance with target=editor|camera tied to the source_state receipt (runtime proves Play only). When it is absent, verify with text tools and disclose that visual verification was skipped."; if (tool_registry) { const String skill_catalog = tool_registry->get_skill_catalog_prompt(); if (!skill_catalog.is_empty()) { @@ -1185,7 +1300,8 @@ Array SolersAgentSession::_collect_tools() { return out; } const uint64_t catalog_revision = tool_registry->get_tool_catalog_revision(); - if (cached_tool_catalog_revision == catalog_revision && cached_request_deferred_count == task_deferred_tools.size()) { + const bool image_input_enabled = _image_input_enabled(); + if (cached_tool_catalog_revision == catalog_revision && cached_request_deferred_count == task_deferred_tools.size() && cached_request_image_input_enabled == image_input_enabled) { return cached_request_tools; } const Array defs = tool_registry->list_tools(); @@ -1193,6 +1309,9 @@ Array SolersAgentSession::_collect_tools() { const Dictionary def = defs[i]; const String exposure = def.get("exposure", "direct"); const StringName canonical_name = StringName(def.get("name", String())); + if (canonical_name == SNAME("render.capture") && !image_input_enabled) { + continue; + } if (exposure == "hidden" || (exposure == "deferred" && !task_deferred_tools.has(canonical_name))) { continue; } @@ -1207,6 +1326,7 @@ Array SolersAgentSession::_collect_tools() { cached_request_tool_tokens = SolersContextManager::estimate_tokens(JSON::stringify(out, "", false, true)); cached_request_deferred_count = task_deferred_tools.size(); cached_tool_catalog_revision = catalog_revision; + cached_request_image_input_enabled = image_input_enabled; return cached_request_tools; } @@ -1275,6 +1395,20 @@ int SolersAgentSession::_active_model_input_support(const String &p_modality) co return SolersModelsDev::input_modality_support(models_dev->get_model(catalog_provider, active_provider.get("model", String())), p_modality); } +bool SolersAgentSession::_image_input_enabled() const { + const String mode = String(active_provider.get("image_input_mode", "auto")).strip_edges().to_lower(); + if (mode == "enabled") { + return true; + } + if (mode == "disabled") { + return false; + } + // Auto mode is capability-forward: only an explicit catalog denial blocks images. + // Compatible gateways with incomplete metadata may still accept multimodal input; + // users can select Disabled when a particular endpoint does not. + return _active_model_input_support("image") != 0; +} + Dictionary SolersAgentSession::_build_request(const Array &p_messages, const String &p_request_system_prompt, const Array &p_tools) const { Dictionary request; request["model"] = active_provider.get("model", String()); @@ -1305,6 +1439,8 @@ Dictionary SolersAgentSession::_build_request(const Array &p_messages, const Str request["max_tokens"] = max_output_tokens; } request["session_id"] = session_id; + request["send_session_id_header"] = active_provider.get("send_session_id_header", true); + request["image_input_enabled"] = _image_input_enabled(); return request; } @@ -1746,7 +1882,7 @@ Dictionary SolersAgentSession::start_turn(const Dictionary &p_args) { emit_signal(SNAME("turn_failed"), e.get("error", Dictionary())); return e; } - if (!turn_attachments.is_empty() && _active_model_input_support("image") == 0) { + if (!turn_attachments.is_empty() && !_image_input_enabled()) { Dictionary e = _error("VISION_CAPABILITY_REQUIRED", "The selected model does not support image input. Choose a vision-capable model before sending image references."); emit_signal(SNAME("turn_failed"), e.get("error", Dictionary())); return e; @@ -2558,7 +2694,7 @@ void SolersAgentSession::_execute_deferred_tool(uint64_t p_token) { return; } if (deferred_canonical_name == "render.capture") { - if (_active_model_input_support("image") == 0) { + if (!_image_input_enabled()) { deferred_result = _tool_denied_result("VISION_CAPABILITY_REQUIRED", "The selected model does not support image input, so viewport captures cannot be returned to it."); deferred_done = true; return; @@ -2983,7 +3119,7 @@ void SolersAgentSession::reset_conversation() { context_manager->reset(); } if (tool_registry) { - tool_registry->restore_session_reversal(session_id, Dictionary()); + tool_registry->restore_session_reversals(session_id, Array()); } session_id = _make_session_id(); authored_revision = 0; @@ -3011,7 +3147,7 @@ void SolersAgentSession::set_session(const String &p_project_path, const String session_id = p_session_id; } if (tool_registry && previous_session_id != session_id) { - tool_registry->restore_session_reversal(previous_session_id, Dictionary()); + tool_registry->restore_session_reversals(previous_session_id, Array()); } transcript_event_sequence = 0; const Dictionary state = _read_transcript_state(project_path, session_id); @@ -3030,7 +3166,7 @@ void SolersAgentSession::set_session(const String &p_project_path, const String delivered_model_attachments.insert(delivered_attachments[i]); } if (tool_registry) { - tool_registry->restore_session_reversal(session_id, state.get("reversal", Dictionary())); + tool_registry->restore_session_reversals(session_id, state.get("reversals", Array())); } pending_background_assets = state.get("background_assets", Array()); background_resume_suppressed = false; diff --git a/modules/solers_ai/core/solers_agent_session.h b/modules/solers_ai/core/solers_agent_session.h index faaf5a1d25..a5c18c4c4d 100644 --- a/modules/solers_ai/core/solers_agent_session.h +++ b/modules/solers_ai/core/solers_agent_session.h @@ -105,6 +105,7 @@ class SolersAgentSession : public Object { int cached_request_tool_tokens = 0; uint32_t cached_request_deferred_count = 0; uint64_t cached_tool_catalog_revision = 0; + bool cached_request_image_input_enabled = false; Array turn_attachments; HashSet delivered_model_attachments; HashSet pending_model_attachments; @@ -217,6 +218,7 @@ class SolersAgentSession : public Object { Array _collect_tools(); bool _refresh_active_model_limits(); int _active_model_input_support(const String &p_modality) const; + bool _image_input_enabled() const; Dictionary _build_request(const Array &p_messages, const String &p_request_system_prompt, const Array &p_tools) const; Dictionary _provider_dispatch_error() const; Error _begin_provider_request(const Dictionary &p_request, const Dictionary &p_profile); @@ -280,6 +282,12 @@ class SolersAgentSession : public Object { // conversation after the current tool batch, before the next model // dispatch. Fails with AGENT_IDLE when no turn is running. Dictionary queue_user_message(const Dictionary &p_args); + // Preserve the original audit log and create a new conversation whose + // history ends immediately before the selected user message. + Dictionary branch_from_event(int64_t p_event_id); + // Atomically restore reversible project mutations after the selected + // message's revision, then create the same conversation branch. + Dictionary rewind_to_event(int64_t p_event_id); void poll(); void shutdown(); void abort(); diff --git a/modules/solers_ai/core/solers_settings_service.cpp b/modules/solers_ai/core/solers_settings_service.cpp index c9901ecaf8..b237359d4c 100644 --- a/modules/solers_ai/core/solers_settings_service.cpp +++ b/modules/solers_ai/core/solers_settings_service.cpp @@ -42,7 +42,7 @@ #include "modules/solers_ai/core/solers_secret_store.h" #include "modules/solers_ai/llm/solers_models_dev.h" -static constexpr int SOLERS_PROVIDER_SETTINGS_VERSION = 6; +static constexpr int SOLERS_PROVIDER_SETTINGS_VERSION = 7; void SolersSettingsService::_bind_methods() { ClassDB::bind_method(D_METHOD("set_provider_registry", "provider_registry"), &SolersSettingsService::set_provider_registry); @@ -108,7 +108,7 @@ void SolersSettingsService::_migrate_provider_settings() { provider = "custom_openai_compatible"; } if (version >= 2 && !previous_provider.is_empty() && previous_provider != provider) { - static const char *PROVIDER_KEYS[] = { "configured", "model", "reasoning_effort", "base_url", "api_key", "oauth" }; + static const char *PROVIDER_KEYS[] = { "configured", "model", "reasoning_effort", "base_url", "api_key", "oauth", "send_session_id_header", "image_input_mode" }; for (const char *key : PROVIDER_KEYS) { const String old_path = _provider_setting_path(previous_provider, key); if (settings->has_setting(old_path)) { @@ -152,7 +152,7 @@ void SolersSettingsService::_migrate_provider_settings() { // Single alias authority: SolersModelsDev::canonical_provider_id. SolersModelsDev *md = provider_registry ? provider_registry->get_models_dev() : nullptr; if (md) { - static const char *PROVIDER_KEYS[] = { "configured", "model", "base_url", "api_key", "oauth", "reasoning_effort" }; + static const char *PROVIDER_KEYS[] = { "configured", "model", "base_url", "api_key", "oauth", "reasoning_effort", "send_session_id_header", "image_input_mode" }; for (const Variant &from_v : md->list_legacy_provider_ids()) { const String from = from_v; const String to = md->canonical_provider_id(from); @@ -251,6 +251,8 @@ Dictionary SolersSettingsService::_get_provider_config(const String &p_provider, data["configured"] = settings->has_setting(_provider_setting_path(p_provider, "configured")) && (bool)settings->get_setting(_provider_setting_path(p_provider, "configured")); data["model"] = settings->has_setting(_provider_setting_path(p_provider, "model")) ? String(settings->get_setting(_provider_setting_path(p_provider, "model"))) : String(profile.get("default_model", String())); data["reasoning_effort"] = settings->has_setting(_provider_setting_path(p_provider, "reasoning_effort")) ? String(settings->get_setting(_provider_setting_path(p_provider, "reasoning_effort"))) : String(); + data["send_session_id_header"] = !settings->has_setting(_provider_setting_path(p_provider, "send_session_id_header")) || (bool)settings->get_setting(_provider_setting_path(p_provider, "send_session_id_header")); + data["image_input_mode"] = settings->has_setting(_provider_setting_path(p_provider, "image_input_mode")) ? String(settings->get_setting(_provider_setting_path(p_provider, "image_input_mode"))) : String("auto"); data["base_url"] = settings->has_setting(_provider_setting_path(p_provider, "base_url")) ? String(settings->get_setting(_provider_setting_path(p_provider, "base_url"))) : String(profile.get("default_base_url", String())); // Credential PRESENCE only: a stored blob / env var existing is the @@ -362,6 +364,13 @@ Dictionary SolersSettingsService::set_provider_config(const Dictionary &p_args) if (!provider_registry) { return _error("PROVIDER_REGISTRY_UNAVAILABLE", "Solers provider registry is not initialized.", false); } + if (p_args.has("image_input_mode")) { + const String mode = String(p_args["image_input_mode"]).strip_edges().to_lower(); + if (mode != "auto" && mode != "enabled" && mode != "disabled") { + return _error("INVALID_IMAGE_INPUT_MODE", "Image input mode must be auto, enabled, or disabled."); + } + } + // Catalog / AuthHook / custom OpenAI-compatible — get_provider_profile always resolves. if (p_args.has("model") && !provider_registry->is_model_allowed(provider, String(p_args["model"]))) { return _error("MODEL_NOT_ALLOWED", "The selected model is not available through this provider connection."); } @@ -393,6 +402,13 @@ Dictionary SolersSettingsService::set_provider_config(const Dictionary &p_args) if (p_args.has("base_url")) { settings->set_manually(_provider_setting_path(provider, "base_url"), String(p_args["base_url"])); } + if (p_args.has("send_session_id_header")) { + settings->set_manually(_provider_setting_path(provider, "send_session_id_header"), (bool)p_args["send_session_id_header"]); + } + if (p_args.has("image_input_mode")) { + const String mode = String(p_args["image_input_mode"]).strip_edges().to_lower(); + settings->set_manually(_provider_setting_path(provider, "image_input_mode"), mode); + } if (p_args.has("api_key") && !String(p_args["api_key"]).is_empty()) { settings->set_manually(_provider_setting_path(provider, "api_key"), SolersSecretStore::protect(String(p_args["api_key"]))); } @@ -411,7 +427,7 @@ Dictionary SolersSettingsService::set_provider_config(const Dictionary &p_args) Dictionary SolersSettingsService::disconnect_provider(const String &p_provider) { EditorSettings *settings = EditorSettings::get_singleton(); ERR_FAIL_NULL_V(settings, _error("EDITOR_SETTINGS_UNAVAILABLE", "EditorSettings is not available.", false)); - static const char *KEYS[] = { "configured", "model", "reasoning_effort", "base_url", "api_key", "oauth" }; + static const char *KEYS[] = { "configured", "model", "reasoning_effort", "base_url", "api_key", "oauth", "send_session_id_header", "image_input_mode" }; for (const char *key : KEYS) { const String path = _provider_setting_path(p_provider, key); if (settings->has_setting(path)) { diff --git a/modules/solers_ai/core/solers_tool_registry.cpp b/modules/solers_ai/core/solers_tool_registry.cpp index b9e72fd81f..4e16a8f6a4 100644 --- a/modules/solers_ai/core/solers_tool_registry.cpp +++ b/modules/solers_ai/core/solers_tool_registry.cpp @@ -1109,32 +1109,28 @@ Dictionary SolersToolRegistry::_finalize_prepared_result(SolersPreparedToolCall mutation["receipt"] = receipt; const String session_key = r_call.context.session_id.is_empty() ? String("direct") : r_call.context.session_id; if (r_call.mutation_policy == SolersToolMutationPolicy::EDITOR_UNDO || r_call.mutation_policy == SolersToolMutationPolicy::FILE_CHECKPOINT) { - const String *previous_id = latest_reversal_by_session.getptr(session_key); - if (previous_id) { - const Dictionary *previous = reversals.getptr(*previous_id); - if (previous) { - _discard_reversal(*previous); - } - reversals.erase(*previous_id); - } const String reversal_id = (session_key + ":" + r_call.context.call_id + ":" + String::num_uint64(r_call.context.authored_revision + 1) + ":" + String::num_int64(reversals.size() + 1)).sha256_text(); record["id"] = reversal_id; record["session_id"] = session_key; record["session_revision"] = (int64_t)(r_call.context.authored_revision + 1); reversals[reversal_id] = record; + reversal_stack_by_session[session_key].push_back(reversal_id); latest_reversal_by_session[session_key] = reversal_id; mutation["reversal_id"] = reversal_id; r_call.journal_event["event_type"] = "checkpoint_created"; r_call.journal_event["checkpoint"] = record; r_call.journal_event["note"] = "Protective checkpoint for history.revert; not a rollback of your edit."; } else { - const String *previous_id = latest_reversal_by_session.getptr(session_key); - if (previous_id) { - const Dictionary *previous = reversals.getptr(*previous_id); - if (previous) { - _discard_reversal(*previous); + Vector *stack = reversal_stack_by_session.getptr(session_key); + if (stack) { + for (const String &id : *stack) { + const Dictionary *previous = reversals.getptr(id); + if (previous) { + _discard_reversal(*previous); + } + reversals.erase(id); } - reversals.erase(*previous_id); + reversal_stack_by_session.erase(session_key); latest_reversal_by_session.erase(session_key); r_call.journal_event["event_type"] = "checkpoint_cleared"; } @@ -1192,10 +1188,20 @@ Dictionary SolersToolRegistry::_revert_latest(const SolersToolContext &p_context _discard_reversal(record); reversals.erase(reversal_id); - latest_reversal_by_session.erase(session_key); + Vector *stack = reversal_stack_by_session.getptr(session_key); + if (stack && !stack->is_empty() && (*stack)[stack->size() - 1] == reversal_id) { + stack->remove_at(stack->size() - 1); + } + if (stack && !stack->is_empty()) { + latest_reversal_by_session[session_key] = (*stack)[stack->size() - 1]; + } else { + reversal_stack_by_session.erase(session_key); + latest_reversal_by_session.erase(session_key); + } Dictionary data; data["reversal_id"] = reversal_id; data["reverted_session_revision"] = record.get("session_revision", 0); + data["checkpoint"] = record.duplicate(true); data["checkpoint_consumed"] = true; data["authored_state_changed"] = true; return _ok(data); @@ -2574,21 +2580,90 @@ void SolersToolRegistry::clear_task_state(const String &p_session_id) { } } -void SolersToolRegistry::restore_session_reversal(const String &p_session_id, const Dictionary &p_record) { +void SolersToolRegistry::restore_session_reversals(const String &p_session_id, const Array &p_records) { if (p_session_id.is_empty()) { return; } - const String *existing_id = latest_reversal_by_session.getptr(p_session_id); - if (existing_id) { - reversals.erase(*existing_id); - latest_reversal_by_session.erase(p_session_id); + Vector *existing = reversal_stack_by_session.getptr(p_session_id); + if (existing) { + for (const String &id : *existing) { + reversals.erase(id); + } + } + reversal_stack_by_session.erase(p_session_id); + latest_reversal_by_session.erase(p_session_id); + for (int i = 0; i < p_records.size(); i++) { + const Dictionary record = p_records[i]; + const String reversal_id = record.get("id", String()); + if (reversal_id.is_empty() || String(record.get("session_id", String())) != p_session_id) { + continue; + } + reversals[reversal_id] = record.duplicate(true); + reversal_stack_by_session[p_session_id].push_back(reversal_id); + latest_reversal_by_session[p_session_id] = reversal_id; } - const String reversal_id = p_record.get("id", String()); - if (reversal_id.is_empty() || String(p_record.get("session_id", String())) != p_session_id) { - return; +} + +Dictionary SolersToolRegistry::rewind_session_to_revision(const String &p_project_path, const String &p_session_id, uint64_t p_revision) { + Vector *stack = reversal_stack_by_session.getptr(p_session_id); + if (!stack) { + return _ok(Dictionary()); } - reversals[reversal_id] = p_record.duplicate(true); - latest_reversal_by_session[p_session_id] = reversal_id; + // Preflight the whole suffix before changing anything. This prevents a + // half-rewind when a user edited a scene or file after an Agent action. + uint64_t expected_scene_version = UINT64_MAX; + bool newest_record = true; + for (int i = stack->size() - 1; i >= 0; i--) { + const Dictionary *record = reversals.getptr((*stack)[i]); + if (!record || (uint64_t)(int64_t)record->get("session_revision", 0) <= p_revision) { + break; + } + const String policy = record->get("policy", String()); + if (policy == "editor_undo") { + const int history_id = record->get("history_id", EditorUndoRedoManager::INVALID_HISTORY); + UndoRedo *undo_redo = EditorUndoRedoManager::get_singleton() ? EditorUndoRedoManager::get_singleton()->get_history_undo_redo(history_id) : nullptr; + const uint64_t after = (int64_t)record->get("version_after", 0); + if (!undo_redo || (expected_scene_version == UINT64_MAX ? undo_redo->get_version() : expected_scene_version) != after) { + return _error("REWIND_CONFLICT", "The scene UndoRedo history changed after an Agent edit."); + } + expected_scene_version = (int64_t)record->get("version_before", 0); + } else if (policy == "file_checkpoint" && newest_record) { + const Array checkpoints = record->get("checkpoints", Array()); + for (int j = 0; j < checkpoints.size(); j++) { + const Dictionary checkpoint = checkpoints[j]; + const String path = checkpoint.get("path", String()); + const bool exists = FileAccess::exists(path); + if (exists != (bool)checkpoint.get("exists_after", false) || (exists && FileAccess::get_sha256(path) != String(checkpoint.get("sha256_after", String())))) { + return _error("REWIND_CONFLICT", vformat("File changed after the Agent edit: %s", path)); + } + } + } + newest_record = false; + } + int count = 0; + Array consumed_checkpoints; + while ((stack = reversal_stack_by_session.getptr(p_session_id)) && !stack->is_empty()) { + const Dictionary *record = reversals.getptr((*stack)[stack->size() - 1]); + if (!record || (uint64_t)(int64_t)record->get("session_revision", 0) <= p_revision) { + break; + } + SolersToolContext context; + context.project_path = p_project_path; + context.session_id = p_session_id; + context.authored_revision = (int64_t)record->get("session_revision", 1) - 1; + Dictionary args; + args["reversal_id"] = (*stack)[stack->size() - 1]; + const Dictionary reverted = _revert_latest(context, args); + if (!(bool)reverted.get("ok", false)) { + return reverted; + } + consumed_checkpoints.push_back(Dictionary(reverted.get("data", Dictionary())).get("checkpoint", Dictionary())); + count++; + } + Dictionary data; + data["reverted_count"] = count; + data["consumed_checkpoints"] = consumed_checkpoints; + return _ok(data); } int SolersToolRegistry::get_tool_count() const { @@ -2601,5 +2676,6 @@ SolersToolRegistry::~SolersToolRegistry() { _clear_tools(); reversals.clear(); latest_reversal_by_session.clear(); + reversal_stack_by_session.clear(); delivered_addon_contracts.clear(); } diff --git a/modules/solers_ai/core/solers_tool_registry.h b/modules/solers_ai/core/solers_tool_registry.h index e98286ccac..0a98f7c2f1 100644 --- a/modules/solers_ai/core/solers_tool_registry.h +++ b/modules/solers_ai/core/solers_tool_registry.h @@ -68,6 +68,7 @@ class SolersToolRegistry : public Object { uint64_t tool_catalog_revision = 0; HashMap reversals; HashMap latest_reversal_by_session; + HashMap> reversal_stack_by_session; HashSet delivered_addon_contracts; SolersObservationService *observation_service = nullptr; @@ -175,7 +176,8 @@ class SolersToolRegistry : public Object { Dictionary call_tool(const StringName &p_name, const Dictionary &p_args); Dictionary call_tool_with_context(const StringName &p_name, const Dictionary &p_args, const SolersToolContext &p_context); void clear_task_state(const String &p_session_id); - void restore_session_reversal(const String &p_session_id, const Dictionary &p_record); + void restore_session_reversals(const String &p_session_id, const Array &p_records); + Dictionary rewind_session_to_revision(const String &p_project_path, const String &p_session_id, uint64_t p_revision); int get_tool_count() const; SolersToolRegistry(); diff --git a/modules/solers_ai/editor/solers_chat_cells.cpp b/modules/solers_ai/editor/solers_chat_cells.cpp index 129fbd307b..0e63f0fab8 100644 --- a/modules/solers_ai/editor/solers_chat_cells.cpp +++ b/modules/solers_ai/editor/solers_chat_cells.cpp @@ -35,6 +35,8 @@ #include "core/os/os.h" #include "editor/themes/editor_scale.h" #include "scene/gui/box_container.h" +#include "scene/gui/button.h" +#include "scene/gui/text_edit.h" #include "scene/resources/style_box_flat.h" #include "scene/resources/text_paragraph.h" #include "scene/theme/theme_db.h" @@ -146,8 +148,87 @@ String solers_summarize_tool_args(const String &p_arguments_json) { /* ------------------------------------------------------------------ */ SolersUserBubble::SolersUserBubble() { - set_mouse_filter(MOUSE_FILTER_IGNORE); + // Receive the context-menu click while still allowing wheel/drag events to + // reach the surrounding chat ScrollContainer. + set_mouse_filter(MOUSE_FILTER_PASS); set_h_size_flags(SIZE_EXPAND_FILL); + connect(SceneStringName(gui_input), callable_mp(this, &SolersUserBubble::_gui_input)); +} + +void SolersUserBubble::_begin_edit() { + if (editing || !rewind_callback.is_valid()) { + return; + } + editing = true; + inline_editor = memnew(TextEdit); + inline_editor->set_text(SolersMention::strip_prompt_block(text)); + inline_editor->set_autowrap_mode(TextServer::AUTOWRAP_WORD_SMART); + inline_editor->set_fit_content_height_enabled(true); + inline_editor->connect(SceneStringName(focus_exited), callable_mp(this, &SolersUserBubble::_cancel_edit_deferred), CONNECT_DEFERRED); + add_child(inline_editor); + rewind_button = memnew(Button(TTR("Rewind"))); + rewind_button->connect(SceneStringName(pressed), callable_mp(this, &SolersUserBubble::_rewind_pressed)); + add_child(rewind_button); + cell_height = MAX(cell_height, 126.0f * EDSCALE); + update_minimum_size(); + _layout_editor(); + inline_editor->grab_focus(); + inline_editor->set_caret_line(inline_editor->get_line_count() - 1); + inline_editor->set_caret_column(inline_editor->get_line(inline_editor->get_line_count() - 1).length()); + queue_redraw(); +} + +void SolersUserBubble::_end_edit() { + editing = false; + if (inline_editor) { + inline_editor->queue_free(); + inline_editor = nullptr; + } + if (rewind_button) { + rewind_button->queue_free(); + rewind_button = nullptr; + } + shaped_for_width = -1.0f; + _shape(get_size().x); + queue_redraw(); +} + +void SolersUserBubble::_cancel_edit_deferred() { + if (!editing || (inline_editor && inline_editor->has_focus()) || (rewind_button && rewind_button->has_focus())) { + return; + } + _end_edit(); +} + +void SolersUserBubble::_rewind_pressed() { + if (!editing || !inline_editor || !rewind_callback.is_valid()) { + return; + } + const String edited_text = inline_editor->get_text(); + _end_edit(); + rewind_callback.call(edited_text); +} + +void SolersUserBubble::_layout_editor() { + if (!editing || !inline_editor || !rewind_button) { + return; + } + const float ed = EDSCALE; + const float width = MIN(get_size().x, 440.0f * ed); + const float x = get_size().x - width; + inline_editor->set_position(Point2(x + 8.0f * ed, 8.0f * ed)); + inline_editor->set_size(Size2(width - 16.0f * ed, cell_height - 48.0f * ed)); + const Size2 button_size(MAX(82.0f * ed, rewind_button->get_combined_minimum_size().x), 30.0f * ed); + rewind_button->set_position(Point2(get_size().x - button_size.x - 8.0f * ed, cell_height - button_size.y - 8.0f * ed)); + rewind_button->set_size(button_size); +} + +void SolersUserBubble::_gui_input(const Ref &p_event) { + const Ref mouse = p_event; + if (mouse.is_valid() && mouse->is_pressed() && mouse->get_button_index() == MouseButton::LEFT) { + _begin_edit(); + accept_event(); + } } void SolersUserBubble::set_message(const String &p_text) { @@ -271,6 +352,7 @@ void SolersUserBubble::_notification(int p_what) { switch (p_what) { case NOTIFICATION_RESIZED: { _shape(get_size().x); + _layout_editor(); } break; case NOTIFICATION_THEME_CHANGED: { shaped_for_width = -1.0f; @@ -278,6 +360,11 @@ void SolersUserBubble::_notification(int p_what) { queue_redraw(); } break; case NOTIFICATION_DRAW: { + if (editing) { + const float width = MIN(get_size().x, 440.0f * EDSCALE); + solers_cell_fill(this, Rect2(Point2(get_size().x - width, 0), Size2(width, cell_height)), SOLERS_CELL_BUBBLE_BG, 14.0f * EDSCALE); + break; + } if (text.is_empty() && attachment_textures.is_empty()) { break; } diff --git a/modules/solers_ai/editor/solers_chat_cells.h b/modules/solers_ai/editor/solers_chat_cells.h index 333632f85e..62d7fc8bee 100644 --- a/modules/solers_ai/editor/solers_chat_cells.h +++ b/modules/solers_ai/editor/solers_chat_cells.h @@ -38,6 +38,8 @@ class SolersMarkdownView; class TextParagraph; +class TextEdit; +class Button; class VBoxContainer; String solers_summarize_tool_args(const String &p_arguments_json); @@ -58,8 +60,18 @@ class SolersUserBubble : public Control { float cell_height = 0.0f; Callable content_changed; + Callable rewind_callback; + TextEdit *inline_editor = nullptr; + Button *rewind_button = nullptr; + bool editing = false; void _shape(float p_cell_width); + void _begin_edit(); + void _end_edit(); + void _cancel_edit_deferred(); + void _rewind_pressed(); + void _layout_editor(); + void _gui_input(const Ref &p_event); protected: void _notification(int p_what); @@ -71,6 +83,7 @@ class SolersUserBubble : public Control { void set_message(const String &p_text); void set_attachments(const Array &p_attachments); void set_content_changed_callback(const Callable &p_cb) { content_changed = p_cb; } + void set_rewind_callback(const Callable &p_cb) { rewind_callback = p_cb; } SolersUserBubble(); }; diff --git a/modules/solers_ai/editor/solers_dock.cpp b/modules/solers_ai/editor/solers_dock.cpp index 6ee37e11f7..62b2727feb 100644 --- a/modules/solers_ai/editor/solers_dock.cpp +++ b/modules/solers_ai/editor/solers_dock.cpp @@ -61,6 +61,7 @@ #include "scene/gui/line_edit.h" #include "scene/gui/margin_container.h" #include "scene/gui/panel_container.h" +#include "scene/gui/popup_menu.h" #include "scene/gui/scroll_bar.h" #include "scene/gui/scroll_container.h" #include "scene/gui/separator.h" @@ -611,7 +612,7 @@ void SolersDock::_on_cell_content_changed() { } } -Control *SolersDock::_append_user_message(const String &p_message, const Array &p_attachments) { +Control *SolersDock::_append_user_message(const String &p_message, const Array &p_attachments, int64_t p_event_id) { VBoxContainer *mount = _chat_mount(); if (!mount) { return nullptr; @@ -624,6 +625,11 @@ Control *SolersDock::_append_user_message(const String &p_message, const Array & mount->add_child(bubble); bubble->set_attachments(p_attachments); bubble->set_message(p_message); + if (p_event_id >= 0) { + bubble->connect(SceneStringName(gui_input), callable_mp(this, &SolersDock::_on_user_message_gui_input).bind(p_event_id)); + bubble->set_rewind_callback(callable_mp(this, &SolersDock::_on_user_message_rewind).bind(p_event_id)); + bubble->set_tooltip_text(TTR("Right-click for conversation actions")); + } if (!history_mount) { callable_mp(this, &SolersDock::_scroll_chat_to_bottom).call_deferred(); @@ -631,6 +637,67 @@ Control *SolersDock::_append_user_message(const String &p_message, const Array & return bubble; } +void SolersDock::_on_user_message_rewind(const String &p_text, int64_t p_event_id) { + if (!agent_session) { + return; + } + const Dictionary result = agent_session->rewind_to_event(p_event_id); + if (!(bool)result.get("ok", false)) { + _append_error_row(String(Dictionary(result.get("error", Dictionary())).get("message", TTR("Could not rewind conversation.")))); + return; + } + _reload_branched_session(result.get("data", Dictionary())); + chat_input->set_text(p_text); + chat_input->grab_focus(); + _update_chat_input_height(); + _update_send_enabled(); +} + +void SolersDock::_on_user_message_gui_input(const Ref &p_event, int64_t p_event_id) { + const Ref mouse = p_event; + if (mouse.is_null() || !mouse->is_pressed() || mouse->get_button_index() != MouseButton::RIGHT || !message_menu) { + return; + } + message_menu_event_id = p_event_id; + message_menu->set_position(DisplayServer::get_singleton()->mouse_get_position()); + message_menu->reset_size(); + message_menu->popup(); +} + +void SolersDock::_reload_branched_session(const Dictionary &p_data) { + if (!agent_session) { + return; + } + session_current_id = p_data.get("session_id", session_current_id); + load_chat_history(agent_session->get_timeline_entries()); + set_session_context(session_project_path, session_current_id); + notify_sessions_changed(); +} + +void SolersDock::_on_message_menu_id_pressed(int p_id) { + if (!agent_session || message_menu_event_id < 0) { + return; + } + const Dictionary result = p_id == 2 ? agent_session->rewind_to_event(message_menu_event_id) : agent_session->branch_from_event(message_menu_event_id); + if (!(bool)result.get("ok", false)) { + _append_error_row(String(Dictionary(result.get("error", Dictionary())).get("message", TTR("Conversation action failed.")))); + return; + } + const Dictionary result_data = result.get("data", Dictionary()); + _reload_branched_session(result_data); + const String prompt = SolersMention::strip_prompt_block(result_data.get("prompt", String())); + if (p_id == 0) { + _submit_chat_prompt(prompt); + } else { + chat_input->set_text(prompt); + chat_input->grab_focus(); + chat_input->set_caret_line(chat_input->get_line_count() - 1); + chat_input->set_caret_column(chat_input->get_line(chat_input->get_line_count() - 1).length()); + _update_chat_input_height(); + _update_send_enabled(); + } +} + void SolersDock::_append_error_row(const String &p_text) { VBoxContainer *mount = _chat_mount(); if (!mount) { @@ -1577,7 +1644,7 @@ void SolersDock::_append_history_message(const Dictionary &p_message) { return; } _settle_tool_group(); - _append_user_message(SolersMention::strip_prompt_block(content), p_message.get("attachments", Array())); + _append_user_message(SolersMention::strip_prompt_block(content), p_message.get("attachments", Array()), p_message.get("event_id", -1)); } else if (role == SolersLLMRole::ASSISTANT) { _settle_tool_group(); const String reasoning = String(p_message.get("reasoning", String())).strip_edges(); @@ -1650,7 +1717,15 @@ void SolersDock::_submit_chat_prompt(const String &p_prompt, const Array &p_atta _append_error_row(String::utf8("\u26a0 ") + String(error.get("message", "Could not start the agent turn."))); } else { if (user_row) { - user_row->set_meta("timeline_event_id", Dictionary(result.get("data", Dictionary())).get("event_id", -1)); + const int64_t event_id = Dictionary(result.get("data", Dictionary())).get("event_id", -1); + user_row->set_meta("timeline_event_id", event_id); + if (event_id >= 0) { + user_row->connect(SceneStringName(gui_input), callable_mp(this, &SolersDock::_on_user_message_gui_input).bind(event_id)); + if (SolersUserBubble *bubble = Object::cast_to(user_row)) { + bubble->set_rewind_callback(callable_mp(this, &SolersDock::_on_user_message_rewind).bind(event_id)); + } + user_row->set_tooltip_text(TTR("Right-click for conversation actions")); + } } timeline_messages = agent_session->get_timeline_entries(); session_current_id = agent_session->get_status().get("session_id", session_current_id); @@ -2460,6 +2535,14 @@ SolersDock::SolersDock() { set_v_size_flags(Control::SIZE_EXPAND_FILL); add_theme_style_override("panel", solers_make_stylebox(SOLERS_BG, Color(0, 0, 0, 0), 0, 0)); + message_menu = memnew(PopupMenu); + message_menu->add_item(TTR("Retry from here"), 0); + message_menu->add_item(TTR("Edit and retry"), 1); + message_menu->add_separator(); + message_menu->add_item(TTR("Rewind conversation and project changes"), 2); + message_menu->connect("id_pressed", callable_mp(this, &SolersDock::_on_message_menu_id_pressed)); + add_child(message_menu); + root_box = memnew(VBoxContainer); root_box->set_h_size_flags(Control::SIZE_EXPAND_FILL); root_box->set_v_size_flags(Control::SIZE_EXPAND_FILL); diff --git a/modules/solers_ai/editor/solers_dock.h b/modules/solers_ai/editor/solers_dock.h index 9c7e680d3e..8529cfab8a 100644 --- a/modules/solers_ai/editor/solers_dock.h +++ b/modules/solers_ai/editor/solers_dock.h @@ -47,6 +47,7 @@ class Label; class LineEdit; class MarginContainer; class PanelContainer; +class PopupMenu; class ScrollContainer; class TextEdit; class Texture2D; @@ -136,6 +137,8 @@ class SolersDock : public PanelContainer { VBoxContainer *active_assistant_row = nullptr; int64_t pending_assistant_event_id = -1; SolersStatusCell *status_cell = nullptr; + PopupMenu *message_menu = nullptr; + int64_t message_menu_event_id = -1; SolersToolGroupCell *active_tool_group = nullptr; HashMap tool_cells_by_id; SolersToolCell *last_started_tool_cell = nullptr; @@ -240,7 +243,11 @@ class SolersDock : public PanelContainer { void _scroll_chat_to_bottom(); void _clear_empty_state(); void _show_empty_state(); - Control *_append_user_message(const String &p_message, const Array &p_attachments = Array()); + Control *_append_user_message(const String &p_message, const Array &p_attachments = Array(), int64_t p_event_id = -1); + void _on_user_message_gui_input(const Ref &p_event, int64_t p_event_id); + void _on_user_message_rewind(const String &p_text, int64_t p_event_id); + void _on_message_menu_id_pressed(int p_id); + void _reload_branched_session(const Dictionary &p_data); void _append_error_row(const String &p_text); void _ensure_status_cell(const String &p_status); void _remove_status_cell(); diff --git a/modules/solers_ai/editor/solers_editor_plugin.cpp b/modules/solers_ai/editor/solers_editor_plugin.cpp index 9753d7f606..fe7248766c 100644 --- a/modules/solers_ai/editor/solers_editor_plugin.cpp +++ b/modules/solers_ai/editor/solers_editor_plugin.cpp @@ -38,6 +38,7 @@ #include "core/object/callable_mp.h" #include "core/os/os.h" #include "core/string/translation_server.h" +#include "editor/docks/editor_dock.h" #include "editor/docks/editor_dock_manager.h" #include "editor/editor_node.h" #include "editor/settings/editor_settings.h" @@ -47,8 +48,6 @@ #include "modules/solers_ai/editor/solers_ui_theme.h" #include "modules/solers_ai/generated/solers_translations.gen.h" -static constexpr int SOLERS_WORKSPACE_LAYOUT_VERSION = 1; - void solers_load_editor_translation() { Ref domain = TranslationServer::get_singleton()->get_editor_domain(); for (const EditorTranslationList *entry = _solers_translations; entry->data; entry++) { @@ -69,7 +68,6 @@ void solers_load_editor_translation() { return; } } - void SolersEditorPlugin::_select_session(const String &p_session_id) { runtime->set_session(project_path, p_session_id); dock->load_chat_history(runtime->get_timeline_entries()); @@ -97,17 +95,6 @@ void SolersEditorPlugin::_notification(int p_what) { } } -void SolersEditorPlugin::set_window_layout(Ref p_layout) { - if ((int)p_layout->get_value("Solers", "workspace_layout_version", 0) < SOLERS_WORKSPACE_LAYOUT_VERSION) { - EditorDockManager::get_singleton()->consolidate_vertical_docks(EditorDock::DOCK_SLOT_RIGHT_UL); - EditorNode::get_singleton()->save_editor_layout_delayed(); - } -} - -void SolersEditorPlugin::get_window_layout(Ref p_layout) { - p_layout->set_value("Solers", "workspace_layout_version", SOLERS_WORKSPACE_LAYOUT_VERSION); -} - SolersEditorPlugin::SolersEditorPlugin() { solers_load_editor_translation(); EditorSettings::get_singleton()->connect(SNAME("_translation_changed"), callable_mp(this, &SolersEditorPlugin::_translation_changed)); @@ -119,7 +106,13 @@ SolersEditorPlugin::SolersEditorPlugin() { dock->set_session_select_callback(callable_mp(this, &SolersEditorPlugin::_select_session)); dock->set_new_session_callback(callable_mp(this, &SolersEditorPlugin::_new_session)); runtime->bind_dock(dock); - add_control_to_container(CONTAINER_EDITOR_SIDE_LEFT, dock); + dock_host = memnew(EditorDock); + dock_host->set_title(TTR("Solers")); + dock_host->set_layout_key("SolersChat"); + dock_host->set_default_slot(EditorDock::DOCK_SLOT_LEFT_UL); + dock_host->set_available_layouts(EditorDock::DOCK_LAYOUT_ALL); + dock_host->add_child(dock); + add_dock(dock_host); const String session_id = OS::get_singleton()->get_environment("SOLERS_SESSION_ID"); if (session_id.is_empty()) { @@ -136,8 +129,12 @@ SolersEditorPlugin::SolersEditorPlugin() { SolersEditorPlugin::~SolersEditorPlugin() { memdelete(runtime); - if (dock && dock->get_parent()) { - remove_control_from_container(CONTAINER_EDITOR_SIDE_LEFT, dock); + if (dock_host) { + remove_dock(dock_host); + if (dock && dock->get_parent() == dock_host) { + dock_host->remove_child(dock); + } + memdelete(dock_host); } memdelete(dock); } diff --git a/modules/solers_ai/editor/solers_editor_plugin.h b/modules/solers_ai/editor/solers_editor_plugin.h index 17866a6467..173dbdbe57 100644 --- a/modules/solers_ai/editor/solers_editor_plugin.h +++ b/modules/solers_ai/editor/solers_editor_plugin.h @@ -34,6 +34,7 @@ class SolersAgentRuntime; class SolersDock; +class EditorDock; void solers_load_editor_translation(); @@ -42,6 +43,7 @@ class SolersEditorPlugin : public EditorPlugin { SolersAgentRuntime *runtime = nullptr; SolersDock *dock = nullptr; + EditorDock *dock_host = nullptr; String project_path; void _select_session(const String &p_session_id); @@ -53,8 +55,6 @@ class SolersEditorPlugin : public EditorPlugin { public: String get_plugin_name() const override { return "Solers"; } - void set_window_layout(Ref p_layout) override; - void get_window_layout(Ref p_layout) override; SolersEditorPlugin(); ~SolersEditorPlugin(); diff --git a/modules/solers_ai/editor/solers_pm_ai_view.cpp b/modules/solers_ai/editor/solers_pm_ai_view.cpp index e39f3ad68d..8d9d01d505 100644 --- a/modules/solers_ai/editor/solers_pm_ai_view.cpp +++ b/modules/solers_ai/editor/solers_pm_ai_view.cpp @@ -507,8 +507,12 @@ void SolersPMAIView::_refresh_form(bool p_load_stored) { api_key_label->show(); api_key_edit->set_editable(true); api_key_reveal->show(); + session_id_header_row->show(); + image_input_row->show(); if (_is_asset_provider(selected_provider)) { + session_id_header_row->hide(); + image_input_row->hide(); const Dictionary profile = _asset_plugin_profile(selected_provider); const String label = profile.get("label", selected_provider); const String default_base_url = profile.get("base_url", String()); @@ -539,6 +543,9 @@ void SolersPMAIView::_refresh_form(bool p_load_stored) { const Dictionary profile = registry->get_provider_profile(selected_provider); const Dictionary config = settings_service->get_provider_config_for(selected_provider).get("data", Dictionary()); + session_id_header_check->set_pressed_no_signal(config.get("send_session_id_header", true)); + const String image_mode = String(config.get("image_input_mode", "auto")); + image_input_option->select(image_mode == "enabled" ? 1 : (image_mode == "disabled" ? 2 : 0)); provider_title->set_text(TTRGET(String(profile.get("label", selected_provider)))); provider_notes->set_text(TTRGET(String(profile.get("notes", String())))); if (_uses_codex_auth(selected_provider)) { @@ -640,6 +647,32 @@ void SolersPMAIView::_on_field_changed(const String &p_ignored) { } } +void SolersPMAIView::_on_session_id_header_toggled(bool p_pressed) { + _on_field_changed(); + if (settings_service && _uses_codex_auth(selected_provider)) { + Dictionary config; + config["provider"] = selected_provider; + config["send_session_id_header"] = p_pressed; + settings_service->set_provider_config(config); + if (saved_feedback) { + saved_feedback->set_text(TTR("Saved")); + } + } +} + +void SolersPMAIView::_on_image_input_selected(int p_index) { + _on_field_changed(); + if (settings_service && _uses_codex_auth(selected_provider)) { + Dictionary config; + config["provider"] = selected_provider; + config["image_input_mode"] = p_index == 1 ? "enabled" : (p_index == 2 ? "disabled" : "auto"); + settings_service->set_provider_config(config); + if (saved_feedback) { + saved_feedback->set_text(TTR("Saved")); + } + } +} + void SolersPMAIView::_on_local_models_only_toggled(bool p_pressed) { if (settings_service) { settings_service->set_local_models_only(p_pressed); @@ -728,6 +761,9 @@ void SolersPMAIView::_save() { const String model = model_edit->get_text().strip_edges(); config["model"] = model.is_empty() ? String(profile.get("default_model", String())) : model; config["base_url"] = base_url_edit->get_text().strip_edges(); + config["send_session_id_header"] = session_id_header_check->is_pressed(); + const int image_mode_index = image_input_option->get_selected(); + config["image_input_mode"] = image_mode_index == 1 ? "enabled" : (image_mode_index == 2 ? "disabled" : "auto"); const String new_key = api_key_edit->get_text().strip_edges(); if (!new_key.is_empty()) { config["api_key"] = new_key; @@ -1215,6 +1251,36 @@ SolersPMAIView::SolersPMAIView() { key_row->add_child(api_key_reveal); } + session_id_header_row = memnew(HBoxContainer); + session_id_header_row->add_theme_constant_override("separation", 8 * EDSCALE); + form->add_child(session_id_header_row); + Label *session_id_header_label = memnew(Label(TTR("Session tracking"))); + session_id_header_label->add_theme_color_override(SceneStringName(font_color), Color(tokens.text.r, tokens.text.g, tokens.text.b, 0.72f)); + session_id_header_row->add_child(session_id_header_label); + session_id_header_check = memnew(CheckBox(TTR("Send session-id header"))); + session_id_header_check->set_h_size_flags(SIZE_EXPAND_FILL); + session_id_header_check->set_pressed(true); + session_id_header_check->set_tooltip_text(TTR("Attach the Solers session id to every LLM request for server-side tracing and routing.")); + session_id_header_check->set_accessibility_name(TTR("Send session-id header")); + session_id_header_check->connect(SceneStringName(toggled), callable_mp(this, &SolersPMAIView::_on_session_id_header_toggled)); + session_id_header_row->add_child(session_id_header_check); + + image_input_row = memnew(HBoxContainer); + image_input_row->add_theme_constant_override("separation", 8 * EDSCALE); + form->add_child(image_input_row); + Label *image_input_label = memnew(Label(TTR("Image input"))); + image_input_label->add_theme_color_override(SceneStringName(font_color), Color(tokens.text.r, tokens.text.g, tokens.text.b, 0.72f)); + image_input_row->add_child(image_input_label); + image_input_option = memnew(OptionButton); + image_input_option->set_h_size_flags(SIZE_EXPAND_FILL); + image_input_option->add_item(TTR("Auto-detect")); + image_input_option->add_item(TTR("Enabled")); + image_input_option->add_item(TTR("Disabled")); + image_input_option->set_tooltip_text(TTR("Auto sends images unless the model catalog explicitly marks them unsupported. Select Disabled if a compatible endpoint rejects multimodal content.")); + image_input_option->set_accessibility_name(TTR("Image input mode")); + image_input_option->connect(SceneStringName(item_selected), callable_mp(this, &SolersPMAIView::_on_image_input_selected)); + image_input_row->add_child(image_input_option); + oauth_box = memnew(VBoxContainer); oauth_box->add_theme_constant_override("separation", 10 * EDSCALE); oauth_box->hide(); diff --git a/modules/solers_ai/editor/solers_pm_ai_view.h b/modules/solers_ai/editor/solers_pm_ai_view.h index dd7470fd19..c771151618 100644 --- a/modules/solers_ai/editor/solers_pm_ai_view.h +++ b/modules/solers_ai/editor/solers_pm_ai_view.h @@ -62,6 +62,10 @@ class SolersPMAIView : public HBoxContainer { Label *api_key_label = nullptr; LineEdit *api_key_edit = nullptr; Button *api_key_reveal = nullptr; + HBoxContainer *session_id_header_row = nullptr; + CheckBox *session_id_header_check = nullptr; + HBoxContainer *image_input_row = nullptr; + OptionButton *image_input_option = nullptr; Label *env_hint = nullptr; VBoxContainer *oauth_box = nullptr; Label *oauth_status = nullptr; @@ -121,6 +125,8 @@ class SolersPMAIView : public HBoxContainer { void _refresh_status(); void _add_status_row(const String &p_text, const Color &p_dot_color); void _on_field_changed(const String &p_ignored = String()); + void _on_session_id_header_toggled(bool p_pressed); + void _on_image_input_selected(int p_index); void _on_local_models_only_toggled(bool p_pressed); void _on_reveal_toggled(bool p_pressed); void _on_codex_connect(); diff --git a/modules/solers_ai/llm/solers_llm_client.cpp b/modules/solers_ai/llm/solers_llm_client.cpp index 2c97809dad..adfb175a25 100644 --- a/modules/solers_ai/llm/solers_llm_client.cpp +++ b/modules/solers_ai/llm/solers_llm_client.cpp @@ -352,6 +352,20 @@ void SolersLLMClient::_publish(const Array &p_events, State p_state) { void SolersLLMClient::_run_worker() { Array batch; Array messages = SolersContextManager::repair_tool_pairing(worker_request.get("messages", Array())); + if (!(bool)worker_request.get("image_input_enabled", false)) { + for (int i = 0; i < messages.size(); i++) { + Dictionary message = messages[i]; + if (Array(message.get("attachments", Array())).is_empty()) { + continue; + } + message.erase("attachments"); + String content = message.get("content", String()); + content += content.is_empty() ? String() : "\n\n"; + content += "[Solers: image content was omitted because image input is disabled for the selected provider/model.]"; + message["content"] = content; + messages[i] = message; + } + } HashSet delivered; const Array delivered_identities = worker_request.get("_delivered_attachment_identities", Array()); for (int i = 0; i < delivered_identities.size(); i++) { @@ -360,6 +374,7 @@ void SolersLLMClient::_run_worker() { HashSet emitted; worker_request["messages"] = SolersLLMMessage::project_attachments(messages, delivered, emitted); worker_request.erase("_delivered_attachment_identities"); + worker_request.erase("image_input_enabled"); Array emitted_identities; for (const String &identity : emitted) { emitted_identities.push_back(identity); diff --git a/modules/solers_ai/llm/solers_llm_protocol.cpp b/modules/solers_ai/llm/solers_llm_protocol.cpp index 574e9c345c..3f706a093c 100644 --- a/modules/solers_ai/llm/solers_llm_protocol.cpp +++ b/modules/solers_ai/llm/solers_llm_protocol.cpp @@ -30,10 +30,21 @@ #include "solers_llm_protocol.h" +#include "core/variant/variant.h" #include "solers_protocol_anthropic_messages.h" #include "solers_protocol_openai_chat.h" #include "solers_protocol_openai_responses.h" +void SolersLLMProtocol::augment_headers(Dictionary &r_headers, const Dictionary &p_request) const { + if ((bool)p_request.get("send_session_id_header", true)) { + const String session_id = p_request.get("session_id", String()); + if (!session_id.is_empty()) { + r_headers["session-id"] = session_id; + } + } + augment_protocol_headers(r_headers, p_request); +} + void SolersLLMProtocolRegistry::register_protocol(SolersLLMProtocol *p_protocol) { ERR_FAIL_NULL(p_protocol); const StringName id = p_protocol->get_id(); diff --git a/modules/solers_ai/llm/solers_llm_protocol.h b/modules/solers_ai/llm/solers_llm_protocol.h index c1da1584d9..44e211432c 100644 --- a/modules/solers_ai/llm/solers_llm_protocol.h +++ b/modules/solers_ai/llm/solers_llm_protocol.h @@ -74,7 +74,11 @@ class SolersLLMProtocol { // Protocol-mandated headers independent of auth (e.g. anthropic-version). // Default: nothing to add. - virtual void augment_headers(Dictionary &r_headers, const Dictionary &p_request) const {} + // Common request metadata is applied here so every wire protocol observes + // the same provider policy. Protocol implementations only add their native + // headers through augment_protocol_headers(). + void augment_headers(Dictionary &r_headers, const Dictionary &p_request) const; + virtual void augment_protocol_headers(Dictionary &r_headers, const Dictionary &p_request) const {} // Seed the per-response streaming parser state for a fresh request. virtual Dictionary begin_stream(const Dictionary &p_request) const { return Dictionary(); } diff --git a/modules/solers_ai/llm/solers_protocol_anthropic_messages.cpp b/modules/solers_ai/llm/solers_protocol_anthropic_messages.cpp index cda76d0e8f..a1fd960a1b 100644 --- a/modules/solers_ai/llm/solers_protocol_anthropic_messages.cpp +++ b/modules/solers_ai/llm/solers_protocol_anthropic_messages.cpp @@ -243,7 +243,7 @@ Dictionary SolersAnthropicMessagesProtocol::build_request_body(const Dictionary return body; } -void SolersAnthropicMessagesProtocol::augment_headers(Dictionary &r_headers, const Dictionary &p_request) const { +void SolersAnthropicMessagesProtocol::augment_protocol_headers(Dictionary &r_headers, const Dictionary &p_request) const { r_headers["anthropic-version"] = "2023-06-01"; } diff --git a/modules/solers_ai/llm/solers_protocol_anthropic_messages.h b/modules/solers_ai/llm/solers_protocol_anthropic_messages.h index dc681f9568..b8e8e041c9 100644 --- a/modules/solers_ai/llm/solers_protocol_anthropic_messages.h +++ b/modules/solers_ai/llm/solers_protocol_anthropic_messages.h @@ -49,6 +49,6 @@ class SolersAnthropicMessagesProtocol : public SolersLLMProtocol { virtual StringName get_id() const override { return StringName("anthropic-messages"); } virtual String get_default_path() const override { return "/v1/messages"; } virtual Dictionary build_request_body(const Dictionary &p_request) const override; - virtual void augment_headers(Dictionary &r_headers, const Dictionary &p_request) const override; + virtual void augment_protocol_headers(Dictionary &r_headers, const Dictionary &p_request) const override; virtual Array parse_event(Dictionary &r_state, const String &p_event_name, const String &p_data) const override; }; diff --git a/modules/solers_ai/llm/solers_protocol_openai_responses.cpp b/modules/solers_ai/llm/solers_protocol_openai_responses.cpp index 89b923c9f9..f084d00bea 100644 --- a/modules/solers_ai/llm/solers_protocol_openai_responses.cpp +++ b/modules/solers_ai/llm/solers_protocol_openai_responses.cpp @@ -175,13 +175,6 @@ Dictionary SolersOpenAIResponsesProtocol::build_request_body(const Dictionary &p return body; } -void SolersOpenAIResponsesProtocol::augment_headers(Dictionary &r_headers, const Dictionary &p_request) const { - const String session_id = p_request.get("session_id", String()); - if (!session_id.is_empty()) { - r_headers["session-id"] = session_id; - } -} - Array SolersOpenAIResponsesProtocol::parse_event(Dictionary &r_state, const String &p_event_name, const String &p_data) const { Array events; const Variant parsed = JSON::parse_string(p_data.strip_edges()); diff --git a/modules/solers_ai/llm/solers_protocol_openai_responses.h b/modules/solers_ai/llm/solers_protocol_openai_responses.h index 3ff903e5b5..71f4f63cc6 100644 --- a/modules/solers_ai/llm/solers_protocol_openai_responses.h +++ b/modules/solers_ai/llm/solers_protocol_openai_responses.h @@ -21,6 +21,5 @@ class SolersOpenAIResponsesProtocol : public SolersLLMProtocol { virtual StringName get_id() const override { return StringName("openai-responses"); } virtual String get_default_path() const override { return "/responses"; } virtual Dictionary build_request_body(const Dictionary &p_request) const override; - virtual void augment_headers(Dictionary &r_headers, const Dictionary &p_request) const override; virtual Array parse_event(Dictionary &r_state, const String &p_event_name, const String &p_data) const override; };