From 8dad52f04027fdda095519127f59aec46f6e989e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 4 Jul 2026 12:03:58 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=94=BB=E5=B8=83Agent?= =?UTF-8?q?=E4=BE=A7=E8=BE=B9=E6=A0=8F=E8=81=8A=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增画布Agent会话契约、领域规则、SpacetimeDB元数据表和生成绑定。 新增api-server画布Agent会话CRUD、OSS消息文档读写、SSE消息流和生成工具编排。 新增前端右侧Agent对话面板、会话状态hook、SSE客户端、BFF客户端和相关测试。 调整画布侧栏、任务栏、小地图默认状态和面板互斥交互。 补齐画布Agent、OSS消息存储、SSE收口、后端契约和项目记忆文档。 --- CONTEXT.md | 12 + docs/README.md | 3 + ...ADR】画布Agent会话消息存OSS-2026-07-03.md | 35 + .../shared-memory/decision-log.md | 8 + .../shared-memory/document-map.md | 3 +- ...架构】SSE客户端传输层收口约定-2026-06-03.md | 16 + ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 16 + ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 27 +- ...】生成类面板Lovart统一改造方案-2026-06-17.md | 1 + .../【编辑器】画布Agent对话面板-2026-07-03.md | 123 + packages/shared/src/contracts/editorAgent.ts | 168 ++ packages/shared/src/contracts/index.ts | 1 + server-rs/Cargo.lock | 12 + server-rs/Cargo.toml | 2 + server-rs/crates/api-server/Cargo.toml | 1 + server-rs/crates/api-server/src/assets.rs | 8 + .../crates/api-server/src/editor_agent.rs | 1998 +++++++++++++++++ .../api-server/src/jump_hop_atlas_slicing.rs | 373 +-- server-rs/crates/api-server/src/main.rs | 1 + .../api-server/src/modules/editor_project.rs | 33 + .../crates/api-server/src/runtime_profile.rs | 44 +- .../crates/module-editor-agent/Cargo.toml | 15 + .../module-editor-agent/src/application.rs | 127 ++ .../module-editor-agent/src/commands.rs | 44 + .../crates/module-editor-agent/src/domain.rs | 57 + .../crates/module-editor-agent/src/errors.rs | 33 + .../crates/module-editor-agent/src/lib.rs | 9 + server-rs/crates/platform-oss/README.md | 7 + server-rs/crates/platform-oss/src/lib.rs | 120 +- .../shared-contracts/src/editor_agent.rs | 384 ++++ server-rs/crates/shared-contracts/src/lib.rs | 1 + .../spacetime-client/src/editor_agent.rs | 136 ++ server-rs/crates/spacetime-client/src/lib.rs | 18 +- .../crates/spacetime-client/src/mapper.rs | 9 + .../src/mapper/editor_agent.rs | 122 + .../src/mapper/runtime_profile.rs | 32 +- .../spacetime-client/src/module_bindings.rs | 56 +- ...agent_conversation_and_return_procedure.rs | 62 + ...agent_conversation_and_return_procedure.rs | 62 + ...or_agent_conversation_create_input_type.rs | 20 + ...or_agent_conversation_delete_input_type.rs | 17 + ...ditor_agent_conversation_get_input_type.rs | 16 + ...itor_agent_conversation_list_input_type.rs | 16 + ...conversation_list_procedure_result_type.rs | 19 + ...gent_conversation_procedure_result_type.rs | 19 + ...editor_agent_conversation_snapshot_type.rs | 22 + .../editor_agent_conversation_table.rs | 162 ++ ...tor_agent_conversation_touch_input_type.rs | 18 + .../editor_agent_conversation_type.rs | 77 + ...agent_conversation_and_return_procedure.rs | 59 + ...gent_conversations_and_return_procedure.rs | 59 + ...agent_conversation_and_return_procedure.rs | 59 + server-rs/crates/spacetime-module/Cargo.toml | 1 + .../src/editor_agent_storage.rs | 320 +++ .../src/editor_project_storage.rs | 39 +- server-rs/crates/spacetime-module/src/lib.rs | 2 + .../crates/spacetime-module/src/migration.rs | 1 + .../spacetime-module/src/runtime/profile.rs | 32 +- .../EditorAgentConversationPanelView.test.tsx | 281 +++ .../EditorAgentConversationPanelView.tsx | 721 ++++++ .../ImageCanvasEditorShellView.test.tsx | 3 + .../ImageCanvasEditorView.test.tsx | 298 ++- .../image-editor/ImageCanvasEditorView.tsx | 113 +- .../ImageCanvasPanelDockView.test.tsx | 24 +- .../image-editor/ImageCanvasPanelDockView.tsx | 27 +- .../ImageCanvasSidebarView.test.tsx | 16 +- .../image-editor/ImageCanvasStageView.tsx | 23 +- .../useEditorAgentConversation.test.tsx | 354 +++ .../useEditorAgentConversation.ts | 651 ++++++ .../useImageCanvasEditorChrome.test.tsx | 5 +- .../useImageCanvasEditorChrome.ts | 9 +- .../image-editor/editorAgentClient.test.ts | 169 ++ .../image-editor/editorAgentClient.ts | 132 ++ .../image-editor/editorAgentSse.test.ts | 88 + src/services/image-editor/editorAgentSse.ts | 70 + 75 files changed, 7836 insertions(+), 285 deletions(-) create mode 100644 docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md create mode 100644 docs/【编辑器】画布Agent对话面板-2026-07-03.md create mode 100644 packages/shared/src/contracts/editorAgent.ts create mode 100644 server-rs/crates/api-server/src/editor_agent.rs create mode 100644 server-rs/crates/module-editor-agent/Cargo.toml create mode 100644 server-rs/crates/module-editor-agent/src/application.rs create mode 100644 server-rs/crates/module-editor-agent/src/commands.rs create mode 100644 server-rs/crates/module-editor-agent/src/domain.rs create mode 100644 server-rs/crates/module-editor-agent/src/errors.rs create mode 100644 server-rs/crates/module-editor-agent/src/lib.rs create mode 100644 server-rs/crates/shared-contracts/src/editor_agent.rs create mode 100644 server-rs/crates/spacetime-client/src/editor_agent.rs create mode 100644 server-rs/crates/spacetime-client/src/mapper/editor_agent.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_create_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_delete_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_procedure_result_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_snapshot_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_table.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_touch_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs create mode 100644 server-rs/crates/spacetime-module/src/editor_agent_storage.rs create mode 100644 src/components/image-editor/EditorAgentConversationPanelView.test.tsx create mode 100644 src/components/image-editor/EditorAgentConversationPanelView.tsx create mode 100644 src/components/image-editor/useEditorAgentConversation.test.tsx create mode 100644 src/components/image-editor/useEditorAgentConversation.ts create mode 100644 src/services/image-editor/editorAgentClient.test.ts create mode 100644 src/services/image-editor/editorAgentClient.ts create mode 100644 src/services/image-editor/editorAgentSse.test.ts create mode 100644 src/services/image-editor/editorAgentSse.ts diff --git a/CONTEXT.md b/CONTEXT.md index 93a8f0dda..e5467abc7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -16,6 +16,18 @@ _Avoid_: 在玩法页面内手写上传、参考图、重绘、预览、删除 独立 `/editor` 中可保存、恢复和继续编辑的图片画布工作状态,包含画布视图、图层布局和资源引用;用于多图对比、生成结果衍生和画布级编辑,不替代玩法页面内的单图资产编辑。 _Avoid_: 玩法结果页单图槽位、发布态作品、只存在前端内存里的临时画布 +**画布Agent对话**: +图片画布工程右侧的对话式编辑器工具,用户通过自然语言调度画布已有的图片类生成与编辑能力(生成图片、生成角色形象、生成图标素材、生成 UI 设计图、基于附件的图片修改),并可附加画布素材或素材库图片作为参考;对话归属单个图片画布工程,可保存历史、新开会话和软删会话。属于画布域工具,不承接玩法创作、不产出玩法作品或模板,与「表单/图片输入创作工作台」的 Avoid 边界不冲突。 +_Avoid_: 对话式玩法创作工作台、绕过模型定价收口的生成入口、把对话消息当作画布布局真相、复用拼图专用 creative-agent 内存会话 + +**画布Agent会话记录**: +画布Agent对话的持久化形态:SpacetimeDB 表只存会话元数据(会话 ID、所属工程、属主、标题、软删标记、聊天记录 OSS 对象引用、时间戳),完整消息内容以会话粒度 JSON 对象存 OSS,追加消息即整体重写对象。 +_Avoid_: api-server 内存会话、消息全文入 SpacetimeDB 表、对话混入工程布局快照、每条消息一个 OSS 对象 + +**画布Agent对话附件**: +画布Agent对话消息携带的图片参考,统一为画布资源 / 素材库对象引用(resourceId / assetId + 可选 objectKey),单条消息上限 9 张;上传图片若从对话入口进入,必须复用素材库 / 画布资源登记链路,在上传格未落地前只从已有画布资源和账号素材库选择,不存在只属于对话的第三种图。 +_Avoid_: 对话私有图片副本、内嵌 base64 附件、音视频附件 + **画布资源**: 图片画布工程中可被一个或多个图层引用的图片资源记录,保存 OSS 对象引用、上传 / 生成来源、提示词、模型、任务和尺寸等资源元数据;同一资源可以在工程布局中出现多次。 _Avoid_: 图层位置、前端 hover / selected 状态、直接内嵌图片二进制 diff --git a/docs/README.md b/docs/README.md index 0ceca3f52..f30c79873 100644 --- a/docs/README.md +++ b/docs/README.md @@ -9,6 +9,7 @@ - [审计与复盘](./audits/README.md):工程审查、文本/乱码审计、专项落地审计。 - [系统设计](./design/README.md):玩法、关系、物品与对话设计。 - [技术方案](./technical/README.md):动画、服务端、外部产品形态拆解。 +- [架构决策](./adr/):记录已经接受的跨模块、长期有效架构取舍;对应长期摘要仍应同步到 `docs/project-memory/shared-memory/decision-log.md`。 - [规划与优先级](./planning/README.md):当前阶段的迭代排序与落地优先级;创作流程统一总计划见 [【玩法创作】创作流程统一总计划-2026-05-30.md](./planning/%E3%80%90%E7%8E%A9%E6%B3%95%E5%88%9B%E4%BD%9C%E3%80%91%E5%88%9B%E4%BD%9C%E6%B5%81%E7%A8%8B%E7%BB%9F%E4%B8%80%E6%80%BB%E8%AE%A1%E5%88%92-2026-05-30.md)。 - [参考目录](./reference/README.md):脚本/Function 速查入口。 重点补充:RPG 创作与运行时脚本职责地图见 [RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md](./reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md)。 @@ -32,6 +33,8 @@ Expo React Native 移动壳和 Tauri 桌面壳的工程结构、同源 WebView `/editor/canvas` 图片画布编辑器的画布素材 ZIP 导出能力,入口放在右上角标题栏下载图标内,采用前端 JSZip 打包画布中有效图层引用的上传图、生成图、修改结果和角色动作序列帧;动作图层右键“导出为”提供序列帧 ZIP(含前端生成的 `preview.gif`)与 Spine JSON ZIP 两个二级选项,方案见 [【前端架构】图片画布素材导出方案-2026-06-15.md](./technical/【前端架构】图片画布素材导出方案-2026-06-15.md)。 +`/editor/canvas` 右侧画布 Agent 对话面板、会话持久化、SSE 事件、附件与生成落画板例外见 [【编辑器】画布Agent对话面板-2026-07-03.md](./【编辑器】画布Agent对话面板-2026-07-03.md);消息正文存 OSS、元数据进 SpacetimeDB 的取舍见 [【ADR】画布Agent会话消息存OSS-2026-07-03.md](./adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md)。 + 图片画布生成类面板的模型泥点默认 JSON、运行时 override、后台“模型定价”页面和主站动态下发口径见 [【编辑器】模型定价配置管理方案-2026-06-22.md](./%E3%80%90%E7%BC%96%E8%BE%91%E5%99%A8%E3%80%91%E6%A8%A1%E5%9E%8B%E5%AE%9A%E4%BB%B7%E9%85%8D%E7%BD%AE%E7%AE%A1%E7%90%86%E6%96%B9%E6%A1%88-2026-06-22.md)。 React 组件测试的用户行为、稳定契约、hook / model 分层断言口径,以及避免内部 DOM 探针、图标 class 和完整对象快照式断言的规则见 [【前端测试】React组件测试准则-2026-06-26.md](./technical/%E3%80%90%E5%89%8D%E7%AB%AF%E6%B5%8B%E8%AF%95%E3%80%91React%E7%BB%84%E4%BB%B6%E6%B5%8B%E8%AF%95%E5%87%86%E5%88%99-2026-06-26.md)。 diff --git a/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md b/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md new file mode 100644 index 000000000..194a23ea0 --- /dev/null +++ b/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md @@ -0,0 +1,35 @@ +# 【ADR】画布Agent会话消息存OSS-2026-07-03 + +状态:已接受 + +## 背景 + +画布Agent对话需要保存历史记录并支持新开会话。消息正文是流式追加、体量随对话增长的 JSON 数据;SpacetimeDB 是后端唯一结构化真相存储,但表行不适合承载不断增长的长文本;画布工程快照已有独立保存链路,且对话流式更新频率远高于布局保存。 + +## 决策 + +- SpacetimeDB 表 `editor_agent_conversation` 只存会话元数据:会话 ID、projectId、ownerUserId、标题、软删标记、聊天记录 OSS 对象引用、时间戳。 +- 完整消息内容以会话粒度 JSON 对象存 OSS(`editor-agent/{conversationId}.json`),追加消息即整体重写对象。 +- `editor-agent/` 是服务端内部消息文档前缀,只保存 `editor-agent/{conversationId}.json` 形态的会话级 JSON 文档;它不是浏览器直传前缀,也不是公开 generated 资源前缀。 +- 浏览器禁止直接上传、覆盖或签名写入 `editor-agent/` 对象;前端只通过 `api-server` 的会话接口创建会话、发送消息、读取历史,OSS 读写由服务端完成。 +- 消息文档序列化后的读写上限为 2 MiB;超过上限时后端拒绝继续读写该会话消息文档,并返回 payload too large 语义错误。 +- 同一会话内的消息追加采用 `conversationId` 级串行锁,避免同一会话的“读-改-写”整对象过程互相覆盖。 +- Agent 工具调用失败时,仍保留 assistant 消息和 `failed` generation record,包括 tool call、模型、错误信息等排障字段;失败记录是会话历史的一部分,不因 SSE 已返回错误而丢弃。 +- 不把消息明细写入 SpacetimeDB 表,不把对话混入画布工程快照,不在 api-server 内存中保存会话真相。 + +## 备选方案与取舍 + +1. **独立 message 表(逐条入 SpacetimeDB)**:查询灵活,但消息文本长、附件结构嵌套,行数与行体积随聊天无界增长,挤占 SpacetimeDB 订阅与快照成本;对话消息没有跨会话结构化查询需求,放表里收益低。 +2. **消息塞进画布工程快照 payload**:省一张表,但每条消息都会触发整个工程快照保存,与画布布局保存互相竞争,流式期间冲突概率高。 +3. **每条消息一个 OSS 对象**:追加成本最低,但加载历史需要 N 次取对象或额外清单维护;对话消息量级小,整体读写实现最简单。 + +选择"表存引用 + OSS 存整段 JSON":与仓库既有「画布资源」「敲击音效」等 OSS 对象引用模式一致,加载历史一次取对象即可。 + +## 影响 + +- 消息写入是"读-改-写"整对象;当前由 `api-server` 按 `conversationId` 做会话内串行化,保证同一进程内同一会话不会并发覆写。若未来横向多实例部署,需要补充分布式锁、对象版本条件写或等价的跨实例并发控制。 +- 会话表仍只保存元数据和 `messagesObjectKey`;API 可以在读取时把 OSS 消息文档拼装为会话详情返回,但完整消息正文的持久化真相仍是 OSS JSON 文档。 +- 消息文档最大 2 MiB;该限制用于阻止单个会话无限增长。后续如果需要更长历史,应引入归档、分页对象或摘要压缩,不应把正文回填进 SpacetimeDB 表。 +- 会话软删只打表标记,OSS 对象保留,便于恢复与审计。 +- 工具生成失败也会写入消息文档:assistant 消息可标记为失败 / 错误类型,`generations` 中保留 `failed` record、模型和错误信息,便于用户回看失败原因和后续排障。 +- 若未来出现跨会话消息检索需求,需另建投影或索引,不回退为消息入表。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6a3f2b474..f664b2658 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-03 画布Agent会话元数据入 SpacetimeDB、消息正文存 OSS + +- 背景:图片画布工程需要对话式编辑历史,但消息正文随流式输出增长,不适合放入表行或画布布局快照;同时画布 Agent 只属于编辑器画布域,不能复用拼图 `creative-agent` 内存会话。 +- 决策:新增 `module-editor-agent` 承载纯领域规则,`editor_agent_conversation` 只保存会话元数据,完整消息以 `editor-agent/{conversationId}.json` 会话粒度存 OSS;`api-server` 负责编排 LLM、SSE、OSS 读写和既有生成工具调用。 +- 影响范围:图片画布右侧 Agent 面板、`shared-contracts` / `packages/shared` 的 `editorAgent` 契约、`spacetime-module` / `spacetime-client`、`platform-oss` 内部读签名边界、画布生成落板规则。 +- 验证方式:`npm run spacetime:generate`、`npm run check:spacetime-schema`、`cargo test -p module-editor-agent --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、前端 Agent 面板与 SSE client 定向测试、`npm run check:encoding`、`git diff --check`。 +- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 + ## 2026-07-01 认证工作集只经 typed projection 同步正式表 - 背景:同手机号重复账号、兑换码白名单错配和微信资料不回写暴露出 `module-auth` 内存工作集、`auth_store_snapshot` 和正式认证表之间仍有历史互刷路径;旧 JSON 快照会把过期手机号索引或用户资料重新带回运行态。 diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md index ea8afce2b..974e0d77c 100644 --- a/docs/project-memory/shared-memory/document-map.md +++ b/docs/project-memory/shared-memory/document-map.md @@ -1,6 +1,6 @@ # 文档地图与阅读索引 -更新时间:`2026-06-22` +更新时间:`2026-07-03` ## 当前文档入口 @@ -18,6 +18,7 @@ | 微信小程序虚拟支付 | `docs/【技术方案】微信虚拟支付接入-2026-05-26.md` | | UI 像素资产与 9-slice 规范 | `UI_CODING_STANDARD.md` | | 图片画布生成面板与模型定价 | `docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【编辑器】模型定价配置管理方案-2026-06-22.md` | +| 图片画布右侧 Agent 对话、会话消息 OSS 持久化、SSE 事件契约、与任务侧栏 / 左侧栏互斥规则 | `docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md` | ## 阅读顺序 diff --git a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md index de90be6b3..567120384 100644 --- a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md +++ b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md @@ -13,6 +13,7 @@ - `readSseStream` 负责读取 `Response.body`、识别 `\n\n` 与 `\r\n\r\n` 事件边界、合并多行 `data:`、flush `TextDecoder` 尾部缓冲,并支持事件处理函数返回 `false` 后取消 reader。 - `readSseJsonStream` 只在传输事件基础上解析 JSON object,空 data 与异常 JSON 继续按旧口径静默跳过。 - 各业务 client 只保留领域事件归一化、最终结果聚合和中文错误文案,不再重复实现 SSE 边界扫描、reader 循环或 UTF-8 flush。 +- `src/services/image-editor/editorAgentSse.ts` 消费画布 Agent 的 JSON SSE,只校验并归一 `stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done` 事件,不持有 reader 循环或事件边界解析。 - OpenAI 兼容流、`[DONE]` 哨兵或其它非 JSON SSE 可直接使用 `readSseStream`;业务 JSON 事件优先使用 `readSseJsonStream`。 ## 落地范围 @@ -25,12 +26,27 @@ - `src/services/visual-novel-runtime/visualNovelRuntimeSse.ts` - `src/services/rpg-entry/rpgProfileClient.ts` - `src/services/llmClient.ts` +- `src/services/image-editor/editorAgentSse.ts` 后续新增 SSE client 时不得复制 `findSseEventBoundary`、`parseSseEventBlock` 或手写 reader 循环;若确实需要特殊 framing,应先扩展 `sseStream.ts` 的传输能力,再在业务 client 中处理领域语义。 +## 画布 Agent SSE client 口径 + +`src/services/image-editor/editorAgentSse.ts` 是画布 Agent 的业务事件归一层,只允许做以下事情: + +- 调用 `readSseJsonStream(response, handler)` 读取 JSON SSE。 +- 白名单识别 `stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done`。 +- 支持两种兼容输入形态:优先使用 SSE `event:` 字段作为事件名;若 `data` 内含 `{ "event": "...", "data": {...} }`,则归一为同一 `EditorAgentSseEvent`。 +- 对未知事件名、缺失 `data` 或非对象 JSON 静默跳过,保持业务 client 的容错口径。 + +`editorAgentSse.ts` 不得实现 `Response.body.getReader()`、`TextDecoder`、SSE 边界扫描、JSON 字符串拼接、fetch、鉴权、错误文案、会话状态聚合或画布聚焦逻辑;这些职责分别留在 `sseStream.ts`、`editorAgentClient.ts`、`useEditorAgentConversation.ts` 和画布视图层。 + ## 验收 - `src/services/sseStream.test.ts` 覆盖 CRLF / LF 边界、UTF-8 尾部 flush、异常 JSON 跳过和提前停止取消 reader。 - `src/services/llmClient.test.ts` 覆盖 OpenAI 兼容文本流、异常 JSON 跳过和 `[DONE]` 后提前停止。 +- `src/services/image-editor/editorAgentSse.test.ts` 覆盖画布 Agent 的 `stage`、`generation_result`、`done` 事件解析,以及 `{ event, data }` fallback 形态归一。 +- `src/services/image-editor/editorAgentClient.test.ts` 覆盖会话 CRUD 路由和 `/messages/stream` 发起后能收到类型化 SSE 事件。 +- 后续修改 `editorAgentSse.ts` 时,若出现手写 reader、`TextDecoder`、SSE 边界扫描或 fetch 逻辑,应视为违反本收口约定。 - 已有 OpenAI 兼容文本流、NPC 聊天流、创作 Agent、创意互动 Agent、视觉小说运行态和充值订单状态测试继续通过。 - `npm run typecheck` 不产生新的类型错误。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index fc8fffb8f..3789f3fb3 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -25,6 +25,7 @@ - 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并显示错误。 - 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。 - 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。 +- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板与素材侧栏、图层侧栏、右上角任务侧栏互斥,打开 Agent 时必须收起其它侧栏,打开其它侧栏或任务侧栏时也必须收起 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 ## 交互规则 @@ -53,6 +54,8 @@ - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId` 或 `assetId` 写入参考图临时状态,生成请求仍使用临时状态中的图片源或 `objectKey`。 - 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口所需的图片 Data URL、signed URL 或 `objectKey` 只存在于提交前的临时参考图状态和请求体字段,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。 - 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面并写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。 +- 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和 SSE 最终写回由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 +- Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 - 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存快照必须排除 `data:*` / `blob:` 内联媒体,且在后端项目快照返回前不得触发自动保存。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 @@ -67,6 +70,11 @@ - `PATCH /api/editor/projects/{projectId}/metadata`:重命名指定工程。 - `DELETE /api/editor/projects/{projectId}`:删除指定工程,并级联删除默认画布和资源元数据。 - `POST /api/editor/projects/{projectId}/resources`:创建画布资源记录,接收上传资源或真实生成资源元数据。 +- `GET /api/editor/projects/{projectId}/agent-conversations`:读取当前工程的画布 Agent 会话列表,按更新时间倒序返回会话摘要。 +- `POST /api/editor/projects/{projectId}/agent-conversations`:在当前工程下创建画布 Agent 会话;可选传入标题,默认标题为“新对话”。 +- `GET /api/editor/agent-conversations/{conversationId}`:读取指定画布 Agent 会话详情,返回会话摘要和 OSS 消息正文中的消息列表。 +- `DELETE /api/editor/agent-conversations/{conversationId}`:软删除指定画布 Agent 会话,并返回删除后的会话摘要。 +- `POST /api/editor/agent-conversations/{conversationId}/messages/stream`:发送画布 Agent 消息并返回 JSON SSE。请求体包含 `clientMessageId`、`text` 和可选 `attachments`;文本与附件不可同时为空。SSE 事件名固定为 `stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done`。生成结果中的图片通过后端写入 project resource / 账号素材后返回轻量快照,前端只消费返回的资源引用并刷新 / 聚焦画布。工具失败时也必须保留失败 generation record,不得只返回一次性错误。 - `GET /api/editor/assets/library`:读取当前账号的素材文件夹和素材。首次读取时自动创建“项目素材”默认文件夹。 - `POST /api/editor/assets/folders`:新建素材文件夹。 - `PATCH /api/editor/assets/folders/{folderId}`:重命名、折叠 / 展开素材文件夹。 @@ -90,6 +98,8 @@ - 示例素材可继续复用 `public/creation-type-references/` 下的站内图片;用户上传和后续生成资源必须通过资源记录表达。 - 不把 hover、dragging、临时吸附线、Space 临时抓手等瞬时 UI 状态写入后端。 - 不在 UI 中加入大段功能说明,编辑器界面只展示必要的工具、素材和状态信息。 +- 画布 Agent 前端分层固定为:`editorAgentClient.ts` 负责 BFF 请求、鉴权错误和 stream 发起;`editorAgentSse.ts` 只负责 SSE JSON 事件归一;`useEditorAgentConversation.ts` 负责会话列表、当前会话、消息流、阶段和停止状态;`EditorAgentConversationPanelView.tsx` 只负责右侧面板展示与交互。 +- `editorAgentSse.ts` 必须复用 `src/services/sseStream.ts` 的 `readSseJsonStream`,不得复制 SSE reader、`TextDecoder`、事件边界扫描或 UTF-8 flush 逻辑;该文件不得承接 fetch、鉴权、会话聚合、画布聚焦或 UI 错误文案。 - 不复用或改写 `CreativeImageInputPanel` 的单图资产编辑语义;`/editor/canvas` 是独立图片画布工程的画布入口。 ## 验收用例 @@ -109,6 +119,12 @@ - 快速编辑面板底部只显示模型选择和 `修改` 按钮;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图或红框序号标注图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户填写的快速编辑提示词。 - 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。 - 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。 +- 点击底部 Dock 的“画布 Agent”后,右侧独立 Agent 面板打开;素材 / 图层侧栏和任务侧栏被收起。再次点击或点击面板关闭按钮后收起 Agent。打开素材 / 图层侧栏或任务侧栏时,Agent 面板同步关闭。 +- Agent 面板能读取当前工程会话列表;无历史会话时发送第一条消息会先创建“新对话”。支持新建会话、切换会话和删除当前会话;删除必须通过独立确认弹窗完成,不能在面板下方追加确认内容。 +- Agent 输入支持文本消息、附件消息和纯附件消息;附件选择弹窗可在“画布 / 素材库”之间切换,只展示图片类资源,最多选择 9 张。 +- 发送消息后,面板展示用户消息、Agent 阶段状态和 SSE 增量回复;`stage/message_delta/tool_started/tool_completed/generation_result/error/done` 都能被正确渲染。流式响应中点击“停止”会中断当前请求,并把仍在 streaming / generating 的消息标记为停止态。 +- Agent 返回生成结果缩略图后,点击缩略图应优先聚焦当前画布中已有 `resourceId` 对应图层;如果当前内存布局尚未包含该资源,则重新读取工程快照,应用后再聚焦新图层。对话入口触发生成时不创建“即将生成”画布占位;生成中状态只显示在消息流,生成完成后通过后端 `canvasCompletion` 落新图层。工具失败时消息内必须保留失败 generation record 和错误气泡,不能只弹一次性 toast。 +- 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。 - 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 代理远端 BiRefNet 服务并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和提示词 `仅提取被红色框框选的素材并整理成spritesheet,图集背景必须使用单一纯绿色 #00FF00 / RGB(0,255,0) 绿幕。绿幕背景必须平整无纹理、无渐变、无阴影、无地面、无环境、无道具,方便后续扣除背景;素材自身不要出现绿色描边、绿色底板、绿色投影或绿色反光。`,生成的 spritesheet 原图和拆分后的独立素材都作为画布图层保留。 - 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。 - 快速编辑 / 重绘站内 public 示例图、历史 generated 图或 OSS generated 图时,前端先读取成 `data:image/*;base64,...` 再提交,后端不得再收到 `/creation-type-references/*`、`/generated-*` 或 OSS URL 作为 `referenceImageSrcs/sourceImageSrc`。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index dbf566438..d8e14e27b 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -1,6 +1,6 @@ # server-rs 与 SpacetimeDB 数据契约 -更新时间:`2026-07-01` +更新时间:`2026-07-03` ## 后端主线 @@ -21,7 +21,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk` 当前主要 crate: - HTTP 服务:`api-server`。 -- 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。 +- 领域模块:`module-ai`、`module-assets`、`module-auth`、`module-bark-battle`、`module-big-fish`、`module-combat`、`module-creative-agent`、`module-editor-agent`、`module-custom-world`、`module-inventory`、`module-match3d`、`module-npc`、`module-progression`、`module-puzzle`、`module-quest`、`module-runtime`、`module-runtime-item`、`module-runtime-story`、`module-square-hole`、`module-story`、`module-visual-novel`。 - 平台副作用:`platform-agent`、`platform-auth`、`platform-image`、`platform-llm`、`platform-oss`、`platform-wechat`、`platform-speech`。 - 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。 - SpacetimeDB:`spacetime-client`、`spacetime-module`。 @@ -60,7 +60,7 @@ npm run check:server-rs-ddd - 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。 - 资产基础能力:`/api/assets/direct-upload-tickets`、`/api/assets/sts-upload-credentials`、`/api/assets/objects/*`、`/api/assets/read-*`,负责直传、确认、绑定和读取。 - 外部 OpenAPI:`/api/external/v1/openapi.json`、`/api/external/v1/assets/direct-upload-tickets`、`/api/external/v1/assets/objects/confirm`、`/api/external/v1/assets/read-url`、`/api/external/v1/editor/*`,使用 Bearer API Key 鉴权;API Key 管理仍在登录态 `/api/profile/api-keys`,不进入外部 OpenAPI JSON。 -- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。 +- 创作 / 游玩支撑能力:`/api/creation-entry/config`、`/api/ai/tasks*`、`/api/runtime/chat/*`、`/api/runtime/settings`、`/api/runtime/save/snapshot`、`/api/profile/browse-history`、`/api/profile/save-archives*`、`/api/profile/play-stats`、`/api/assets/history`、`/api/assets/character-visual/*`、`/api/assets/character-animation/*`、`/api/assets/character-workflow-cache*`、`/api/assets/hyper3d/*`、`/api/runtime/custom-world/asset-studio/*`、`/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*`。`/api/runtime/custom-world/asset-studio/*` 解析默认角色形象 / 动作提示词时可以在 OSS 缓存不可用或未配置时按无缓存返回默认提示;保存 workflow 缓存和真实素材读写仍必须要求 OSS 正常可用。 - 后台入口配置:`/admin/api/creation-entry/config`、`/admin/api/creation-entry/config/banners` 和 `/admin/api/creation-entry/config/interactions`。 - 自定义世界 / RPG:`/api/runtime/custom-world*`、`/api/story/*`、`/api/runtime/chat/*`。 - 拼图:`/api/runtime/puzzle/*`。 @@ -75,6 +75,15 @@ npm run check:server-rs-ddd 需要新增路由时,先确认玩法入口配置和 tracking 分类,不要绕过 `app.rs` 的统一中间件、鉴权和入口开关。涉及创作、生成、作品、公开详情、试玩、正式运行态、运行态库存、运行态设置 / 存档、游玩历史、存档归档、游玩统计、AI task、角色资产工坊或玩法生成支撑资产的路由,不再直接在 `app.rs` 逐玩法 `.merge(...)`,也不挂到 `modules/platform.rs`;必须先进入 `server-rs/crates/api-server/src/modules/play_flow.rs` 的统一玩法流程主干,再由主干注册表分发到各领域 HTTP Adapter 或支撑能力 handler。 +### 图片画布 Agent 对话 + +- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取和软删;`/api/editor/agent-conversations/{conversationId}/messages/stream` 负责发送消息并返回 SSE。 +- `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。 +- `spacetime-module` 的 `editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。 +- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、SSE 事件持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。 +- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。 +- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。 + ### 创作 / 游玩统一流程主干 `modules/play_flow.rs` 是后端创作与游玩流程的统一入口。现有外部 URL、DTO、错误 envelope、鉴权方式、入口开关语义和 SpacetimeDB schema 默认不变,但路由组织必须遵循: @@ -214,7 +223,7 @@ npm run check:server-rs-ddd - 敲木鱼敲击物和背景环境图:VectorEngine `/v1/images/edits`,模型固定 `gpt-image-2`。敲击物支持 multipart 多参考图,第一张固定为后端内嵌默认木鱼图,用户上传图只作为新主题参考;prompt 必须要求 `1:1` 单一纯绿色 `#00FF00 / RGB(0,255,0)` 绿幕背景主体图,并禁止黑底、白底、棋盘格和任何实底背景。当前敲击物和返回按钮上传 OSS 前只做服务端绿幕去背后处理,避免泛抠图误伤玉米等主体像素。背景环境图只使用第一步抠图完成后的透明敲击物图作为参考,prompt 必须要求中央主体预留区保持干净,中央 40% 区域禁止出现主题主体、主体局部特写、轮廓影子或重复元素,主题元素只能作为外围氛围,且必须显式声明不继承任何绿色底色、绿幕底色或纯绿色画布。 - Hyper3D / Rodin:只保留后端安全代理和旧数据兼容;Rodin 提交、状态、下载和响应解析归属 `platform-hyper3d`,`api-server/src/hyper3d_generation.rs` 只做路由、配置和错误 envelope 映射;新 Match3D 草稿和批量新增不再生成 GLB。 - 音频:视觉小说专用音频路由保留;VectorEngine Suno/Vidu provider 协议、任务提交/查询、音频 URL 提取、下载、MIME/extension 归一和 OSS put 请求准备归属 `platform-audio`。`api-server/src/vector_engine_audio_generation.rs` 只做路由、配置、计费、asset object confirm、entity binding 和错误 envelope 映射;拼图、抓大鹅和敲木鱼提示词生成音效入口暂时关闭,通用 `/api/creation/audio/*` 对这些目标返回 `410 Gone`。敲木鱼创作只接收上传 / 录音音频资产;前端选择或录音阶段只在浏览器本地处理待提交音频,统一限制裁切后最长 1 秒、裁掉前后声音过小片段,并用浏览器端近似响度算法平衡到 `-15 LKFS` 后做峰值保护。点击生成时才直传 OSS 并确认 `asset_object`,创作 JSON 只提交轻量 `WoodenFishAudioAsset`,不得继续上传 Data URL 音频;未提供时由 `api-server` 写回内置默认木鱼音 `/wooden-fish/default-hit-sound.mp3`。 -- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。 +- OSS:私有 generated legacy path 进入浏览器前必须通过 `/api/assets/read-url` 换签;不要裸请求 `/generated-*`。前端如果收到同一 OSS bucket 的完整 `https://*.oss-*.aliyuncs.com/generated-*` 地址,也必须先归一为 legacy path 后走同一换签链路,避免裸连私有 bucket 403 或绕过签名缓存。OSS 签名、读签名、HEAD 和 PUT 的结构化日志由 `platform-oss` 输出,排查资产写入 / 确认失败时优先按 `operation`、`object_key` / `key_prefix`、`status_class`、`error_kind` 和 `elapsed_ms` 下钻。新上传 generated 私有对象默认写入 `Cache-Control: public, max-age=31536000, immutable`;旧对象若缺该头,只能依赖 `ETag` / `Last-Modified` 协商缓存,应通过 OSS 元数据刷新或 CDN 配置补齐,不要恢复 api-server 静态代理。`editor-agent/` 前缀只用于服务端内部读写画布 Agent 会话消息文档,不属于浏览器直传 legacy public prefix;`/api/assets/direct-upload-tickets` 必须拒绝 `legacyPrefix=editor-agent`,内部读取只允许 `editor-agent/{conversationId}.json` 形态。 - 外部 API 失败审计:外部供应商调用未成功时,`api-server` 必须发送 OTLP 失败事件并写入 `tracking_event`。VectorEngine 图片 provider 在 `platform-image` 内输出结构化日志和 `PlatformImageFailureAudit`,覆盖 `request_send`、`response_body`、`upstream_status`、`response_parse`、`missing_image` 和 `image_download` 阶段;`api-server` 只把该 audit 映射成 `external_api_call_failure`,`scope_kind = module`、`scope_id = provider`、`module_key = external-api`。metadata 固定包含 provider、endpoint、operation、failureStage、statusCode、statusClass、timeout、retryable、errorMessage、latencyMs、promptChars、referenceImageCount、imageModel、rawExcerpt,以及在调用方可获得上下文时补充的 `userId`(触发者)和 `profileId`(草稿 / 作品 / 场景作用域)。图片生成入口应优先把 owner user id 和 profile id 透传到失败审计,不要只保留 provider 级聚合,否则很难按“谁触发、哪个作品触发”定位问题。入库优先复用 tracking outbox,outbox 不可写或保护阈值拒绝时回退同步写 SpacetimeDB;不得新增前端兜底或在 SpacetimeDB reducer 内做外部 I/O。 - 外部生成运行记录:所有外部生成编排的完成态统一写入 `tracking_event`,`event_key = external_generation_run`,`scope_kind = module`,`scope_id = provider`,`module_key = external-generation`。metadata 固定包含 `runId`、`provider`、`operation`、`requestLabel`、`requestPayload`、`status`、`success`、`failureReason`、`providerRequestId`、`resultPayload`、`startedAtMicros`、`completedAtMicros` 和 `durationMs`。这类记录只用于运行审计和排障,不再走 `ai_task` 旧表。 @@ -445,11 +454,19 @@ npm run check:server-rs-ddd - 说明:外部 OpenAPI 调用使用的账号级 API Key 凭据表,只保存 key prefix、SHA-256 hash、作用域、撤销状态和使用时间;明文 Key 只在 `/api/profile/api-keys` 创建接口返回一次,不进入 SpacetimeDB,且 API Key 管理接口不写入外部 OpenAPI JSON。v1 默认作用域为 `editor:project`、`editor:canvas`、`editor:image-generate`、`editor:asset`;其中 `editor:project` 覆盖项目列表、最近项目、创建、读取、重命名和删除,`editor:canvas` 覆盖默认画布布局保存,`editor:image-generate` 覆盖编辑器现有图片生成、重绘 / 调整、规范图、宣发素材、图标 spritesheet 生成 / 拆分、UI 设计图素材拆分、角色动画、视频、音效和背景音乐生成,`editor:asset` 覆盖素材直传凭证、素材对象确认、签名读取、账号级素材库和项目画布资源记录操作。 - 索引:`by_external_api_key_owner_user_id` 用于登录态 API Key 列表;`key_hash` 唯一索引用于外部 API 鉴权。 +### `editor_agent_conversation` + +- Rust 结构体:`EditorAgentConversation` +- 源码:`server-rs/crates/spacetime-module/src/editor_agent_storage.rs` +- 说明:画布Agent对话会话元数据表,归属单个 `editor_project`;只保存会话 ID、project、owner、标题、消息 OSS 对象键(`editor-agent/{conversationId}.json`)、软删标记和时间戳。消息正文整体存 OSS,按会话粒度整体读写,不进 SpacetimeDB、不进画布工程快照 payload。删除为软删(`deleted = true`,OSS 对象保留)。领域校验(标题截取、附件上限、归属 / 软删规则)沉在 `module-editor-agent`。 +- 过程:只通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 操作;`api-server` 不直接绕过 `spacetime-client` facade 读写表。消息文档当前由 `api-server` 做单会话串行锁和 2 MiB 上限保护,避免同一 SSE 回合并发重写同一个 OSS JSON 文档。 +- 索引:`by_editor_agent_conversation_project_id` 用于会话列表;`by_editor_agent_conversation_owner_user_id` 用于账号级归属校验。 + ### `editor_project` - Rust 结构体:`EditorProject` - 源码:`server-rs/crates/spacetime-module/src/editor_project_storage.rs` -- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与图层布局已拆到 `editor_canvas`,旧 layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*` BFF 和 `spacetime-client` facade 读写;项目页列表、重命名和删除也使用该能力,删除工程时级联清理默认画布和资源元数据。 +- 说明:图片画布工程真相表,保存 owner、标题和工程时间戳;viewport 与图层布局已拆到 `editor_canvas`,旧 layout columns 暂作为兼容列保留,不再作为权威数据源。只通过 `/api/editor/projects*`、`/api/editor/projects/{projectId}/agent-conversations`、`/api/editor/agent-conversations/{conversationId}*` BFF 和 `spacetime-client` facade 读写;项目页列表、重命名和删除也使用该能力,删除工程时级联清理默认画布和资源元数据。 - 索引:`by_editor_project_owner_user_id` 用于读取当前用户最近编辑工程和项目页工程列表。 ### `editor_canvas` diff --git a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md index 53e44990a..c77a9d1b5 100644 --- a/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md +++ b/docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md @@ -100,6 +100,7 @@ - 面板中用户修改比例、尺寸或清晰度后,已有空白待生成占位立即同步更新 `width / height / originalWidth / originalHeight`,且保持中心点不跳动。 - 快速编辑点击修改后不创建独立 `Quick Edit Generator` 画布生成占位;当前快速编辑面板显示修改中,生成成功后结果直接覆盖源图,失败时保留当前面板并显示错误。需要新建占位的是生成图片、生成视频、重绘、去背景和角色动作等会产出新图层的入口。 - 任何会打开画布内 composer / 面板的入口,必须在面板渲染后通过统一 overlay 可见性校正检查真实 DOM 矩形;如果面板超出画布视口,或底部工具栏 / 左下 dock 会遮住面板,就只平移当前 viewport 让面板完整进入安全区域。新增生成类入口不要在按钮 handler 里手写单独的避让偏移。 +- 画布Agent对话入口例外:画布Agent对话(右侧对话面板)触发的生成不创建"即将生成"画布占位,生成中状态由对话消息流内的条目承载(阶段提示、模型标注、进行中动画);生成完成后结果图才按统一 placement 避让模型落画板为新图层,并在对话消息内显示缩略图。工具失败时也必须在对话消息内保留失败 generation record,而不是只弹一次性错误气泡。该例外仅限画布Agent对话入口,其余生成入口仍必须先落占位。详见 docs/【编辑器】画布Agent对话面板-2026-07-03.md。 ## 画布悬浮信息 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md new file mode 100644 index 000000000..81ea47f96 --- /dev/null +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -0,0 +1,123 @@ +# 画布Agent对话面板 + +日期:`2026-07-03` + +## 定位与边界 + +- **画布Agent对话** 是图片画布工程(`/editor/canvas`)右侧的对话式编辑器工具:用户通过自然语言调度画布已有的图片类生成与编辑能力,并可附加画布资源或素材库图片作为参考。 +- 它是画布域工具,**不承接玩法创作**、不产出玩法作品或模板,与 `CONTEXT.md` 中「表单/图片输入创作工作台」的 Avoid 边界不冲突。 +- 独立于拼图专用的 `/api/runtime/creative-agent/sessions`(该会话为 api-server 内存态、拼图领域专用,不复用)。 + +## 能力范围(第一期) + +对话 Agent 可通过 function-calling 触发以下五类工具,全部复用既有计费收口接口: + +| 工具 | 后端接口 | +| --- | --- | +| 生成图片 | `POST /api/editor/images/generations` | +| 修改图片(基于附件/画布素材) | `POST /api/editor/images/edits` | +| 生成角色形象 | 既有角色形象生成入口对应接口 | +| 生成图标素材 | `POST /api/editor/icon-spritesheets/generations` | +| 生成 UI 设计图 | 既有 UI 设计图生成入口对应接口 | + +- 意图解析与工具编排在后端 api-server,前端只渲染状态,不承接业务规则。 +- 所有生成必须走 `execute_billable_asset_operation_with_cost` 与模型定价配置,禁止绕过定价收口。 +- 视频 / 音频 / 图层操作等其余画板功能第一期不进入对话工具面,仍走现有面板。 + +## 当前分支落地状态 + +- 已落地:会话元数据、OSS 消息文档、会话 CRUD、SSE 消息流、后端 LLM / 启发式工具规划、右侧对话面板、会话历史、新建 / 软删会话、停止当前 SSE 回合、附件从画布资源 / 账号素材库选择,以及五类图片工具对既有生成入口的复用。 +- 已落地:`tool_started` / `tool_completed` 事件携带 `status`;工具失败时也会写入失败 generation record,并随后发送 `stage=failed` 与 `error`,前端应保留消息内失败条目。 +- 未落地:附件弹窗末尾上传格、跨刷新异步生成恢复、external generation task 轮询回填。未落地前,对话消息状态只表示本次 SSE 回合记录,不作为后台任务队列真相。 + +## 会话与持久化 + +- 对话归属单个图片画布工程;每个工程有自己的会话列表,可新开会话。 +- SpacetimeDB 新表 `editor_agent_conversation` 只存会话元数据:会话 ID、projectId、ownerUserId、标题、软删标记、聊天记录 OSS 对象引用、创建/更新时间。 +- 完整消息内容存 OSS:`editor-agent/{conversationId}.json`,**会话粒度整体读写**(追加消息=重写对象),不按消息拆对象。 +- 不把对话塞进画布工程快照 payload,不在 api-server 内存中保存会话真相。 +- 会话标题:新会话默认「新对话」,首条用户消息发出后自动截取前 N 字作为标题;第一期不做手动重命名。 +- 会话删除:列表项 hover 出删除按钮 + 确认;软删(表打 deleted 标记,OSS 对象保留)。 + +## 生成结果落画板(对现有占位规则的例外) + +- 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由对话消息流承载。 +- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时在对话消息内显示缩略图(点击可让画布聚焦到对应图层;如果当前画布尚无该资源图层,前端先刷新工程快照再聚焦)。 +- 当前第一阶段通过既有编辑器生成 BFF 的 `canvasCompletion` 写回工程快照;刷新后异步任务恢复和轮询回填属于后续能力,不在本阶段声明为已完成。 +- 该例外已同步登记在《生成类面板Lovart统一改造方案-2026-06-17》「画布占位落点」节。 + +## 右侧布局 + +- 对话框与既有任务侧栏(`ImageCanvasTaskSidebarView`)**互斥展开**:展开一个自动收起另一个;各自收起后保留入口按钮。 +- 对话框与左侧素材 / 图层侧栏也互斥:打开画布 Agent 时收起左侧栏;再次打开素材、图层或任务侧栏时收起 Agent 面板。 +- 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。 +- 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。 + +## 附件 + +- 输入区 `+` 按钮打开**图片选择弹窗**(仅图片,无音视频): + - 「画布」页签(默认):展示当前工程图层引用的图片资源; + - 「素材库」页签:账号级素材库(复用 `ImageCanvasAssetLibrary` 数据源); + - 多选 + 底部「取消 / 应用」。 +- 网格末尾上传格为后续补齐项;在上传格未落地前,对话附件只从已有画布资源和账号素材库选择。后续若从对话入口上传图片,必须复用素材库 / 画布资源登记链路,不新增对话私有图片类型。 +- 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。 +- 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。 + +## LLM 与计费 + +- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),function-calling 注册五类工具。 +- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。 +- 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。 + +## Lovart 参照做/不做清单(验收标准) + +做(第一期): + +1. 助手文本 SSE 流式输出; +2. 阶段提示行(思考中 → 思考完成 → 生成中 → 完成/失败); +3. 工具/模型标注行(生成时显示模型名 + 图标); +4. 消息内生成结果缩略图(点击 → 画布聚焦对应图层); +5. 生成中的进行中动画; +6. 错误气泡(失败/余额不足,带原因); +7. 发送中断:进行中时发送按钮变「停止」,可中断当前回合(已提交的生成任务不追回,照常落画板)。 + +不做(明确排除,防止后人补齐): + +- 点赞/点踩反馈按钮; +- 消息复制、分享/导出对话; +- Agent 模式切换下拉(固定单一 Agent); +- 语音输入、@引用、多 Agent 协作; +- Lovart 的积分/加速档位显示(泥点扣费只在生成动作上体现)。 + +## 顺手需求 + +- 左侧侧边栏默认隐藏:`useImageCanvasEditorChrome.ts` 中 `activeSidebarPanel` 初始值 `'assets'` → `null`; +- 小地图默认隐藏:`isMinimapOpen` 初始值 `true` → `false`; +- 纯默认值修改,不加 localStorage 偏好记忆。 + +## 后端分层落位 + +- `module-editor-agent`(新 crate):领域规则——会话/消息校验、状态机、附件上限、软删规则、工具清单领域定义;纯逻辑无 IO。 +- `spacetime-module`:新表 `editor_agent_conversation` + procedure;同步 `migration.rs`、表目录、生成绑定,运行 `npm run check:spacetime-schema`。 +- `spacetime-client`:facade 读写方法。 +- `api-server`: + - `GET/POST /api/editor/projects/{projectId}/agent-conversations`(列表/新建); + - `GET/DELETE /api/editor/agent-conversations/{conversationId}`(详情/软删); + - `POST /api/editor/agent-conversations/{conversationId}/messages/stream`(SSE); + - Agent 编排(function-calling 循环、工具内部调既有生成执行链路)放 api-server 编排层,独立文件,不复用 `creative_agent.rs` 内存会话。 +- `shared-contracts` + `packages/shared`:新 `editorAgent` DTO 与 SSE 事件契约(`stage`、`message_delta`、`tool_started`、`tool_completed`、`generation_result`、`error`、`done`)。 + +## 实施顺序 + +1. 契约与领域规则(shared-contracts / packages/shared + module-editor-agent); +2. 存储层(spacetime-module 表 + procedure + migration + spacetime-client + schema check); +3. api-server 会话 CRUD + 消息 OSS 读写 + SSE 回显桩(不接 LLM,先保证会话链路端到端真实落库); +4. 前端最小纵切(对话框、会话管理、消息流、附件弹窗、侧边栏/小地图默认值)——可与 3 并行:3 只碰 `server-rs/`,4 只碰 `src/` 且先以契约 mock 客户端联调,汇合点在 4 末接真实 API; +5. LLM 编排 + 五类工具接入 + 生成落画板 + 停止/错误态(替换回显桩这一个点); +6. 验证与文档:定向测试、类型检查、`npm run check:encoding`、`git diff --check`、`npm run check:spacetime-schema`、api-server smoke `/healthz`;同步 `CONTEXT.md` 与相关文档。 + +## 第一阶段验收补充 + +- 打开画布 Agent 后,任务侧栏和左侧素材 / 图层面板应关闭;再次打开任务侧栏或素材 / 图层面板时,Agent 面板应关闭。 +- 发送消息时先本地追加用户消息,再消费 SSE 增量;停止按钮只中断当前 SSE 回合,不追回已经提交的生成工具调用。 +- 点击 Agent 消息内生成结果缩略图时,若当前画布已有该资源图层则直接聚焦;若尚无该资源图层,应先刷新工程快照,再聚焦新图层。 diff --git a/packages/shared/src/contracts/editorAgent.ts b/packages/shared/src/contracts/editorAgent.ts new file mode 100644 index 000000000..6d6c5cf7f --- /dev/null +++ b/packages/shared/src/contracts/editorAgent.ts @@ -0,0 +1,168 @@ +// 画布Agent对话契约:会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。 + +export const EDITOR_AGENT_MAX_ATTACHMENTS = 9; +export const EDITOR_AGENT_TITLE_MAX_CHARS = 20; +export const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE = '新对话'; +export const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION = 1; + +export type EditorAgentStage = + | 'idle' + | 'thinking' + | 'responding' + | 'generating' + | 'completed' + | 'failed'; + +export type EditorAgentMessageRole = 'user' | 'assistant'; + +export type EditorAgentMessageKind = 'chat' | 'stage' | 'error'; + +export type EditorAgentMessageStatus = + | 'streaming' + | 'generating' + | 'completed' + | 'failed' + | 'stopped'; + +export type EditorAgentAttachmentSource = 'canvas_resource' | 'library_asset'; + +export type EditorAgentToolName = + | 'generate_image' + | 'edit_image' + | 'generate_character' + | 'generate_icon_spritesheet' + | 'generate_ui_design'; + +export interface EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource; + referenceId: string; + objectKey?: string | null; + imageSrc: string; + thumbnailSrc?: string | null; + label?: string | null; + width?: number | null; + height?: number | null; +} + +export interface EditorAgentGeneratedImage { + resourceId: string | null; + imageSrc: string; + thumbnailSrc: string | null; + width: number | null; + height: number | null; +} + +export type EditorAgentGenerationStatus = 'generating' | 'completed' | 'failed'; + +export interface EditorAgentGenerationRecord { + toolCallId: string; + toolName: EditorAgentToolName; + taskId: string | null; + status: EditorAgentGenerationStatus; + model: string | null; + images: EditorAgentGeneratedImage[]; + error?: string | null; +} + +export interface EditorAgentMessage { + id: string; + role: EditorAgentMessageRole; + kind: EditorAgentMessageKind; + text: string; + attachments: EditorAgentAttachmentRef[]; + generations: EditorAgentGenerationRecord[]; + status: EditorAgentMessageStatus; + createdAt: string; +} + +export interface EditorAgentConversationSummary { + conversationId: string; + projectId: string; + title: string; + createdAt: string; + updatedAt: string; +} + +export interface EditorAgentConversationDetail + extends EditorAgentConversationSummary { + messages: EditorAgentMessage[]; +} + +export interface EditorAgentConversationMessagesDocument { + version: number; + conversationId: string; + messages: EditorAgentMessage[]; +} + +export interface CreateEditorAgentConversationRequest { + title?: string | null; +} + +export interface EditorAgentConversationListResponse { + conversations: EditorAgentConversationSummary[]; +} + +export interface EditorAgentConversationResponse { + conversation: EditorAgentConversationDetail; +} + +export interface StreamEditorAgentMessageRequest { + clientMessageId: string; + text: string; + attachments?: EditorAgentAttachmentRef[]; +} + +export interface EditorAgentStageEvent { + conversationId: string; + stage: EditorAgentStage; +} + +export interface EditorAgentMessageDeltaEvent { + conversationId: string; + messageId: string; + role: EditorAgentMessageRole; + kind: EditorAgentMessageKind; + textDelta: string; +} + +export interface EditorAgentToolEvent { + conversationId: string; + messageId: string; + toolCallId: string; + toolName: EditorAgentToolName; + summary?: string | null; + taskId?: string | null; + model?: string | null; + status?: EditorAgentGenerationStatus | null; + error?: string | null; +} + +export interface EditorAgentGenerationResultEvent { + conversationId: string; + messageId: string; + toolCallId: string; + toolName: EditorAgentToolName; + model: string | null; + images: EditorAgentGeneratedImage[]; +} + +export interface EditorAgentErrorEvent { + conversationId: string | null; + code: string; + message: string; + recoverable: boolean; +} + +export interface EditorAgentDoneEvent { + conversationId: string; + title: string | null; +} + +export type EditorAgentSseEvent = + | { event: 'stage'; data: EditorAgentStageEvent } + | { event: 'message_delta'; data: EditorAgentMessageDeltaEvent } + | { event: 'tool_started'; data: EditorAgentToolEvent } + | { event: 'tool_completed'; data: EditorAgentToolEvent } + | { event: 'generation_result'; data: EditorAgentGenerationResultEvent } + | { event: 'error'; data: EditorAgentErrorEvent } + | { event: 'done'; data: EditorAgentDoneEvent }; diff --git a/packages/shared/src/contracts/index.ts b/packages/shared/src/contracts/index.ts index 02b4005e4..5eb52d4dd 100644 --- a/packages/shared/src/contracts/index.ts +++ b/packages/shared/src/contracts/index.ts @@ -1,6 +1,7 @@ export type * from './barkBattle'; export type * from './creationAudio'; export type * from './creativeAgent'; +export * from './editorAgent'; export * from './hostBridge'; export type * from './hyper3d'; export type * from './jumpHop'; diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index efa038805..181cd0ac8 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -222,6 +222,7 @@ dependencies = [ "module-combat", "module-creative-agent", "module-custom-world", + "module-editor-agent", "module-inventory", "module-match3d", "module-npc", @@ -3147,6 +3148,16 @@ dependencies = [ "spacetimedb", ] +[[package]] +name = "module-editor-agent" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "shared-kernel", + "spacetimedb", +] + [[package]] name = "module-inventory" version = "0.1.0" @@ -5809,6 +5820,7 @@ dependencies = [ "module-big-fish", "module-combat", "module-custom-world", + "module-editor-agent", "module-inventory", "module-jump-hop", "module-match3d", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index a7c331ee5..c7681f2fb 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/module-big-fish", "crates/module-combat", "crates/module-creative-agent", + "crates/module-editor-agent", "crates/module-inventory", "crates/module-custom-world", "crates/module-jump-hop", @@ -64,6 +65,7 @@ module-bark-battle = { path = "crates/module-bark-battle", default-features = fa module-big-fish = { path = "crates/module-big-fish", default-features = false } module-combat = { path = "crates/module-combat", default-features = false } module-creative-agent = { path = "crates/module-creative-agent", default-features = false } +module-editor-agent = { path = "crates/module-editor-agent", default-features = false } module-custom-world = { path = "crates/module-custom-world", default-features = false } module-inventory = { path = "crates/module-inventory", default-features = false } module-jump-hop = { path = "crates/module-jump-hop", default-features = false } diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml index 93df64598..a38b50da4 100644 --- a/server-rs/crates/api-server/Cargo.toml +++ b/server-rs/crates/api-server/Cargo.toml @@ -25,6 +25,7 @@ module-bark-battle = { workspace = true } module-combat = { workspace = true } module-creative-agent = { workspace = true } module-custom-world = { workspace = true } +module-editor-agent = { workspace = true } module-inventory = { workspace = true } module-match3d = { workspace = true } module-npc = { workspace = true } diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 908576bae..c67e6fa49 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -85,6 +85,14 @@ pub(crate) async fn create_direct_upload_ticket_for_owner( "supported": platform_oss::LEGACY_PUBLIC_PREFIXES, })) })?; + if matches!(legacy_prefix, LegacyAssetPrefix::EditorAgent) { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "field": "legacyPrefix", + "supported": platform_oss::LEGACY_PUBLIC_PREFIXES, + })), + ); + } let signed = oss_client .sign_post_object(OssPostObjectRequest { diff --git a/server-rs/crates/api-server/src/editor_agent.rs b/server-rs/crates/api-server/src/editor_agent.rs new file mode 100644 index 000000000..088b4e68e --- /dev/null +++ b/server-rs/crates/api-server/src/editor_agent.rs @@ -0,0 +1,1998 @@ +use std::{ + collections::BTreeMap, + convert::Infallible, + sync::{Arc, Mutex, OnceLock}, +}; + +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::{ + IntoResponse, Response, + sse::{Event, Sse}, + }, +}; +use module_editor_agent::{ + EDITOR_AGENT_CONVERSATION_ID_PREFIX, EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE, + EDITOR_AGENT_MESSAGE_ID_PREFIX, derive_conversation_title, editor_agent_messages_object_key, + validate_user_message, +}; +use platform_llm::{LlmMessage, LlmMessageContentPart, LlmTextRequest}; +use platform_oss::{ + LegacyAssetPrefix, OssObjectAccess, OssPutObjectRequest, OssSignedGetObjectUrlRequest, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use shared_contracts::assets::{ + EditorCanvasGenerationCompletionPayload, EditorCanvasGenerationPlaceholderPayload, +}; +use shared_contracts::editor_agent::{ + CreateEditorAgentConversationRequest, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + EditorAgentAttachmentRef, EditorAgentAttachmentSource, EditorAgentConversationDetail, + EditorAgentConversationListResponse, EditorAgentConversationMessagesDocument, + EditorAgentConversationResponse, EditorAgentConversationSummary, EditorAgentDoneEvent, + EditorAgentErrorEvent, EditorAgentGeneratedImage, EditorAgentGenerationRecord, + EditorAgentGenerationResultEvent, EditorAgentGenerationStatus, EditorAgentMessage, + EditorAgentMessageDeltaEvent, EditorAgentMessageKind, EditorAgentMessageRole, + EditorAgentMessageStatus, EditorAgentStage, EditorAgentStageEvent, EditorAgentToolEvent, + EditorAgentToolName, StreamEditorAgentMessageRequest, +}; +use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string}; +use spacetime_client::{ + EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, + EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput, + EditorAssetLibraryRecord, EditorAssetRecord, EditorProjectGetRecordInput, EditorProjectRecord, + EditorProjectResourceRecord, +}; + +use crate::{ + api_response::json_success_body, + auth::AuthenticatedAccessToken, + editor_project::{ + EditorGenerationCaller, EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, + EditorImageGenerationRequest, current_utc_micros, edit_editor_image_for_owner, + generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, + map_editor_project_error, + }, + http_error::AppError, + platform_errors::map_oss_error, + request_context::RequestContext, + state::AppState, +}; + +const EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES: usize = 2 * 1024 * 1024; +const EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS: u64 = 60; +const EDITOR_AGENT_LLM_ATTACHMENT_URL_EXPIRE_SECONDS: u64 = 300; +const EDITOR_AGENT_LLM_MAX_HISTORY_MESSAGES: usize = 12; +const EDITOR_AGENT_TOOL_CALL_ID_PREFIX: &str = "editor-agent-tool"; +const EDITOR_AGENT_CANVAS_RESULT_GAP: f64 = 32.0; +type EditorAgentConversationLockMap = Mutex>>>; +static EDITOR_AGENT_CONVERSATION_LOCKS: OnceLock = OnceLock::new(); + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +struct EditorAgentConversationDeleteResponse { + deleted_conversation_id: String, + conversation: EditorAgentConversationSummary, +} + +pub async fn list_editor_agent_conversations( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?; + let conversations = state + .spacetime_client() + .list_editor_agent_conversations(project_id, owner_user_id) + .await + .map_err(map_editor_project_error)? + .into_iter() + .map(conversation_summary_from_record) + .collect(); + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationListResponse { conversations }, + )) +} + +pub async fn create_editor_agent_conversation( + State(state): State, + Path(project_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + ensure_editor_project_access(&state, project_id.as_str(), owner_user_id.as_str()).await?; + + let conversation_id = build_prefixed_uuid_id(EDITOR_AGENT_CONVERSATION_ID_PREFIX); + let messages_object_key = editor_agent_messages_object_key(conversation_id.as_str()); + let title = normalize_optional_string(payload.title) + .unwrap_or_else(|| EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE.to_string()); + let now_micros = current_utc_micros(); + let seed_record = EditorAgentConversationRecord { + conversation_id: conversation_id.clone(), + project_id: project_id.clone(), + owner_user_id: owner_user_id.clone(), + title: title.clone(), + messages_object_key: messages_object_key.clone(), + deleted: false, + created_at: now_rfc3339(), + updated_at: now_rfc3339(), + updated_at_micros: now_micros, + }; + write_messages_document( + &state, + &seed_record, + &empty_messages_document(conversation_id.as_str()), + ) + .await?; + + let conversation = state + .spacetime_client() + .create_editor_agent_conversation(EditorAgentConversationCreateRecordInput { + conversation_id, + project_id, + owner_user_id, + title, + messages_object_key, + created_at_micros: now_micros, + }) + .await + .map_err(map_editor_project_error)?; + let document = read_messages_document(&state, &conversation).await?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationResponse { + conversation: conversation_detail_from_record(conversation, document.messages), + }, + )) +} + +pub async fn get_editor_agent_conversation( + State(state): State, + Path(conversation_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let conversation = state + .spacetime_client() + .get_editor_agent_conversation(conversation_id, owner_user_id) + .await + .map_err(map_editor_project_error)?; + let document = read_messages_document(&state, &conversation).await?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationResponse { + conversation: conversation_detail_from_record(conversation, document.messages), + }, + )) +} + +pub async fn delete_editor_agent_conversation( + State(state): State, + Path(conversation_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, +) -> Result, AppError> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let conversation = state + .spacetime_client() + .delete_editor_agent_conversation(EditorAgentConversationDeleteRecordInput { + conversation_id, + owner_user_id, + updated_at_micros: current_utc_micros(), + }) + .await + .map_err(map_editor_project_error)?; + + Ok(json_success_body( + Some(&request_context), + EditorAgentConversationDeleteResponse { + deleted_conversation_id: conversation.conversation_id.clone(), + conversation: conversation_summary_from_record(conversation), + }, + )) +} + +pub async fn stream_editor_agent_message( + State(state): State, + Path(conversation_id): Path, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Json(payload): Json, +) -> Result { + let owner_user_id = authenticated.claims().user_id().to_string(); + let client_message_id = normalize_required_string(payload.client_message_id.as_str()) + .ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?; + let attachment_reference_ids = payload + .attachments + .iter() + .map(|attachment| attachment.reference_id.clone()) + .collect::>(); + validate_user_message(payload.text.as_str(), &attachment_reference_ids) + .map_err(|error| editor_agent_bad_request(error.to_string()))?; + + let conversation = state + .spacetime_client() + .get_editor_agent_conversation(conversation_id, owner_user_id.clone()) + .await + .map_err(map_editor_project_error)?; + let attachments = + normalize_editor_agent_attachments(&state, &conversation, payload.attachments.as_slice()) + .await?; + let conversation_lock = editor_agent_conversation_lock(conversation.conversation_id.as_str()); + let conversation_lock_guard = conversation_lock.lock_owned().await; + let mut document = read_messages_document(&state, &conversation).await?; + if document + .messages + .iter() + .any(|message| message.id == client_message_id) + { + return Err( + AppError::from_status(StatusCode::CONFLICT).with_details(json!({ + "provider": "editor-agent", + "field": "clientMessageId", + "message": "clientMessageId already exists in this conversation", + })), + ); + } + + let was_empty = document.messages.is_empty(); + let user_message = EditorAgentMessage { + id: client_message_id, + role: EditorAgentMessageRole::User, + kind: EditorAgentMessageKind::Chat, + text: payload.text.trim().to_string(), + attachments, + generations: Vec::new(), + status: EditorAgentMessageStatus::Completed, + created_at: now_rfc3339(), + }; + document.messages.push(user_message.clone()); + write_messages_document(&state, &conversation, &document).await?; + + let assistant_message_id = build_prefixed_uuid_id(EDITOR_AGENT_MESSAGE_ID_PREFIX); + let stream_state = state.clone(); + let stream_conversation = conversation.clone(); + let stream_title = was_empty.then(|| derive_conversation_title(user_message.text.as_str())); + let stream_assistant_message_id = assistant_message_id.clone(); + let stream_user_message = user_message.clone(); + let stream_request_context = request_context.clone(); + let stream_conversation_lock_guard = conversation_lock_guard; + + let stream = async_stream::stream! { + let _conversation_lock_guard = stream_conversation_lock_guard; + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Thinking, + }, + )); + + let turn_plan = plan_editor_agent_turn( + &stream_state, + &stream_conversation, + &document, + &stream_user_message, + ) + .await; + let mut assistant_text = turn_plan.reply_text; + if assistant_text.trim().is_empty() { + assistant_text = heuristic_editor_agent_turn_plan( + stream_user_message.text.as_str(), + stream_user_message.attachments.as_slice(), + ) + .reply_text; + } + let mut assistant_status = EditorAgentMessageStatus::Completed; + let mut generation_records = Vec::new(); + + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Responding, + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "message_delta", + EditorAgentMessageDeltaEvent { + conversation_id: stream_conversation.conversation_id.clone(), + message_id: stream_assistant_message_id.clone(), + role: EditorAgentMessageRole::Assistant, + kind: EditorAgentMessageKind::Chat, + text_delta: assistant_text.clone(), + }, + )); + + if let Some(tool_call) = turn_plan.tool_call { + let tool_call_id = build_prefixed_uuid_id(EDITOR_AGENT_TOOL_CALL_ID_PREFIX); + let tool_summary = tool_call + .summary + .clone() + .or_else(|| Some(editor_agent_tool_default_summary(tool_call.tool_name))); + yield Ok::(editor_agent_sse_json_event_or_error( + "tool_started", + EditorAgentToolEvent { + conversation_id: stream_conversation.conversation_id.clone(), + message_id: stream_assistant_message_id.clone(), + tool_call_id: tool_call_id.clone(), + tool_name: tool_call.tool_name, + summary: tool_summary.clone(), + task_id: None, + model: tool_call.model.clone(), + status: Some(EditorAgentGenerationStatus::Generating), + error: None, + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Generating, + }, + )); + + match execute_editor_agent_tool_call( + &stream_state, + &stream_request_context, + &stream_conversation, + &stream_user_message, + &tool_call_id, + tool_call, + ) + .await + { + Ok(execution) => { + generation_records.push(execution.record.clone()); + yield Ok::(editor_agent_sse_json_event_or_error( + "generation_result", + EditorAgentGenerationResultEvent { + conversation_id: stream_conversation.conversation_id.clone(), + message_id: stream_assistant_message_id.clone(), + tool_call_id: tool_call_id.clone(), + tool_name: execution.record.tool_name, + model: execution.record.model.clone(), + images: execution.record.images.clone(), + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "tool_completed", + EditorAgentToolEvent { + conversation_id: stream_conversation.conversation_id.clone(), + message_id: stream_assistant_message_id.clone(), + tool_call_id, + tool_name: execution.record.tool_name, + summary: Some(execution.summary), + task_id: execution.record.task_id.clone(), + model: execution.record.model.clone(), + status: Some(EditorAgentGenerationStatus::Completed), + error: None, + }, + )); + } + Err(error) => { + let error_code = error.error.code().to_string(); + let error_message = error.error.body_text(); + let error_model = error.model.clone(); + assistant_status = EditorAgentMessageStatus::Failed; + generation_records.push(EditorAgentGenerationRecord { + tool_call_id: tool_call_id.clone(), + tool_name: error.tool_name, + task_id: None, + status: EditorAgentGenerationStatus::Failed, + model: error_model.clone(), + images: Vec::new(), + error: Some(error_message.clone()), + }); + yield Ok::(editor_agent_sse_json_event_or_error( + "tool_completed", + EditorAgentToolEvent { + conversation_id: stream_conversation.conversation_id.clone(), + message_id: stream_assistant_message_id.clone(), + tool_call_id, + tool_name: error.tool_name, + summary: Some(editor_agent_tool_default_summary(error.tool_name)), + task_id: None, + model: error_model, + status: Some(EditorAgentGenerationStatus::Failed), + error: Some(error_message.clone()), + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Failed, + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "error", + EditorAgentErrorEvent { + conversation_id: Some(stream_conversation.conversation_id.clone()), + code: error_code, + message: error_message, + recoverable: true, + }, + )); + } + } + } + + let write_result = async { + let mut next_document = read_messages_document(&stream_state, &stream_conversation).await?; + if !next_document + .messages + .iter() + .any(|message| message.id == stream_assistant_message_id) + { + next_document.messages.push(EditorAgentMessage { + id: stream_assistant_message_id.clone(), + role: EditorAgentMessageRole::Assistant, + kind: if assistant_status == EditorAgentMessageStatus::Failed { + EditorAgentMessageKind::Error + } else { + EditorAgentMessageKind::Chat + }, + text: assistant_text, + attachments: Vec::new(), + generations: generation_records, + status: assistant_status, + created_at: now_rfc3339(), + }); + } + write_messages_document(&stream_state, &stream_conversation, &next_document).await + } + .await; + match write_result { + Ok(()) => { + let mut done_title = None; + if let Some(title) = stream_title { + match stream_state.spacetime_client().touch_editor_agent_conversation( + EditorAgentConversationTouchRecordInput { + conversation_id: stream_conversation.conversation_id.clone(), + owner_user_id: stream_conversation.owner_user_id.clone(), + title: Some(title.clone()), + updated_at_micros: current_utc_micros(), + }, + ).await { + Ok(updated) => done_title = Some(updated.title), + Err(error) => { + yield Ok::(editor_agent_sse_json_event_or_error( + "error", + EditorAgentErrorEvent { + conversation_id: Some(stream_conversation.conversation_id.clone()), + code: "SPACETIME_UPDATE_FAILED".to_string(), + message: error.to_string(), + recoverable: true, + }, + )); + } + } + } else { + match stream_state.spacetime_client().touch_editor_agent_conversation( + EditorAgentConversationTouchRecordInput { + conversation_id: stream_conversation.conversation_id.clone(), + owner_user_id: stream_conversation.owner_user_id.clone(), + title: None, + updated_at_micros: current_utc_micros(), + }, + ).await { + Ok(_) => {} + Err(error) => { + yield Ok::(editor_agent_sse_json_event_or_error( + "error", + EditorAgentErrorEvent { + conversation_id: Some(stream_conversation.conversation_id.clone()), + code: "SPACETIME_UPDATE_FAILED".to_string(), + message: error.to_string(), + recoverable: true, + }, + )); + } + } + } + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Completed, + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "done", + EditorAgentDoneEvent { + conversation_id: stream_conversation.conversation_id.clone(), + title: done_title, + }, + )); + } + Err(error) => { + yield Ok::(editor_agent_sse_json_event_or_error( + "stage", + EditorAgentStageEvent { + conversation_id: stream_conversation.conversation_id.clone(), + stage: EditorAgentStage::Failed, + }, + )); + yield Ok::(editor_agent_sse_json_event_or_error( + "error", + EditorAgentErrorEvent { + conversation_id: Some(stream_conversation.conversation_id.clone()), + code: error.code().to_string(), + message: error.body_text(), + recoverable: true, + }, + )); + } + } + }; + + Ok(Sse::new(stream).into_response()) +} + +fn editor_agent_conversation_lock(conversation_id: &str) -> Arc> { + let locks = EDITOR_AGENT_CONVERSATION_LOCKS.get_or_init(|| Mutex::new(BTreeMap::new())); + let mut locks = locks + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + locks + .entry(conversation_id.to_string()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone() +} + +async fn ensure_editor_project_access( + state: &AppState, + project_id: &str, + owner_user_id: &str, +) -> Result<(), AppError> { + state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: project_id.to_string(), + owner_user_id: owner_user_id.to_string(), + }) + .await + .map(|_| ()) + .map_err(map_editor_project_error) +} + +async fn normalize_editor_agent_attachments( + state: &AppState, + conversation: &EditorAgentConversationRecord, + attachments: &[EditorAgentAttachmentRef], +) -> Result, AppError> { + if attachments.is_empty() { + return Ok(Vec::new()); + } + + let needs_canvas_resources = attachments + .iter() + .any(|attachment| attachment.source == EditorAgentAttachmentSource::CanvasResource); + let needs_library_assets = attachments + .iter() + .any(|attachment| attachment.source == EditorAgentAttachmentSource::LibraryAsset); + + let project = if needs_canvas_resources { + Some( + state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: conversation.project_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + }) + .await + .map_err(map_editor_project_error)?, + ) + } else { + None + }; + let library = if needs_library_assets { + Some( + state + .spacetime_client() + .get_editor_asset_library(conversation.owner_user_id.clone(), current_utc_micros()) + .await + .map_err(map_editor_project_error)?, + ) + } else { + None + }; + + attachments + .iter() + .map(|attachment| { + normalize_editor_agent_attachment( + conversation, + project.as_ref(), + library.as_ref(), + attachment, + ) + }) + .collect() +} + +fn normalize_editor_agent_attachment( + conversation: &EditorAgentConversationRecord, + project: Option<&EditorProjectRecord>, + library: Option<&EditorAssetLibraryRecord>, + attachment: &EditorAgentAttachmentRef, +) -> Result { + let reference_id = normalize_required_string(attachment.reference_id.as_str()) + .ok_or_else(|| editor_agent_bad_request("attachment.referenceId is required"))?; + match attachment.source { + EditorAgentAttachmentSource::CanvasResource => { + let project = project.ok_or_else(|| { + editor_agent_bad_request("canvas resource attachment project context missing") + })?; + let resource = project + .resources + .iter() + .find(|resource| resource.resource_id == reference_id) + .ok_or_else(|| { + editor_agent_bad_request(format!( + "canvas resource attachment not found in current project: {reference_id}" + )) + })?; + normalize_canvas_resource_attachment(conversation, attachment, resource) + } + EditorAgentAttachmentSource::LibraryAsset => { + let library = library.ok_or_else(|| { + editor_agent_bad_request("library asset attachment context missing") + })?; + let asset = library + .assets + .iter() + .find(|asset| asset.asset_id == reference_id) + .ok_or_else(|| { + editor_agent_bad_request(format!( + "library asset attachment not found for current user: {reference_id}" + )) + })?; + normalize_library_asset_attachment(attachment, asset) + } + } +} + +fn normalize_canvas_resource_attachment( + conversation: &EditorAgentConversationRecord, + attachment: &EditorAgentAttachmentRef, + resource: &EditorProjectResourceRecord, +) -> Result { + if resource.project_id != conversation.project_id + || resource.owner_user_id != conversation.owner_user_id + { + return Err(editor_agent_bad_request( + "canvas resource attachment does not belong to this conversation project", + )); + } + validate_attachment_object_key( + attachment.object_key.as_deref(), + resource.object_key.as_deref(), + resource.resource_id.as_str(), + )?; + + Ok(EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: resource.resource_id.clone(), + object_key: resource.object_key.clone(), + image_src: resource.image_src.clone(), + thumbnail_src: None, + label: normalize_optional_string(attachment.label.clone()), + width: Some(resource.width), + height: Some(resource.height), + }) +} + +fn normalize_library_asset_attachment( + attachment: &EditorAgentAttachmentRef, + asset: &EditorAssetRecord, +) -> Result { + validate_attachment_object_key( + attachment.object_key.as_deref(), + asset.object_key.as_deref(), + asset.asset_id.as_str(), + )?; + + Ok(EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::LibraryAsset, + reference_id: asset.asset_id.clone(), + object_key: asset.object_key.clone(), + image_src: asset.image_src.clone(), + thumbnail_src: asset.thumbnail_src.clone(), + label: normalize_optional_string(attachment.label.clone()) + .or_else(|| Some(asset.label.clone())), + width: Some(asset.width), + height: Some(asset.height), + }) +} + +fn validate_attachment_object_key( + submitted_object_key: Option<&str>, + stored_object_key: Option<&str>, + reference_id: &str, +) -> Result<(), AppError> { + let Some(submitted_object_key) = submitted_object_key.and_then(normalize_required_string) + else { + return Ok(()); + }; + let Some(stored_object_key) = stored_object_key.and_then(normalize_required_string) else { + return Err(editor_agent_bad_request(format!( + "attachment objectKey is not available for reference: {reference_id}" + ))); + }; + if submitted_object_key != stored_object_key { + return Err(editor_agent_bad_request(format!( + "attachment objectKey does not match reference: {reference_id}" + ))); + } + Ok(()) +} + +fn conversation_summary_from_record( + conversation: EditorAgentConversationRecord, +) -> EditorAgentConversationSummary { + EditorAgentConversationSummary { + conversation_id: conversation.conversation_id, + project_id: conversation.project_id, + title: conversation.title, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + } +} + +fn conversation_detail_from_record( + conversation: EditorAgentConversationRecord, + messages: Vec, +) -> EditorAgentConversationDetail { + EditorAgentConversationDetail { + conversation_id: conversation.conversation_id, + project_id: conversation.project_id, + title: conversation.title, + created_at: conversation.created_at, + updated_at: conversation.updated_at, + messages, + } +} + +fn empty_messages_document(conversation_id: &str) -> EditorAgentConversationMessagesDocument { + EditorAgentConversationMessagesDocument { + version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + conversation_id: conversation_id.to_string(), + messages: Vec::new(), + } +} + +async fn read_messages_document( + state: &AppState, + conversation: &EditorAgentConversationRecord, +) -> Result { + let oss_client = state + .oss_client() + .ok_or_else(editor_agent_oss_unavailable)?; + let signed = oss_client + .sign_internal_get_object_url(OssSignedGetObjectUrlRequest { + object_key: conversation.messages_object_key.clone(), + expire_seconds: Some(EDITOR_AGENT_MESSAGES_READ_EXPIRE_SECONDS), + }) + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + let response = reqwest::Client::new() + .get(signed.signed_url.as_str()) + .send() + .await + .map_err(|error| editor_agent_oss_read_error(error.to_string()))?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(empty_messages_document( + conversation.conversation_id.as_str(), + )); + } + if !response.status().is_success() { + return Err(editor_agent_oss_read_error(format!( + "OSS returned non-success status {}", + response.status().as_u16() + ))); + } + if response + .content_length() + .is_some_and(|size| size > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES as u64) + { + return Err(editor_agent_messages_document_too_large()); + } + let bytes = response + .bytes() + .await + .map_err(|error| editor_agent_oss_read_error(error.to_string()))?; + if bytes.is_empty() { + return Ok(empty_messages_document( + conversation.conversation_id.as_str(), + )); + } + if bytes.len() > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES { + return Err(editor_agent_messages_document_too_large()); + } + let document: EditorAgentConversationMessagesDocument = serde_json::from_slice(&bytes) + .map_err(|error| { + editor_agent_oss_read_error(format!("message document JSON invalid: {error}")) + })?; + if document.conversation_id != conversation.conversation_id { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "editor-agent", + "message": "message document conversationId does not match metadata", + "conversationId": conversation.conversation_id, + "documentConversationId": document.conversation_id, + })), + ); + } + Ok(document) +} + +async fn write_messages_document( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &EditorAgentConversationMessagesDocument, +) -> Result<(), AppError> { + if document.conversation_id != conversation.conversation_id { + return Err( + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-agent", + "message": "message document conversationId does not match metadata", + })), + ); + } + let body = serde_json::to_vec(document).map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-agent", + "message": format!("failed to serialize message document: {error}"), + })) + })?; + if body.len() > EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES { + return Err(editor_agent_messages_document_too_large()); + } + + let oss_client = state + .oss_client() + .ok_or_else(editor_agent_oss_unavailable)?; + let put_result = oss_client + .put_object( + &reqwest::Client::new(), + OssPutObjectRequest { + prefix: LegacyAssetPrefix::EditorAgent, + path_segments: Vec::new(), + file_name: format!("{}.json", conversation.conversation_id), + content_type: Some("application/json; charset=utf-8".to_string()), + access: OssObjectAccess::Private, + metadata: BTreeMap::from([ + ( + "conversation-id".to_string(), + conversation.conversation_id.clone(), + ), + ("project-id".to_string(), conversation.project_id.clone()), + ( + "owner-user-id".to_string(), + conversation.owner_user_id.clone(), + ), + ]), + body, + }, + ) + .await + .map_err(|error| map_oss_error(error, "aliyun-oss"))?; + if put_result.object_key != conversation.messages_object_key { + return Err( + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "editor-agent", + "message": "OSS object key mismatch while writing message document", + "expectedObjectKey": conversation.messages_object_key, + "actualObjectKey": put_result.object_key, + })), + ); + } + Ok(()) +} + +#[derive(Clone, Debug, PartialEq)] +struct EditorAgentTurnPlan { + reply_text: String, + tool_call: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct EditorAgentToolCallPlan { + tool_name: EditorAgentToolName, + prompt: String, + summary: Option, + model: Option, + aspect_ratio: Option, + image_size: Option, + icon_descriptions: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EditorAgentRawTurnPlan { + reply_text: Option, + tool_call: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct EditorAgentRawToolCallPlan { + tool_name: EditorAgentToolName, + prompt: Option, + summary: Option, + model: Option, + aspect_ratio: Option, + image_size: Option, + icon_descriptions: Option>, +} + +#[derive(Debug)] +struct EditorAgentToolExecution { + record: EditorAgentGenerationRecord, + summary: String, +} + +#[derive(Debug)] +struct EditorAgentToolExecutionError { + tool_name: EditorAgentToolName, + model: Option, + error: AppError, +} + +async fn plan_editor_agent_turn( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &EditorAgentConversationMessagesDocument, + user_message: &EditorAgentMessage, +) -> EditorAgentTurnPlan { + let fallback = heuristic_editor_agent_turn_plan( + user_message.text.as_str(), + user_message.attachments.as_slice(), + ); + let Some(llm_client) = state.creative_agent_gpt5_client() else { + return fallback; + }; + + let request = build_editor_agent_llm_request(state, conversation, document, user_message).await; + match llm_client.request_text(request).await { + Ok(response) => parse_editor_agent_turn_plan( + response.content.as_str(), + fallback.reply_text.as_str(), + user_message.text.as_str(), + ) + .unwrap_or(fallback), + Err(_) => fallback, + } +} + +async fn build_editor_agent_llm_request( + state: &AppState, + conversation: &EditorAgentConversationRecord, + document: &EditorAgentConversationMessagesDocument, + user_message: &EditorAgentMessage, +) -> LlmTextRequest { + let mut user_parts = vec![LlmMessageContentPart::InputText { + text: build_editor_agent_llm_user_prompt(conversation, document, user_message), + }]; + for attachment in &user_message.attachments { + if let Some(image_url) = sign_editor_agent_attachment_image_url(state, attachment) { + user_parts.push(LlmMessageContentPart::InputImage { image_url }); + } + } + + LlmTextRequest::new(vec![ + LlmMessage::system(editor_agent_llm_system_prompt()), + LlmMessage::user_multimodal(user_parts), + ]) + .with_responses_api() + .with_max_tokens(900) +} + +fn editor_agent_llm_system_prompt() -> String { + [ + "你是 Genarrative 图片画布 Agent,只负责帮助用户理解、规划和触发画布生成工具。", + "必须只输出一个 JSON 对象,不要输出 Markdown、解释或额外文本。", + "JSON 结构:{\"replyText\":\"给用户看的中文回复\",\"toolCall\":null 或 {\"toolName\":\"generate_image|edit_image|generate_character|generate_icon_spritesheet|generate_ui_design\",\"prompt\":\"生成或修改提示词\",\"summary\":\"短动作摘要\",\"model\":null,\"aspectRatio\":null,\"imageSize\":\"1K\",\"iconDescriptions\":[\"图标描述\"]}}。", + "只有用户明确要求生成、重绘、修改、画 UI、出角色或图标时才给 toolCall;普通咨询、评价、解释时 toolCall 为 null。", + "edit_image 和 generate_icon_spritesheet 必须依赖用户附件;没有附件时不要调用这两个工具,在 replyText 中提示先选择参考图。", + "generate_ui_design 表示生成一张 UI 设计图,不是提取 UI 素材。", + "对话回复要简短,不能承诺免费生成;生成工具由后端按模型定价扣泥点。", + ] + .join("\n") +} + +fn build_editor_agent_llm_user_prompt( + conversation: &EditorAgentConversationRecord, + document: &EditorAgentConversationMessagesDocument, + user_message: &EditorAgentMessage, +) -> String { + let history = document + .messages + .iter() + .rev() + .take(EDITOR_AGENT_LLM_MAX_HISTORY_MESSAGES) + .collect::>() + .into_iter() + .rev() + .map(|message| { + json!({ + "role": message.role, + "kind": message.kind, + "text": message.text, + "attachmentCount": message.attachments.len(), + "generationCount": message.generations.len(), + }) + }) + .collect::>(); + let attachments = user_message + .attachments + .iter() + .map(|attachment| { + json!({ + "source": attachment.source, + "referenceId": attachment.reference_id, + "label": attachment.label, + "width": attachment.width, + "height": attachment.height, + "hasObjectKey": attachment.object_key.as_ref().is_some_and(|value| !value.trim().is_empty()), + }) + }) + .collect::>(); + + json!({ + "conversationId": conversation.conversation_id, + "projectId": conversation.project_id, + "latestUserText": user_message.text, + "latestAttachments": attachments, + "recentMessages": history, + }) + .to_string() +} + +fn sign_editor_agent_attachment_image_url( + state: &AppState, + attachment: &EditorAgentAttachmentRef, +) -> Option { + let object_key = attachment + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty())?; + state + .oss_client()? + .sign_get_object_url(OssSignedGetObjectUrlRequest { + object_key: object_key.trim_start_matches('/').to_string(), + expire_seconds: Some(EDITOR_AGENT_LLM_ATTACHMENT_URL_EXPIRE_SECONDS), + }) + .ok() + .map(|signed| signed.signed_url) +} + +fn parse_editor_agent_turn_plan( + raw_text: &str, + fallback_reply_text: &str, + fallback_prompt: &str, +) -> Option { + let json_text = extract_editor_agent_json_object(raw_text)?; + let raw: EditorAgentRawTurnPlan = serde_json::from_str(json_text).ok()?; + let reply_text = normalize_optional_string(raw.reply_text) + .or_else(|| normalize_optional_string(Some(fallback_reply_text.to_string()))) + .unwrap_or_else(|| build_echo_assistant_text(fallback_prompt, 0)); + let tool_call = raw.tool_call.and_then(|tool| { + let prompt = normalize_optional_string(tool.prompt) + .or_else(|| normalize_optional_string(Some(fallback_prompt.to_string())))?; + Some(EditorAgentToolCallPlan { + tool_name: tool.tool_name, + prompt, + summary: normalize_optional_string(tool.summary), + model: normalize_optional_string(tool.model), + aspect_ratio: normalize_optional_string(tool.aspect_ratio), + image_size: normalize_optional_string(tool.image_size), + icon_descriptions: tool + .icon_descriptions + .unwrap_or_default() + .into_iter() + .filter_map(|description| normalize_optional_string(Some(description))) + .collect(), + }) + }); + + Some(EditorAgentTurnPlan { + reply_text, + tool_call, + }) +} + +fn extract_editor_agent_json_object(raw_text: &str) -> Option<&str> { + let trimmed = raw_text.trim(); + let without_fence = if let Some(rest) = trimmed.strip_prefix("```json") { + rest.strip_suffix("```").unwrap_or(rest).trim() + } else if let Some(rest) = trimmed.strip_prefix("```") { + rest.strip_suffix("```").unwrap_or(rest).trim() + } else { + trimmed + }; + let start = without_fence.find('{')?; + let end = without_fence.rfind('}')?; + (start <= end).then_some(&without_fence[start..=end]) +} + +fn heuristic_editor_agent_turn_plan( + text: &str, + attachments: &[EditorAgentAttachmentRef], +) -> EditorAgentTurnPlan { + let trimmed = text.trim(); + let normalized = trimmed.to_lowercase(); + let wants_generation = contains_any( + normalized.as_str(), + &[ + "生成", "出图", "绘制", "设计", "画一", "画个", "画张", "generate", "create", + ], + ); + let wants_edit = contains_any( + normalized.as_str(), + &[ + "修改", "改成", "重绘", "换成", "编辑", "优化", "edit", "redraw", + ], + ); + let wants_character = contains_any( + normalized.as_str(), + &["角色", "人物", "立绘", "形象", "character"], + ); + let wants_icons = contains_any( + normalized.as_str(), + &["图标", "icon", "spritesheet", "素材图集"], + ); + let wants_ui = contains_any( + normalized.as_str(), + &["ui", "界面", "hud", "按钮", "面板", "弹窗"], + ); + + let tool_name = if wants_icons && !attachments.is_empty() { + Some(EditorAgentToolName::GenerateIconSpritesheet) + } else if wants_edit && !attachments.is_empty() { + Some(EditorAgentToolName::EditImage) + } else if wants_character && wants_generation { + Some(EditorAgentToolName::GenerateCharacter) + } else if wants_ui && wants_generation { + Some(EditorAgentToolName::GenerateUiDesign) + } else if wants_generation { + Some(EditorAgentToolName::GenerateImage) + } else { + None + }; + + let reply_text = match tool_name { + Some(EditorAgentToolName::EditImage) => "我来按你的要求修改这张图。".to_string(), + Some(EditorAgentToolName::GenerateCharacter) => "我来生成角色形象。".to_string(), + Some(EditorAgentToolName::GenerateIconSpritesheet) => "我来生成图标素材图集。".to_string(), + Some(EditorAgentToolName::GenerateUiDesign) => "我来生成 UI 设计图。".to_string(), + Some(EditorAgentToolName::GenerateImage) => "我来生成图片。".to_string(), + None => build_echo_assistant_text(trimmed, attachments.len()), + }; + let tool_call = tool_name.map(|tool_name| EditorAgentToolCallPlan { + tool_name, + prompt: if trimmed.is_empty() { + "根据附件生成适合当前画布的图片".to_string() + } else { + trimmed.to_string() + }, + summary: Some(editor_agent_tool_default_summary(tool_name)), + model: None, + aspect_ratio: None, + image_size: Some("1K".to_string()), + icon_descriptions: derive_icon_descriptions(trimmed), + }); + + EditorAgentTurnPlan { + reply_text, + tool_call, + } +} + +fn contains_any(text: &str, needles: &[&str]) -> bool { + needles.iter().any(|needle| text.contains(needle)) +} + +fn derive_icon_descriptions(prompt: &str) -> Vec { + let normalized = prompt.trim(); + if normalized.is_empty() { + return vec![ + "主按钮图标".to_string(), + "奖励图标".to_string(), + "关闭图标".to_string(), + ]; + } + normalized + .split(['、', ',', ',', '\n', ';', ';']) + .map(str::trim) + .filter(|item| !item.is_empty()) + .take(8) + .map(ToOwned::to_owned) + .collect::>() + .into_iter() + .filter(|item| item.chars().count() <= 80) + .collect::>() + .pipe_non_empty_or_else(|| vec![normalized.to_string()]) +} + +trait VecPipeNonEmpty { + fn pipe_non_empty_or_else(self, fallback: F) -> Self + where + F: FnOnce() -> Self; +} + +impl VecPipeNonEmpty for Vec { + fn pipe_non_empty_or_else(self, fallback: F) -> Self + where + F: FnOnce() -> Self, + { + if self.is_empty() { fallback() } else { self } + } +} + +async fn execute_editor_agent_tool_call( + state: &AppState, + request_context: &RequestContext, + conversation: &EditorAgentConversationRecord, + user_message: &EditorAgentMessage, + tool_call_id: &str, + tool_call: EditorAgentToolCallPlan, +) -> Result { + let project = state + .spacetime_client() + .get_editor_project(EditorProjectGetRecordInput { + project_id: conversation.project_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + }) + .await + .map_err(map_editor_project_error) + .map_err(|error| EditorAgentToolExecutionError { + tool_name: tool_call.tool_name, + model: tool_call.model.clone(), + error, + })?; + let completion = + build_editor_agent_canvas_completion(&project, tool_call.tool_name, &tool_call.prompt); + let caller = EditorGenerationCaller { + owner_user_id: conversation.owner_user_id.clone(), + audit_subject_user_id: Some(conversation.owner_user_id.clone()), + audit_project_id: Some(conversation.project_id.clone()), + }; + let tool_request_context = editor_agent_tool_request_context(request_context, tool_call_id); + let attachment_sources = editor_agent_attachment_sources(user_message.attachments.as_slice()); + let generation_inputs = Some(json!({ + "source": "editor-agent", + "conversationId": conversation.conversation_id, + "messageId": user_message.id, + "toolCallId": tool_call_id, + "fields": [{ "title": "用户指令", "value": tool_call.prompt }], + "references": user_message.attachments.iter().map(|attachment| json!({ + "title": attachment.label.clone().unwrap_or_else(|| "对话附件".to_string()), + "label": attachment.label, + "refType": match attachment.source { + EditorAgentAttachmentSource::CanvasResource => "project-resource", + EditorAgentAttachmentSource::LibraryAsset => "asset", + }, + "refId": attachment.reference_id, + })).collect::>(), + })); + + let tool_name = tool_call.tool_name; + let tool_model = tool_call.model.clone(); + let response = match tool_name { + EditorAgentToolName::GenerateImage + | EditorAgentToolName::GenerateCharacter + | EditorAgentToolName::GenerateUiDesign => { + let kind = match tool_name { + EditorAgentToolName::GenerateCharacter => Some("character".to_string()), + EditorAgentToolName::GenerateUiDesign => Some("ui-design".to_string()), + _ => None, + }; + generate_editor_image_for_owner( + state, + &tool_request_context, + caller, + EditorImageGenerationRequest { + prompt: tool_call.prompt.clone(), + size: None, + kind, + model: tool_call.model.clone(), + aspect_ratio: tool_call.aspect_ratio.clone(), + image_size: tool_call.image_size.clone(), + reference_image_srcs: Some(attachment_sources), + project_id: Some(conversation.project_id.clone()), + asset_kind: Some(editor_agent_tool_asset_kind(tool_name).to_string()), + generation_inputs, + asset_folder_id: None, + asset_label: tool_call.summary.clone(), + source_resource_id: user_message + .attachments + .first() + .map(|attachment| attachment.reference_id.clone()), + canvas_completion: Some(completion), + }, + ) + .await + } + EditorAgentToolName::EditImage => { + let (source_image_src, reference_image_srcs) = + split_editor_agent_edit_sources(attachment_sources).ok_or_else(|| { + EditorAgentToolExecutionError { + tool_name, + model: tool_model.clone(), + error: editor_agent_bad_request( + "修改图片需要先选择一张画布或素材库图片附件", + ), + } + })?; + edit_editor_image_for_owner( + state, + &tool_request_context, + caller, + EditorImageEditRequest { + prompt: tool_call.prompt.clone(), + source_image_src, + size: None, + model: tool_call.model.clone(), + reference_image_srcs: Some(reference_image_srcs), + project_id: Some(conversation.project_id.clone()), + asset_kind: Some(editor_agent_tool_asset_kind(tool_name).to_string()), + generation_inputs, + asset_folder_id: None, + asset_label: tool_call.summary.clone(), + source_resource_id: user_message + .attachments + .first() + .map(|attachment| attachment.reference_id.clone()), + target_layer_id: None, + canvas_completion: Some(completion), + }, + ) + .await + } + EditorAgentToolName::GenerateIconSpritesheet => { + let (reference_image_src, extra_references) = + split_editor_agent_edit_sources(attachment_sources).ok_or_else(|| { + EditorAgentToolExecutionError { + tool_name, + model: tool_model.clone(), + error: editor_agent_bad_request("生成图标素材需要先选择一张图标规范参考图"), + } + })?; + let icon_descriptions = if tool_call.icon_descriptions.is_empty() { + derive_icon_descriptions(tool_call.prompt.as_str()) + } else { + tool_call.icon_descriptions.clone() + }; + generate_editor_icon_spritesheet_for_owner( + state, + &tool_request_context, + caller, + EditorIconSpritesheetGenerationRequest { + reference_image_src, + reference_image_srcs: Some(extra_references), + icon_descriptions, + model: tool_call.model.clone(), + aspect_ratio: tool_call.aspect_ratio.clone(), + image_size: tool_call.image_size.clone(), + project_id: Some(conversation.project_id.clone()), + generation_inputs, + asset_folder_id: None, + canvas_completion: Some(completion), + }, + ) + .await + } + } + .map_err(|error| EditorAgentToolExecutionError { + tool_name, + model: tool_model.clone(), + error, + })?; + + Ok(build_editor_agent_tool_execution( + tool_call_id, + tool_name, + tool_call.summary, + response.0, + )) +} + +fn editor_agent_tool_request_context(base: &RequestContext, tool_call_id: &str) -> RequestContext { + RequestContext::new( + format!("{}:{tool_call_id}", base.request_id()), + format!("{} editor-agent-tool", base.operation()), + std::time::Duration::ZERO, + false, + ) +} + +fn build_editor_agent_tool_execution( + tool_call_id: &str, + tool_name: EditorAgentToolName, + summary: Option, + response_body: Value, +) -> EditorAgentToolExecution { + let data = response_body + .get("data") + .filter(|_| response_body.get("ok").and_then(Value::as_bool) == Some(true)) + .unwrap_or(&response_body); + let images = editor_agent_generated_images_from_response(tool_name, data); + let task_id = data + .get("taskId") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + images + .iter() + .find_map(|image| image.resource_id.as_ref().map(|_| ())) + .and_then(|_| { + data.get("taskId") + .and_then(Value::as_str) + .map(str::to_string) + }) + }); + let model = data + .get("model") + .and_then(Value::as_str) + .map(str::to_string); + + EditorAgentToolExecution { + record: EditorAgentGenerationRecord { + tool_call_id: tool_call_id.to_string(), + tool_name, + task_id, + status: EditorAgentGenerationStatus::Completed, + model, + images, + error: None, + }, + summary: summary.unwrap_or_else(|| editor_agent_tool_default_summary(tool_name)), + } +} + +fn editor_agent_generated_images_from_response( + tool_name: EditorAgentToolName, + data: &Value, +) -> Vec { + if tool_name == EditorAgentToolName::GenerateIconSpritesheet { + let mut images = Vec::new(); + if let Some(resource) = data.get("spritesheetResource") { + images.push(editor_agent_generated_image_from_resource( + resource, + data.get("spritesheetImageSrc").and_then(Value::as_str), + data.get("spritesheetWidth").and_then(Value::as_u64), + data.get("spritesheetHeight").and_then(Value::as_u64), + )); + } + if let Some(icon_images) = data.get("iconImageSrcs").and_then(Value::as_array) { + for icon in icon_images { + if let Some(resource) = icon.get("resource") { + images.push(editor_agent_generated_image_from_resource( + resource, + icon.get("imageSrc").and_then(Value::as_str), + icon.get("width").and_then(Value::as_u64), + icon.get("height").and_then(Value::as_u64), + )); + } + } + } + return images; + } + + data.get("resource") + .map(|resource| { + vec![editor_agent_generated_image_from_resource( + resource, + data.get("imageSrc").and_then(Value::as_str), + data.get("width").and_then(Value::as_u64), + data.get("height").and_then(Value::as_u64), + )] + }) + .unwrap_or_else(|| { + data.get("imageSrc") + .and_then(Value::as_str) + .map(|image_src| { + vec![EditorAgentGeneratedImage { + resource_id: None, + image_src: image_src.to_string(), + thumbnail_src: None, + width: data + .get("width") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), + height: data + .get("height") + .and_then(Value::as_u64) + .and_then(|value| u32::try_from(value).ok()), + }] + }) + .unwrap_or_default() + }) +} + +fn editor_agent_generated_image_from_resource( + resource: &Value, + fallback_image_src: Option<&str>, + fallback_width: Option, + fallback_height: Option, +) -> EditorAgentGeneratedImage { + EditorAgentGeneratedImage { + resource_id: resource + .get("resourceId") + .and_then(Value::as_str) + .map(str::to_string), + image_src: resource + .get("imageSrc") + .and_then(Value::as_str) + .or(fallback_image_src) + .unwrap_or("") + .to_string(), + thumbnail_src: resource + .get("thumbnailSrc") + .and_then(Value::as_str) + .map(str::to_string), + width: resource + .get("width") + .and_then(Value::as_u64) + .or(fallback_width) + .and_then(|value| u32::try_from(value).ok()), + height: resource + .get("height") + .and_then(Value::as_u64) + .or(fallback_height) + .and_then(|value| u32::try_from(value).ok()), + } +} + +fn editor_agent_attachment_sources(attachments: &[EditorAgentAttachmentRef]) -> Vec { + attachments + .iter() + .filter_map(|attachment| { + attachment + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .or_else(|| normalize_required_string(attachment.reference_id.as_str())) + }) + .collect() +} + +fn split_editor_agent_edit_sources(sources: Vec) -> Option<(String, Vec)> { + let mut iter = sources.into_iter(); + let first = iter.next()?; + Some((first, iter.collect())) +} + +fn editor_agent_tool_asset_kind(tool_name: EditorAgentToolName) -> &'static str { + match tool_name { + EditorAgentToolName::GenerateCharacter => "character", + EditorAgentToolName::GenerateUiDesign => "ui-design", + EditorAgentToolName::EditImage => "editor_agent_edit_image", + EditorAgentToolName::GenerateIconSpritesheet => "icon-spritesheet", + EditorAgentToolName::GenerateImage => "editor_agent_generated_image", + } +} + +fn editor_agent_tool_default_summary(tool_name: EditorAgentToolName) -> String { + match tool_name { + EditorAgentToolName::GenerateImage => "生成图片", + EditorAgentToolName::EditImage => "修改图片", + EditorAgentToolName::GenerateCharacter => "生成角色形象", + EditorAgentToolName::GenerateIconSpritesheet => "生成图标素材", + EditorAgentToolName::GenerateUiDesign => "生成 UI 设计图", + } + .to_string() +} + +fn build_editor_agent_canvas_completion( + project: &EditorProjectRecord, + tool_name: EditorAgentToolName, + title: &str, +) -> EditorCanvasGenerationCompletionPayload { + let (width, height) = editor_agent_tool_display_size(tool_name); + let (x, y) = next_editor_agent_canvas_position(project.layers.clone(), width, height); + EditorCanvasGenerationCompletionPayload { + dialog_id: None, + title: normalize_optional_string(Some(title.to_string())) + .unwrap_or_else(|| editor_agent_tool_default_summary(tool_name)), + placeholder: EditorCanvasGenerationPlaceholderPayload { + x, + y, + width, + height, + original_width: width, + original_height: height, + }, + } +} + +fn editor_agent_tool_display_size(tool_name: EditorAgentToolName) -> (f64, f64) { + match tool_name { + EditorAgentToolName::GenerateUiDesign => (640.0, 360.0), + EditorAgentToolName::GenerateCharacter => (512.0, 768.0), + _ => (512.0, 512.0), + } +} + +fn next_editor_agent_canvas_position(layers: Value, _width: f64, _height: f64) -> (f64, f64) { + let mut max_right: Option = None; + let mut min_y: Option = None; + if let Value::Array(items) = layers { + for item in items { + if item.get("itemType").and_then(Value::as_str) == Some("generation-dialog") { + continue; + } + let Some(x) = item.get("x").and_then(Value::as_f64) else { + continue; + }; + let Some(y) = item.get("y").and_then(Value::as_f64) else { + continue; + }; + let width = item.get("width").and_then(Value::as_f64).unwrap_or(0.0); + if !x.is_finite() || !y.is_finite() || !width.is_finite() { + continue; + } + max_right = Some(max_right.map_or(x + width, |value| value.max(x + width))); + min_y = Some(min_y.map_or(y, |value| value.min(y))); + } + } + ( + max_right.map_or(0.0, |right| right + EDITOR_AGENT_CANVAS_RESULT_GAP), + min_y.unwrap_or(0.0), + ) +} + +fn build_echo_assistant_text(text: &str, attachment_count: usize) -> String { + let trimmed = text.trim(); + if trimmed.is_empty() { + return format!("已收到 {attachment_count} 个附件。"); + } + format!("收到:{trimmed}") +} + +fn now_rfc3339() -> String { + shared_kernel::format_rfc3339(time::OffsetDateTime::now_utc()) + .unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string()) +} + +fn editor_agent_bad_request(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": "editor-agent", + "message": message.into(), + })) +} + +fn editor_agent_oss_unavailable() -> AppError { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS is not configured for editor agent conversations", + })) +} + +fn editor_agent_oss_read_error(message: impl Into) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": message.into(), + })) +} + +fn editor_agent_messages_document_too_large() -> AppError { + AppError::from_status(StatusCode::PAYLOAD_TOO_LARGE).with_details(json!({ + "provider": "editor-agent", + "message": "message document is too large", + "maxBytes": EDITOR_AGENT_MESSAGES_DOCUMENT_MAX_BYTES, + })) +} + +fn editor_agent_sse_json_event_or_error(event_name: &str, payload: T) -> Event +where + T: Serialize, +{ + match serde_json::to_string(&payload) { + Ok(data) => Event::default().event(event_name).data(data), + Err(error) => Event::default().event("error").data( + serde_json::to_string(&EditorAgentErrorEvent { + conversation_id: None, + code: "SERIALIZE_EVENT_FAILED".to_string(), + message: error.to_string(), + recoverable: false, + }) + .unwrap_or_else(|_| "{}".to_string()), + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn echo_assistant_text_uses_attachment_fallback() { + assert_eq!( + build_echo_assistant_text(" 画一棵树 ", 0), + "收到:画一棵树" + ); + assert_eq!(build_echo_assistant_text(" ", 2), "已收到 2 个附件。"); + } + + #[test] + fn empty_document_uses_contract_version() { + let document = empty_messages_document("editor-agent-conv-1"); + assert_eq!(document.version, EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION); + assert_eq!(document.conversation_id, "editor-agent-conv-1"); + assert!(document.messages.is_empty()); + } + + #[test] + fn canvas_attachment_normalization_uses_project_resource_truth() { + let conversation = test_conversation_record(); + let attachment = EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: " resource-1 ".to_string(), + object_key: Some("generated-editor-assets/resource-1.png".to_string()), + image_src: "https://client.example/stale.png".to_string(), + thumbnail_src: Some("https://client.example/thumb.png".to_string()), + label: Some(" 参考图 ".to_string()), + width: Some(1), + height: Some(1), + }; + let resource = test_project_resource_record(); + + let normalized = + normalize_canvas_resource_attachment(&conversation, &attachment, &resource) + .expect("attachment should normalize"); + + assert_eq!(normalized.reference_id, "resource-1"); + assert_eq!( + normalized.object_key.as_deref(), + Some("generated-editor-assets/resource-1.png") + ); + assert_eq!(normalized.image_src, "/api/assets/read/resource-1.png"); + assert_eq!(normalized.thumbnail_src, None); + assert_eq!(normalized.label.as_deref(), Some("参考图")); + assert_eq!(normalized.width, Some(512)); + assert_eq!(normalized.height, Some(256)); + } + + #[test] + fn attachment_normalization_rejects_mismatched_object_key() { + let conversation = test_conversation_record(); + let attachment = EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: "resource-1".to_string(), + object_key: Some("generated-editor-assets/other.png".to_string()), + image_src: "/api/assets/read/resource-1.png".to_string(), + thumbnail_src: None, + label: None, + width: None, + height: None, + }; + let resource = test_project_resource_record(); + + assert!( + normalize_canvas_resource_attachment(&conversation, &attachment, &resource).is_err() + ); + } + + #[test] + fn library_attachment_normalization_uses_asset_label_fallback() { + let attachment = EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::LibraryAsset, + reference_id: "asset-1".to_string(), + object_key: None, + image_src: "https://client.example/stale.png".to_string(), + thumbnail_src: None, + label: None, + width: None, + height: None, + }; + let asset = EditorAssetRecord { + asset_id: "asset-1".to_string(), + folder_id: "project".to_string(), + label: "素材库图".to_string(), + asset_object_id: Some("asset-object-1".to_string()), + image_src: "/api/assets/read/asset-1.png".to_string(), + object_key: Some("generated-editor-assets/asset-1.png".to_string()), + width: 128, + height: 128, + source_type: "uploaded".to_string(), + prompt: None, + actual_prompt: None, + model: None, + provider: None, + task_id: None, + asset_kind: None, + generation_inputs: None, + source_resource_id: None, + public_showcase_enabled: None, + thumbnail_src: Some("/api/assets/read/asset-1-thumb.png".to_string()), + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:00Z".to_string(), + }; + + let normalized = normalize_library_asset_attachment(&attachment, &asset) + .expect("library attachment should normalize"); + + assert_eq!(normalized.label.as_deref(), Some("素材库图")); + assert_eq!( + normalized.thumbnail_src.as_deref(), + Some("/api/assets/read/asset-1-thumb.png") + ); + assert_eq!(normalized.width, Some(128)); + assert_eq!(normalized.height, Some(128)); + } + + #[test] + fn editor_agent_turn_plan_parses_fenced_json_tool_call() { + let plan = parse_editor_agent_turn_plan( + r#"```json +{ + "replyText": "我来生成一张像素风角色图。", + "toolCall": { + "toolName": "generate_character", + "prompt": "像素风银发游侠", + "summary": "生成像素角色", + "imageSize": "1K" + } +} +```"#, + "fallback", + "像素风银发游侠", + ) + .expect("fenced JSON should parse"); + + assert_eq!(plan.reply_text, "我来生成一张像素风角色图。"); + let tool_call = plan.tool_call.expect("tool call should exist"); + assert_eq!(tool_call.tool_name, EditorAgentToolName::GenerateCharacter); + assert_eq!(tool_call.prompt, "像素风银发游侠"); + assert_eq!(tool_call.summary.as_deref(), Some("生成像素角色")); + assert_eq!(tool_call.image_size.as_deref(), Some("1K")); + } + + #[test] + fn editor_agent_heuristic_routes_tools_from_user_text() { + let character_plan = + heuristic_editor_agent_turn_plan("帮我生成一个角色形象:猫耳骑士", &[]); + assert_eq!( + character_plan + .tool_call + .as_ref() + .expect("character tool expected") + .tool_name, + EditorAgentToolName::GenerateCharacter + ); + + let edit_plan = heuristic_editor_agent_turn_plan( + "把附件这张图改成赛博朋克风格", + &[EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: "resource-1".to_string(), + object_key: Some("generated-editor-assets/resource-1.png".to_string()), + image_src: "/generated-editor-assets/resource-1.png".to_string(), + thumbnail_src: None, + label: None, + width: Some(512), + height: Some(512), + }], + ); + assert_eq!( + edit_plan + .tool_call + .as_ref() + .expect("edit tool expected") + .tool_name, + EditorAgentToolName::EditImage + ); + + let chat_plan = heuristic_editor_agent_turn_plan("这张图适合做头像吗?", &[]); + assert!(chat_plan.tool_call.is_none()); + } + + #[test] + fn editor_agent_canvas_completion_places_result_after_existing_layers() { + let project = EditorProjectRecord { + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + title: "画布".to_string(), + canvas: spacetime_client::EditorCanvasRecord { + canvas_id: "canvas-1".to_string(), + project_id: "project-1".to_string(), + title: "画布".to_string(), + viewport: spacetime_client::EditorCanvasViewportRecord { + x: 0.0, + y: 0.0, + scale: 1.0, + }, + layers: json!([]), + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:00Z".to_string(), + }, + viewport: spacetime_client::EditorCanvasViewportRecord { + x: 0.0, + y: 0.0, + scale: 1.0, + }, + layers: json!([ + { + "layerId": "layer-1", + "resourceId": "resource-1", + "x": 10.0, + "y": 20.0, + "width": 180.0, + "height": 120.0 + } + ]), + resources: Vec::new(), + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:00Z".to_string(), + }; + + let completion = build_editor_agent_canvas_completion( + &project, + EditorAgentToolName::GenerateUiDesign, + "UI 设计图", + ); + + assert_eq!(completion.dialog_id, None); + assert_eq!(completion.title, "UI 设计图"); + assert_eq!(completion.placeholder.x, 222.0); + assert_eq!(completion.placeholder.y, 20.0); + assert_eq!(completion.placeholder.width, 640.0); + assert_eq!(completion.placeholder.height, 360.0); + } + + fn test_conversation_record() -> EditorAgentConversationRecord { + EditorAgentConversationRecord { + conversation_id: "editor-agent-conv-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + title: "新对话".to_string(), + messages_object_key: "editor-agent/editor-agent-conv-1.json".to_string(), + deleted: false, + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:00Z".to_string(), + updated_at_micros: 1, + } + } + + fn test_project_resource_record() -> EditorProjectResourceRecord { + EditorProjectResourceRecord { + resource_id: "resource-1".to_string(), + project_id: "project-1".to_string(), + owner_user_id: "user-1".to_string(), + asset_object_id: Some("asset-object-1".to_string()), + image_src: "/api/assets/read/resource-1.png".to_string(), + object_key: Some("generated-editor-assets/resource-1.png".to_string()), + width: 512, + height: 256, + source_type: "generated".to_string(), + prompt: Some("森林".to_string()), + actual_prompt: None, + model: Some("gpt-image-2".to_string()), + provider: Some("VectorEngine".to_string()), + task_id: Some("task-1".to_string()), + source_resource_id: None, + asset_kind: Some("editor_generated_image".to_string()), + generation_inputs: None, + public_showcase_enabled: false, + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:00Z".to_string(), + } + } +} diff --git a/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs b/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs index 94cf2b68f..37f08aa4d 100644 --- a/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs +++ b/server-rs/crates/api-server/src/jump_hop_atlas_slicing.rs @@ -11,10 +11,9 @@ use serde_json::json; use crate::{ http_error::AppError, jump_hop::{ - JumpHopTileAtlasSlice, JumpHopTileFaceSlice, JumpHopTileFaceSlices, JUMP_HOP_CREATION_PROVIDER, JUMP_HOP_TILE_UV_FACE_COLS, JUMP_HOP_TILE_UV_FACE_ROWS, - crop_jump_hop_tile_texture_cell, jump_hop_tile_face_key_label, - jump_hop_tile_type_by_index, + JumpHopTileAtlasSlice, JumpHopTileFaceSlice, JumpHopTileFaceSlices, + crop_jump_hop_tile_texture_cell, jump_hop_tile_face_key_label, jump_hop_tile_type_by_index, }, openai_image_generation::DownloadedOpenAiImage, }; @@ -137,11 +136,7 @@ fn enforce_monotonic(boundaries: &mut [u32]) { /// * `radius` - 搜索半径 /// /// 返回精修后的分界线位置(不含 0 和 max)。 -pub(crate) fn refine_boundaries_seed( - density: &[f32], - seeds: &[u32], - radius: u32, -) -> Vec { +pub(crate) fn refine_boundaries_seed(density: &[f32], seeds: &[u32], radius: u32) -> Vec { let mut refined = Vec::with_capacity(seeds.len()); for &seed in seeds { let pos = find_min_density_position(density, seed, radius); @@ -229,10 +224,7 @@ fn gaussian_smooth_1d(signal: &[f32], sigma: f32) -> Vec { } /// 低于阈值的连续区间 → 候选谷列表 -fn extract_valleys_below_threshold( - signal: &[f32], - threshold: f32, -) -> Vec<(usize, usize)> { +fn extract_valleys_below_threshold(signal: &[f32], threshold: f32) -> Vec<(usize, usize)> { let n = signal.len(); let mut valleys = Vec::new(); let mut in_valley = false; @@ -256,10 +248,7 @@ fn extract_valleys_below_threshold( } /// 合并间距 < min_gap 的相邻谷 -fn merge_close_valleys( - valleys: &[(usize, usize)], - min_gap: usize, -) -> Vec<(usize, usize)> { +fn merge_close_valleys(valleys: &[(usize, usize)], min_gap: usize) -> Vec<(usize, usize)> { if valleys.is_empty() { return vec![]; } @@ -286,11 +275,7 @@ fn valley_centers(valleys: &[(usize, usize)]) -> Vec { } /// 滑窗选最优 target_count 个谷:枚举连续组合,选间距最均匀的一组 -fn select_spaced_valleys( - centers: &[u32], - expected_spacing: f32, - target_count: usize, -) -> Vec { +fn select_spaced_valleys(centers: &[u32], expected_spacing: f32, target_count: usize) -> Vec { if centers.len() <= target_count { return centers.to_vec(); } @@ -365,7 +350,10 @@ pub(crate) fn refine_boundaries_valley( .collect(); scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal)); scored.truncate(expected_valleys + 2); - let mut c: Vec = scored.into_iter().map(|(_, center)| center as u32).collect(); + let mut c: Vec = scored + .into_iter() + .map(|(_, center)| center as u32) + .collect(); c.sort(); c } else { @@ -459,27 +447,28 @@ pub(crate) fn slice_tile_atlas_adaptive( detect_cell_grid_seed(pixels, width, height, rows, cols) } AtlasSliceAlgorithm::ValleyDetection => { - detect_cell_grid_valley(pixels, width, height, rows, cols) - .unwrap_or_else(|_| { - // 谷检测失败时回退到种子点精修 - detect_cell_grid_seed(pixels, width, height, rows, cols) - }) + detect_cell_grid_valley(pixels, width, height, rows, cols).unwrap_or_else(|_| { + // 谷检测失败时回退到种子点精修 + detect_cell_grid_seed(pixels, width, height, rows, cols) + }) } }; if grid.row_boundaries.len() != (rows + 1) as usize || grid.col_boundaries.len() != (cols + 1) as usize { - return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": JUMP_HOP_CREATION_PROVIDER, - "message": format!( - "自适应网格检测结果异常:期望 {}×{},实际 {}×{}", - rows + 1, - cols + 1, - grid.row_boundaries.len(), - grid.col_boundaries.len(), - ), - }))); + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!( + "自适应网格检测结果异常:期望 {}×{},实际 {}×{}", + rows + 1, + cols + 1, + grid.row_boundaries.len(), + grid.col_boundaries.len(), + ), + })), + ); } let tile_count = (rows * cols) as usize; @@ -540,22 +529,70 @@ fn slice_jump_hop_tile_uv_faces_adaptive( Ok(JumpHopTileFaceSlices { top: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Top, + 1, + 0, )?, front: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Front, + 1, + 1, )?, right: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Right, + 2, + 1, )?, back: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Back, + 3, + 1, )?, left: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Left, + 0, + 1, )?, bottom: slice_jump_hop_tile_uv_face_adaptive( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Bottom, + 1, + 2, )?, }) } @@ -592,12 +629,7 @@ fn slice_jump_hop_tile_uv_face_adaptive( Ok(JumpHopTileFaceSlice { face, - source_atlas_cell: format!( - "row-{}-col-{}/{}", - atlas_row + 1, - atlas_col + 1, - face_label - ), + source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, face_label), bytes: cursor.into_inner(), }) } @@ -619,7 +651,10 @@ const GRAD_SMOOTH: usize = 3; fn build_cleaned_tile( source: &image::RgbaImage, - tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32, + tile_x: u32, + tile_y: u32, + tile_w: u32, + tile_h: u32, ) -> Option { let pixels = source.as_raw(); let sw = source.width() as usize; @@ -634,25 +669,35 @@ fn build_cleaned_tile( for sy in 0..tile_h { for sx in 0..tile_w { let si = idx(sx, sy); - if visited[si] { continue; } + if visited[si] { + continue; + } let go = (tile_y as usize + sy as usize) * stride + (tile_x as usize + sx as usize) * 4; - if pixels[go + 3] < BLOB_ALPHA { continue; } + if pixels[go + 3] < BLOB_ALPHA { + continue; + } queue.clear(); queue.push(si); visited[si] = true; let mut qi = 0; while qi < queue.len() { - let cur = queue[qi]; qi += 1; + let cur = queue[qi]; + qi += 1; let cx = cur as u32 % tile_w; let cy = cur as u32 / tile_w; - for (dx, dy) in [(1i32,0i32),(-1,0),(0,1),(0,-1)] { + for (dx, dy) in [(1i32, 0i32), (-1, 0), (0, 1), (0, -1)] { let nx = cx as i32 + dx; let ny = cy as i32 + dy; - if nx < 0 || nx >= tile_w as i32 || ny < 0 || ny >= tile_h as i32 { continue; } + if nx < 0 || nx >= tile_w as i32 || ny < 0 || ny >= tile_h as i32 { + continue; + } let ni = idx(nx as u32, ny as u32); - if visited[ni] { continue; } - let ngo = (tile_y as usize + ny as usize) * stride + (tile_x as usize + nx as usize) * 4; + if visited[ni] { + continue; + } + let ngo = (tile_y as usize + ny as usize) * stride + + (tile_x as usize + nx as usize) * 4; if pixels[ngo + 3] >= BLOB_ALPHA { visited[ni] = true; queue.push(ni); @@ -660,14 +705,18 @@ fn build_cleaned_tile( } } let area = queue.len(); - if area < MIN_BLOB_AREA { continue; } + if area < MIN_BLOB_AREA { + continue; + } if area > best_comp.len() { best_comp = queue.clone(); } } } - if best_comp.is_empty() { return None; } + if best_comp.is_empty() { + return None; + } // 构造 cleaned 图像:仅含主 blob let mut cleaned = image::RgbaImage::new(tile_w, tile_h); @@ -677,7 +726,11 @@ fn build_cleaned_tile( let gx = tile_x + lx; let gy = tile_y + ly; let go = (gy as usize * sw + gx as usize) * 4; - cleaned.put_pixel(lx, ly, image::Rgba([pixels[go], pixels[go+1], pixels[go+2], pixels[go+3]])); + cleaned.put_pixel( + lx, + ly, + image::Rgba([pixels[go], pixels[go + 1], pixels[go + 2], pixels[go + 3]]), + ); } Some(cleaned) } @@ -685,108 +738,161 @@ fn build_cleaned_tile( // ---- 2. density + gradient → 边界 ---- fn smooth_1d(signal: &[f32], window: usize) -> Vec { - if signal.len() <= window { return signal.to_vec(); } + if signal.len() <= window { + return signal.to_vec(); + } let hw = window / 2; - (0..signal.len()).map(|i| { - let lo = i.saturating_sub(hw); - let hi = (i + hw).min(signal.len() - 1); - let sum: f32 = signal[lo..=hi].iter().sum(); - sum / (hi - lo + 1) as f32 - }).collect() + (0..signal.len()) + .map(|i| { + let lo = i.saturating_sub(hw); + let hi = (i + hw).min(signal.len() - 1); + let sum: f32 = signal[lo..=hi].iter().sum(); + sum / (hi - lo + 1) as f32 + }) + .collect() } fn gradient(signal: &[f32]) -> Vec { - if signal.len() < 2 { return vec![]; } - (0..signal.len()-1).map(|i| signal[i+1] - signal[i]).collect() + if signal.len() < 2 { + return vec![]; + } + (0..signal.len() - 1) + .map(|i| signal[i + 1] - signal[i]) + .collect() } /// 在 gradient 中找最强上升沿 (positive) 和最强下降沿 (negative) 的位置。 /// 返回 (peak_idx_pos, peak_idx_neg) 中最显著的 4 个位置,按值大小排序。 -struct GradPeak { idx: usize, val: f32 } +struct GradPeak { + idx: usize, + val: f32, +} fn find_gradient_peaks(grad: &[f32], count: usize, min_sep: usize) -> Vec { - if grad.len() < 2 { return vec![]; } + if grad.len() < 2 { + return vec![]; + } // 取绝对值后找局部极大 let abs_grad: Vec = grad.iter().map(|&g| g.abs()).collect(); - let mut peaks: Vec = (1..abs_grad.len()-1) - .filter(|&i| abs_grad[i] > abs_grad[i-1] && abs_grad[i] >= abs_grad[i+1] && abs_grad[i] > 0.0) - .map(|i| GradPeak { idx: i, val: grad[i] }) // 保留符号 + let mut peaks: Vec = (1..abs_grad.len() - 1) + .filter(|&i| { + abs_grad[i] > abs_grad[i - 1] && abs_grad[i] >= abs_grad[i + 1] && abs_grad[i] > 0.0 + }) + .map(|i| GradPeak { + idx: i, + val: grad[i], + }) // 保留符号 .collect(); - peaks.sort_by(|a, b| b.val.abs().partial_cmp(&a.val.abs()).unwrap_or(std::cmp::Ordering::Equal)); + peaks.sort_by(|a, b| { + b.val + .abs() + .partial_cmp(&a.val.abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }); // 去重:间距小于 min_sep 的只保留最大者 let mut chosen = Vec::new(); for p in peaks { - if chosen.iter().all(|c: &GradPeak| (c.idx as isize - p.idx as isize).unsigned_abs() >= min_sep) { + if chosen + .iter() + .all(|c: &GradPeak| (c.idx as isize - p.idx as isize).unsigned_abs() >= min_sep) + { chosen.push(p); - if chosen.len() >= count { break; } + if chosen.len() >= count { + break; + } } } chosen } /// 行 density → gradient → y₀,y₁,y₂,y₃ -fn detect_row_boundaries(cleaned: &image::RgbaImage, tile_w: u32, tile_h: u32) -> Option<(u32,u32,u32,u32)> { +fn detect_row_boundaries( + cleaned: &image::RgbaImage, + tile_w: u32, + tile_h: u32, +) -> Option<(u32, u32, u32, u32)> { // 行 density let mut row_density = Vec::with_capacity(tile_h as usize); for y in 0..tile_h { let mut cnt = 0u32; for x in 0..tile_w { - if cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA { cnt += 1; } + if cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA { + cnt += 1; + } } row_density.push(cnt as f32 / tile_w as f32); } let smooth = smooth_1d(&row_density, GRAD_SMOOTH); let grad = gradient(&smooth); let peaks = find_gradient_peaks(&grad, 4, 4); - if peaks.len() < 2 { return None; } + if peaks.len() < 2 { + return None; + } // 分离正负 let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect(); let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect(); - if pos.len() < 1 || neg.len() < 1 { return None; } + if pos.len() < 1 || neg.len() < 1 { + return None; + } // y₁: 最强正峰(窄→宽); y₂: 最强负峰(宽→窄) let y1 = pos[0].idx as u32; let y2 = neg[0].idx as u32; let y0 = row_density.iter().position(|&d| d > 0.0).unwrap_or(0) as u32; - let y3 = (row_density.len() as u32).saturating_sub( - 1 + row_density.iter().rev().position(|&d| d > 0.0).unwrap_or(0) as u32 - ) + 1; + let y3 = (row_density.len() as u32) + .saturating_sub(1 + row_density.iter().rev().position(|&d| d > 0.0).unwrap_or(0) as u32) + + 1; - if y1 < y0 + 2 || y2 <= y1 + 6 || y2 > y3.saturating_sub(2) { return None; } + if y1 < y0 + 2 || y2 <= y1 + 6 || y2 > y3.saturating_sub(2) { + return None; + } Some((y0, y1, y2, y3)) } /// 列高度 profile(每列 blob 的首次/末次行)→ gradient → x₀,x₁,x₂,x₃ -fn detect_col_boundaries(cleaned: &image::RgbaImage, tile_w: u32, _tile_h: u32, y0: u32, y3: u32) -> Option<(u32,u32,u32,u32)> { +fn detect_col_boundaries( + cleaned: &image::RgbaImage, + tile_w: u32, + _tile_h: u32, + y0: u32, + y3: u32, +) -> Option<(u32, u32, u32, u32)> { // 每列的 blob 高度 let mut col_height = Vec::with_capacity(tile_w as usize); for x in 0..tile_w { let first = (y0..y3).find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA); - let last = (y0..y3).rev().find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA); - col_height.push( - first.map_or(0.0, |f| { - let l = last.unwrap_or(f); - (l - f + 1) as f32 / (y3 - y0).max(1) as f32 - }) - ); + let last = (y0..y3) + .rev() + .find(|&y| cleaned.get_pixel(x, y).0[3] >= BLOB_ALPHA); + col_height.push(first.map_or(0.0, |f| { + let l = last.unwrap_or(f); + (l - f + 1) as f32 / (y3 - y0).max(1) as f32 + })); } let smooth = smooth_1d(&col_height, GRAD_SMOOTH); let grad = gradient(&smooth); let peaks = find_gradient_peaks(&grad, 4, 4); - if peaks.len() < 2 { return None; } + if peaks.len() < 2 { + return None; + } let pos: Vec<_> = peaks.iter().filter(|p| p.val > 0.0).collect(); let neg: Vec<_> = peaks.iter().filter(|p| p.val < 0.0).collect(); - if pos.len() < 1 || neg.len() < 1 { return None; } + if pos.len() < 1 || neg.len() < 1 { + return None; + } let x1 = pos[0].idx as u32; let x2 = neg[0].idx as u32; let x0 = col_height.iter().position(|&d| d > 0.0).unwrap_or(0) as u32; - let x3 = (tile_w as usize).saturating_sub( - 1 + col_height.iter().rev().position(|&d| d > 0.0).unwrap_or(0) - ) as u32 + 1; + let x3 = (tile_w as usize) + .saturating_sub(1 + col_height.iter().rev().position(|&d| d > 0.0).unwrap_or(0)) + as u32 + + 1; - if x1 < x0 + 2 || x2 <= x1 + 6 || x2 > x3.saturating_sub(2) { return None; } + if x1 < x0 + 2 || x2 <= x1 + 6 || x2 > x3.saturating_sub(2) { + return None; + } Some((x0, x1, x2, x3)) } @@ -795,7 +901,10 @@ fn detect_col_boundaries(cleaned: &image::RgbaImage, tile_w: u32, _tile_h: u32, /// 在 block 范围内基于 histogram 找最大全不透明矩形。 fn max_opaque_rect( cleaned: &image::RgbaImage, - bx0: u32, by0: u32, bw: u32, bh: u32, + bx0: u32, + by0: u32, + bw: u32, + bh: u32, ) -> Option<(u32, u32, u32, u32)> { let mut heights = vec![0u32; bw as usize]; let mut best_area = 0u32; @@ -866,11 +975,17 @@ mod tests { fn slice_jump_hop_tile_uv_faces_blob( source: &image::RgbaImage, - tile_x: u32, tile_y: u32, tile_w: u32, tile_h: u32, - atlas_row: u32, atlas_col: u32, + tile_x: u32, + tile_y: u32, + tile_w: u32, + tile_h: u32, + atlas_row: u32, + atlas_col: u32, ) -> Result { let fallback = || { - slice_jump_hop_tile_uv_faces_adaptive(source, tile_x, tile_y, tile_w, tile_h, atlas_row, atlas_col) + slice_jump_hop_tile_uv_faces_adaptive( + source, tile_x, tile_y, tile_w, tile_h, atlas_row, atlas_col, + ) }; // Step 1: BFS 主 blob → cleaned 图像 @@ -891,16 +1006,16 @@ fn slice_jump_hop_tile_uv_faces_blob( // Step 3: 3×3 block → 5 有效块 + Block(1,2) 拆分 → 6 块 // blocks: (row, col): 0=Top, 1,0=Left, 1,1=Front, 1,2=Right+Back, 2=Bottom - let blocks: [(u32,u32,u32,u32); 5] = [ - (x1, y0, x2 - x1, y1 - y0), // Top - (x0, y1, x1 - x0, y2 - y1), // Left - (x1, y1, x2 - x1, y2 - y1), // Front - (x2, y1, x3 - x2, y2 - y1), // Right+Back - (x1, y2, x2 - x1, y3 - y2), // Bottom + let blocks: [(u32, u32, u32, u32); 5] = [ + (x1, y0, x2 - x1, y1 - y0), // Top + (x0, y1, x1 - x0, y2 - y1), // Left + (x1, y1, x2 - x1, y2 - y1), // Front + (x2, y1, x3 - x2, y2 - y1), // Right+Back + (x1, y2, x2 - x1, y3 - y2), // Bottom ]; // Step 4: max opaque rectangle per block - let rect = |b: (u32,u32,u32,u32), name: &str| -> Result<(u32,u32,u32,u32), AppError> { + let rect = |b: (u32, u32, u32, u32), name: &str| -> Result<(u32, u32, u32, u32), AppError> { max_opaque_rect(&cleaned, b.0, b.1, b.2, b.3).ok_or_else(|| { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": JUMP_HOP_CREATION_PROVIDER, @@ -909,29 +1024,33 @@ fn slice_jump_hop_tile_uv_faces_blob( }) }; - let top = rect(blocks[0], "Top")?; - let left = rect(blocks[1], "Left")?; - let front = rect(blocks[2], "Front")?; + let top = rect(blocks[0], "Top")?; + let left = rect(blocks[1], "Left")?; + let front = rect(blocks[2], "Front")?; // Right+Back → 从中点拆分 let (rb_x0, rb_y0, rb_w, rb_h) = blocks[3]; let mid = rb_x0 + rb_w / 2; let right_rect = rect((rb_x0, rb_y0, mid - rb_x0, rb_h), "Right")?; - let back_rect = rect((mid, rb_y0, rb_x0 + rb_w - mid, rb_h), "Back")?; + let back_rect = rect((mid, rb_y0, rb_x0 + rb_w - mid, rb_h), "Back")?; let bottom = rect(blocks[4], "Bottom")?; // Step 5: crop (tile_local → global) - let global = |r: (u32,u32,u32,u32)| (tile_x + r.0, tile_y + r.1, r.2, r.3); + let global = |r: (u32, u32, u32, u32)| (tile_x + r.0, tile_y + r.1, r.2, r.3); - let mk = |r: (u32,u32,u32,u32), face: JumpHopTileFaceKey| -> Result { + let mk = |r: (u32, u32, u32, u32), + face: JumpHopTileFaceKey| + -> Result { let (gx, gy, gw, gh) = global(r); let cleaned_dyn = crop_jump_hop_tile_texture_cell(source, gx, gy, gw, gh); let mut cursor = std::io::Cursor::new(Vec::new()); - cleaned_dyn.write_to(&mut cursor, image::ImageFormat::Png).map_err(|e| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": JUMP_HOP_CREATION_PROVIDER, - "message": format!("跳一跳地板 UV 面贴图切割失败:{e}"), - })) - })?; + cleaned_dyn + .write_to(&mut cursor, image::ImageFormat::Png) + .map_err(|e| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": JUMP_HOP_CREATION_PROVIDER, + "message": format!("跳一跳地板 UV 面贴图切割失败:{e}"), + })) + })?; let label = jump_hop_tile_face_key_label(&face); Ok(JumpHopTileFaceSlice { face, @@ -941,11 +1060,11 @@ fn slice_jump_hop_tile_uv_faces_blob( }; Ok(JumpHopTileFaceSlices { - top: mk(top, JumpHopTileFaceKey::Top)?, - left: mk(left, JumpHopTileFaceKey::Left)?, - front: mk(front, JumpHopTileFaceKey::Front)?, - right: mk(right_rect, JumpHopTileFaceKey::Right)?, - back: mk(back_rect, JumpHopTileFaceKey::Back)?, + top: mk(top, JumpHopTileFaceKey::Top)?, + left: mk(left, JumpHopTileFaceKey::Left)?, + front: mk(front, JumpHopTileFaceKey::Front)?, + right: mk(right_rect, JumpHopTileFaceKey::Right)?, + back: mk(back_rect, JumpHopTileFaceKey::Back)?, bottom: mk(bottom, JumpHopTileFaceKey::Bottom)?, }) } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 93e18e9d1..6e109a4cb 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -36,6 +36,7 @@ mod custom_world_asset_prompts; mod custom_world_foundation_draft; mod custom_world_result_prompts; mod custom_world_rpg_draft_prompts; +mod editor_agent; mod editor_generation_config; mod editor_generation_queue; mod editor_green_screen; diff --git a/server-rs/crates/api-server/src/modules/editor_project.rs b/server-rs/crates/api-server/src/modules/editor_project.rs index 977c27b4b..dc640e7ad 100644 --- a/server-rs/crates/api-server/src/modules/editor_project.rs +++ b/server-rs/crates/api-server/src/modules/editor_project.rs @@ -7,6 +7,11 @@ use axum::{ use crate::{ auth::require_bearer_auth, + editor_agent::{ + create_editor_agent_conversation, delete_editor_agent_conversation, + get_editor_agent_conversation, list_editor_agent_conversations, + stream_editor_agent_message, + }, editor_project::{ create_editor_asset, create_editor_asset_folder, create_editor_project, create_editor_project_resource, delete_editor_asset, delete_editor_asset_folder, @@ -21,6 +26,7 @@ use crate::{ }; const EDITOR_IMAGE_REFERENCE_BODY_LIMIT_BYTES: usize = 12 * 1024 * 1024; +const EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES: usize = 2 * 1024 * 1024; pub fn router(state: AppState) -> Router { Router::new() @@ -72,6 +78,33 @@ pub fn router(state: AppState) -> Router { require_bearer_auth, )), ) + .route( + "/api/editor/projects/{project_id}/agent-conversations", + get(list_editor_agent_conversations) + .post(create_editor_agent_conversation) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/editor/agent-conversations/{conversation_id}", + get(get_editor_agent_conversation) + .delete(delete_editor_agent_conversation) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) + .route( + "/api/editor/agent-conversations/{conversation_id}/messages/stream", + post(stream_editor_agent_message) + .layer(DefaultBodyLimit::max(EDITOR_AGENT_MESSAGE_BODY_LIMIT_BYTES)) + .route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) .route( "/api/editor/project-resources/{resource_id}/showcase", patch(update_editor_project_resource_showcase).route_layer( diff --git a/server-rs/crates/api-server/src/runtime_profile.rs b/server-rs/crates/api-server/src/runtime_profile.rs index b4589c634..5bd8a8d5b 100644 --- a/server-rs/crates/api-server/src/runtime_profile.rs +++ b/server-rs/crates/api-server/src/runtime_profile.rs @@ -13,19 +13,18 @@ use module_runtime::{ PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_H5, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM, PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_MINI_PROGRAM_VIRTUAL, - PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileFeedbackEvidenceRecord, - RuntimeProfileCodeOperationRecord, RuntimeProfileFeedbackEvidenceSnapshot, + PROFILE_RECHARGE_PAYMENT_CHANNEL_WECHAT_NATIVE, RuntimeProfileCodeOperationRecord, + RuntimeProfileFeedbackEvidenceRecord, RuntimeProfileFeedbackEvidenceSnapshot, RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeRecord, - RuntimeProfileMembershipBenefitRecord, - RuntimeProfileMembershipTier, RuntimeProfileRechargeCenterRecord, - RuntimeProfileRechargeOrderRecord, RuntimeProfileRechargeOrderStatus, - RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRechargeProductKind, - RuntimeProfileRechargeProductRecord, RuntimeProfileRedeemCodeMode, - RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, - RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, - RuntimeProfileTaskCycle, RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, - RuntimeProfileWalletLedgerSourceType, RuntimeReferralInviteCenterRecord, - RuntimeTrackingScopeKind, + RuntimeProfileMembershipBenefitRecord, RuntimeProfileMembershipTier, + RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, + RuntimeProfileRechargeOrderStatus, RuntimeProfileRechargeProductConfigRecord, + RuntimeProfileRechargeProductKind, RuntimeProfileRechargeProductRecord, + RuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, + RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileTaskCenterRecord, + RuntimeProfileTaskClaimRecord, RuntimeProfileTaskConfigRecord, RuntimeProfileTaskCycle, + RuntimeProfileTaskItemRecord, RuntimeProfileTaskStatus, RuntimeProfileWalletLedgerSourceType, + RuntimeReferralInviteCenterRecord, RuntimeTrackingScopeKind, }; use platform_wechat::pay::WechatPayNotifyOrder; use serde::{Deserialize, Serialize}; @@ -53,18 +52,17 @@ use shared_contracts::runtime::{ PROFILE_WALLET_LEDGER_SOURCE_TYPE_POINTS_RECHARGE, PROFILE_WALLET_LEDGER_SOURCE_TYPE_PUZZLE_AUTHOR_INCENTIVE_CLAIM, PROFILE_WALLET_LEDGER_SOURCE_TYPE_REDEEM_CODE_REWARD, - PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileDashboardSummaryResponse, - ProfileCodeOperationAdminResponse, ProfileFeedbackEvidenceItemResponse, + PROFILE_WALLET_LEDGER_SOURCE_TYPE_SNAPSHOT_SYNC, ProfileCodeOperationAdminResponse, + ProfileDashboardSummaryResponse, ProfileFeedbackEvidenceItemResponse, ProfileFeedbackSubmissionResponse, ProfileInviteCodeAdminListResponse, - ProfileInviteCodeAdminResponse, - ProfileMembershipBenefitResponse, ProfileMembershipResponse, ProfilePlayStatsResponse, - ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, ProfileRechargeOrderResponse, - ProfileRechargeProductConfigAdminListResponse, ProfileRechargeProductConfigAdminResponse, - ProfileRechargeProductResponse, ProfileRedeemCodeAdminListResponse, - ProfileRedeemCodeAdminResponse, ProfileReferralInviteCenterResponse, - ProfileReferralInvitedUserResponse, ProfileTaskCenterResponse, - ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, ProfileTaskItemResponse, - ProfileWalletConfigAdminResponse, ProfileWalletLedgerEntryResponse, + ProfileInviteCodeAdminResponse, ProfileMembershipBenefitResponse, ProfileMembershipResponse, + ProfilePlayStatsResponse, ProfilePlayedWorkSummaryResponse, ProfileRechargeCenterResponse, + ProfileRechargeOrderResponse, ProfileRechargeProductConfigAdminListResponse, + ProfileRechargeProductConfigAdminResponse, ProfileRechargeProductResponse, + ProfileRedeemCodeAdminListResponse, ProfileRedeemCodeAdminResponse, + ProfileReferralInviteCenterResponse, ProfileReferralInvitedUserResponse, + ProfileTaskCenterResponse, ProfileTaskConfigAdminListResponse, ProfileTaskConfigAdminResponse, + ProfileTaskItemResponse, ProfileWalletConfigAdminResponse, ProfileWalletLedgerEntryResponse, ProfileWalletLedgerResponse, RedeemProfileReferralInviteCodeRequest, RedeemProfileReferralInviteCodeResponse, RedeemProfileRewardCodeRequest, RedeemProfileRewardCodeResponse, SubmitProfileFeedbackRequest, SubmitProfileFeedbackResponse, diff --git a/server-rs/crates/module-editor-agent/Cargo.toml b/server-rs/crates/module-editor-agent/Cargo.toml new file mode 100644 index 000000000..e54246781 --- /dev/null +++ b/server-rs/crates/module-editor-agent/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "module-editor-agent" +edition.workspace = true +version.workspace = true +license.workspace = true + +[features] +default = [] +spacetime-types = ["dep:spacetimedb"] + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +shared-kernel = { workspace = true } +spacetimedb = { workspace = true, optional = true } \ No newline at end of file diff --git a/server-rs/crates/module-editor-agent/src/application.rs b/server-rs/crates/module-editor-agent/src/application.rs new file mode 100644 index 000000000..6a9e100db --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/application.rs @@ -0,0 +1,127 @@ +use shared_kernel::normalize_required_string; + +use crate::{ + EDITOR_AGENT_MAX_ATTACHMENTS, EditorAgentConversationCreateInput, + EditorAgentConversationDeleteInput, EditorAgentConversationTouchInput, EditorAgentError, +}; + +pub fn validate_conversation_key( + conversation_id: &str, + owner_user_id: &str, +) -> Result<(String, String), EditorAgentError> { + let conversation_id = normalize_required_string(conversation_id) + .ok_or(EditorAgentError::MissingConversationId)?; + let owner_user_id = + normalize_required_string(owner_user_id).ok_or(EditorAgentError::MissingOwnerUserId)?; + Ok((conversation_id, owner_user_id)) +} + +pub fn validate_create_conversation( + input: &EditorAgentConversationCreateInput, +) -> Result<(), EditorAgentError> { + validate_conversation_key(&input.conversation_id, &input.owner_user_id)?; + if normalize_required_string(&input.project_id).is_none() { + return Err(EditorAgentError::MissingProjectId); + } + Ok(()) +} + +pub fn validate_touch_conversation( + input: &EditorAgentConversationTouchInput, +) -> Result<(), EditorAgentError> { + validate_conversation_key(&input.conversation_id, &input.owner_user_id)?; + Ok(()) +} + +pub fn validate_delete_conversation( + input: &EditorAgentConversationDeleteInput, +) -> Result<(), EditorAgentError> { + validate_conversation_key(&input.conversation_id, &input.owner_user_id)?; + Ok(()) +} + +/// 校验会话访问权:拥有者匹配且未软删。 +pub fn ensure_conversation_accessible( + snapshot_owner_user_id: &str, + snapshot_deleted: bool, + requester_user_id: &str, +) -> Result<(), EditorAgentError> { + if snapshot_owner_user_id != requester_user_id { + return Err(EditorAgentError::NotConversationOwner); + } + if snapshot_deleted { + return Err(EditorAgentError::ConversationDeleted); + } + Ok(()) +} + +/// 校验用户消息:文本与附件不可同时为空,附件数量不超过上限,附件引用需带资源标识。 +pub fn validate_user_message( + text: &str, + attachment_reference_ids: &[String], +) -> Result<(), EditorAgentError> { + let has_text = normalize_required_string(text).is_some(); + if !has_text && attachment_reference_ids.is_empty() { + return Err(EditorAgentError::EmptyMessage); + } + if attachment_reference_ids.len() > EDITOR_AGENT_MAX_ATTACHMENTS { + return Err(EditorAgentError::TooManyAttachments); + } + if attachment_reference_ids + .iter() + .any(|reference_id| normalize_required_string(reference_id).is_none()) + { + return Err(EditorAgentError::InvalidAttachmentReference); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::derive_conversation_title; + + #[test] + fn derive_title_truncates_and_normalizes_whitespace() { + assert_eq!( + derive_conversation_title(" 帮我生成\n一张森林背景 "), + "帮我生成 一张森林背景" + ); + assert_eq!(derive_conversation_title(""), "新对话"); + assert_eq!(derive_conversation_title(" \n "), "新对话"); + let long_text = "一二三四五六七八九十一二三四五六七八九十超出部分"; + assert_eq!(derive_conversation_title(long_text).chars().count(), 20); + } + + #[test] + fn validate_user_message_rules() { + assert_eq!( + validate_user_message("", &[]), + Err(EditorAgentError::EmptyMessage) + ); + assert!(validate_user_message("", &["resource-1".to_string()]).is_ok()); + assert!(validate_user_message("画一棵树", &[]).is_ok()); + let too_many: Vec = (0..10).map(|i| format!("resource-{i}")).collect(); + assert_eq!( + validate_user_message("画一棵树", &too_many), + Err(EditorAgentError::TooManyAttachments) + ); + assert_eq!( + validate_user_message("画一棵树", &[" ".to_string()]), + Err(EditorAgentError::InvalidAttachmentReference) + ); + } + + #[test] + fn ensure_conversation_accessible_rules() { + assert!(ensure_conversation_accessible("user-1", false, "user-1").is_ok()); + assert_eq!( + ensure_conversation_accessible("user-1", false, "user-2"), + Err(EditorAgentError::NotConversationOwner) + ); + assert_eq!( + ensure_conversation_accessible("user-1", true, "user-1"), + Err(EditorAgentError::ConversationDeleted) + ); + } +} diff --git a/server-rs/crates/module-editor-agent/src/commands.rs b/server-rs/crates/module-editor-agent/src/commands.rs new file mode 100644 index 000000000..936465fef --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/commands.rs @@ -0,0 +1,44 @@ +use serde::{Deserialize, Serialize}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationCreateInput { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub created_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationTouchInput { + pub conversation_id: String, + pub owner_user_id: String, + pub title: Option, + pub updated_at_micros: i64, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationDeleteInput { + pub conversation_id: String, + pub owner_user_id: String, + pub updated_at_micros: i64, +} +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationListInput { + pub project_id: String, + pub owner_user_id: String, +} + +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationGetInput { + pub conversation_id: String, + pub owner_user_id: String, +} diff --git a/server-rs/crates/module-editor-agent/src/domain.rs b/server-rs/crates/module-editor-agent/src/domain.rs new file mode 100644 index 000000000..1b98a4264 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/domain.rs @@ -0,0 +1,57 @@ +//! 画布Agent对话领域模型。 +//! +//! 本 crate 只描述会话元数据、消息与附件的纯领域事实与规则; +//! LLM、SSE、OSS 读写和 SpacetimeDB 写表均留在外层 adapter。 + +use serde::{Deserialize, Serialize}; +#[cfg(feature = "spacetime-types")] +use spacetimedb::SpacetimeType; + +pub const EDITOR_AGENT_CONVERSATION_ID_PREFIX: &str = "editor-agent-conv-"; +pub const EDITOR_AGENT_MESSAGE_ID_PREFIX: &str = "editor-agent-message-"; +/// 会话消息 OSS 对象目录前缀,完整键见 [`editor_agent_messages_object_key`]。 +pub const EDITOR_AGENT_MESSAGES_OBJECT_PREFIX: &str = "editor-agent/"; +/// 单条消息附件上限(超出由前端拦截、后端兜底拒绝)。 +pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9; +/// 会话标题取首条用户消息的前 N 个字符。 +pub const EDITOR_AGENT_TITLE_MAX_CHARS: usize = 20; +/// 新会话默认标题。 +pub const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE: &str = "新对话"; + +/// 会话元数据快照:SpacetimeDB 表只存这些字段,消息正文整体存 OSS。 +#[cfg_attr(feature = "spacetime-types", derive(SpacetimeType))] +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct EditorAgentConversationSnapshot { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub deleted: bool, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +/// 会话消息 OSS 对象键:`editor-agent/{conversationId}.json`,会话粒度整体读写。 +pub fn editor_agent_messages_object_key(conversation_id: &str) -> String { + format!("{EDITOR_AGENT_MESSAGES_OBJECT_PREFIX}{conversation_id}.json") +} + +/// 从首条用户消息推导会话标题:去掉首尾空白与换行后截取前 N 个字符; +/// 空文本(例如纯附件消息)退回默认标题。 +pub fn derive_conversation_title(first_message_text: &str) -> String { + let normalized: String = first_message_text + .chars() + .map(|c| if c == '\n' || c == '\r' { ' ' } else { c }) + .collect::() + .split_whitespace() + .collect::>() + .join(" "); + if normalized.is_empty() { + return EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE.to_string(); + } + normalized + .chars() + .take(EDITOR_AGENT_TITLE_MAX_CHARS) + .collect() +} diff --git a/server-rs/crates/module-editor-agent/src/errors.rs b/server-rs/crates/module-editor-agent/src/errors.rs new file mode 100644 index 000000000..69e31f2f8 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/errors.rs @@ -0,0 +1,33 @@ +use std::{error::Error, fmt}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum EditorAgentError { + MissingConversationId, + MissingProjectId, + MissingOwnerUserId, + MissingMessageId, + EmptyMessage, + TooManyAttachments, + InvalidAttachmentReference, + ConversationDeleted, + NotConversationOwner, +} + +impl fmt::Display for EditorAgentError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let message = match self { + Self::MissingConversationId => "editor agent conversation_id 缺失", + Self::MissingProjectId => "editor agent project_id 缺失", + Self::MissingOwnerUserId => "editor agent owner_user_id 缺失", + Self::MissingMessageId => "editor agent message_id 缺失", + Self::EmptyMessage => "消息内容为空(文本与附件均缺失)", + Self::TooManyAttachments => "单条消息附件超过上限", + Self::InvalidAttachmentReference => "附件引用缺少资源标识", + Self::ConversationDeleted => "会话已删除", + Self::NotConversationOwner => "当前用户不是会话拥有者", + }; + write!(f, "{message}") + } +} + +impl Error for EditorAgentError {} diff --git a/server-rs/crates/module-editor-agent/src/lib.rs b/server-rs/crates/module-editor-agent/src/lib.rs new file mode 100644 index 000000000..b68fa5247 --- /dev/null +++ b/server-rs/crates/module-editor-agent/src/lib.rs @@ -0,0 +1,9 @@ +mod application; +mod commands; +mod domain; +mod errors; + +pub use application::*; +pub use commands::*; +pub use domain::*; +pub use errors::*; diff --git a/server-rs/crates/platform-oss/README.md b/server-rs/crates/platform-oss/README.md index 17dd52566..06dff5292 100644 --- a/server-rs/crates/platform-oss/README.md +++ b/server-rs/crates/platform-oss/README.md @@ -24,6 +24,7 @@ 7. `content-type`、`content-length-range`、`success_action_status` policy 条件生成 8. `PostObject` 签名、`GetObject` 读签名、`HEAD Object` 和 `PutObject` 的结构化日志 9. generated 私有对象上传默认写入 `Cache-Control: public, max-age=31536000, immutable` +10. `editor-agent/` 服务端内部消息文档前缀,以及仅供后端使用的内部短期读签名能力 当前仍未落地的内容: @@ -41,9 +42,15 @@ 6. 结构化日志只记录 `provider`、`operation`、`bucket`、`endpoint`、`object_key` / `key_prefix`、`access`、`content_type`、`content_length`、`status`、`status_class`、`error_kind` 和 `elapsed_ms` 等排障字段;禁止输出 AccessKey、policy、signature、Authorization header 或完整 signed URL。 7. 完整 OSS URL 不能当作 object key 传入签名接口;前端收到 `https://*.oss-*.aliyuncs.com/generated-*` 时应先归一为 legacy public path,再通过 `/api/assets/read-url` 换取短期 signed URL。 8. generated 资源缓存的主路径是 OSS 对象头、浏览器 / WebView HTTP 缓存和后续 CDN,不允许改成 api-server 本地磁盘静态资源兜底。 +9. `PostObject` 是 OSS 基础设施能力,不代表所有前缀都允许浏览器直传;`editor-agent/` 只用于画布 Agent 会话消息文档,属于服务端内部前缀,不向浏览器开放写入。 +10. 画布 Agent 消息文档固定使用 `editor-agent/{conversationId}.json`,由 `api-server` 通过服务端 `PutObject` 整体写入,通过内部读签名读取;浏览器只调用会话 API,不直接持有 `editor-agent/` 对象的 OSS 写权限、STS 写权限或直传表单。 +11. `editor-agent/` 不属于 legacy `/generated-*` 公开资源前缀,也不应通过通用 `/api/assets/read-url` 暴露为前端可自行读写的对象地址。 +12. 画布 Agent 消息文档的业务上限、会话内串行写入、失败 generation record 保留等规则由 `api-server` 与 ADR 约束;`platform-oss` 只负责对象 key 规范化、签名和服务端上传能力。 ## 3. 边界约束 1. `platform-oss` 只承接对象存储平台适配,不承接业务实体状态与业务规则。 2. 资产状态与对象绑定最终由业务模块和 `apps/spacetime-module` 管理,前端接口由 `apps/api-server` 暴露。 3. 不允许把 OSS SDK、签名逻辑和 URL 策略重新散落到多个业务模块里各自实现。 +4. `editor-agent/` 前缀是服务端内部消息文档命名空间,只允许保存 `editor-agent/{conversationId}.json` 形态的会话级 JSON 文档。 +5. 浏览器直传能力不得用于 `editor-agent/` 前缀;若未来需要上传用户附件,应走独立的附件 / 资产前缀和确认链路,不得复用会话消息文档对象。 diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index d4aee7772..37dd079a1 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -49,6 +49,7 @@ pub enum OssObjectAccess { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum LegacyAssetPrefix { + EditorAgent, CharacterDrafts, Characters, Animations, @@ -242,6 +243,7 @@ impl LegacyAssetPrefix { .trim_end_matches('/'); match normalized { + "editor-agent" => Some(Self::EditorAgent), "generated-character-drafts" => Some(Self::CharacterDrafts), "generated-characters" => Some(Self::Characters), "generated-animations" => Some(Self::Animations), @@ -262,6 +264,7 @@ impl LegacyAssetPrefix { pub fn as_str(&self) -> &'static str { match self { + Self::EditorAgent => "editor-agent", Self::CharacterDrafts => "generated-character-drafts", Self::Characters => "generated-characters", Self::Animations => "generated-animations", @@ -535,6 +538,30 @@ impl OssClient { pub fn sign_get_object_url( &self, request: OssSignedGetObjectUrlRequest, + ) -> Result { + self.sign_get_object_url_with_normalizer( + request, + normalize_object_key, + "sign_get_object_url", + ) + } + + pub fn sign_internal_get_object_url( + &self, + request: OssSignedGetObjectUrlRequest, + ) -> Result { + self.sign_get_object_url_with_normalizer( + request, + normalize_editor_agent_messages_object_key, + "sign_internal_get_object_url", + ) + } + + fn sign_get_object_url_with_normalizer( + &self, + request: OssSignedGetObjectUrlRequest, + normalize: fn(&str) -> Result, + operation: &'static str, ) -> Result { let started_at = Instant::now(); let requested_object_key = request @@ -555,7 +582,7 @@ impl OssClient { )); } - let object_key = normalize_object_key(&request.object_key)?; + let object_key = normalize(&request.object_key)?; let expires_at = OffsetDateTime::now_utc() .checked_add(Duration::seconds(i64::try_from(expire_seconds).map_err( |_| OssError::InvalidRequest("expireSeconds 超出可支持范围".to_string()), @@ -627,7 +654,7 @@ impl OssClient { match &result { Ok(response) => info!( provider = OSS_PROVIDER, - operation = "sign_get_object_url", + operation, bucket = %response.bucket, endpoint = %response.endpoint, object_key = %response.object_key, @@ -637,7 +664,7 @@ impl OssClient { ), Err(error) => warn!( provider = OSS_PROVIDER, - operation = "sign_get_object_url", + operation, bucket = %self.config.bucket(), endpoint = %self.config.endpoint(), object_key = %requested_object_key, @@ -1031,12 +1058,48 @@ fn normalize_object_key(raw: &str) -> Result { return Err(OssError::InvalidRequest("objectKey 不能为空".to_string())); } - if LegacyAssetPrefix::from_object_key(&normalized).is_none() { + match LegacyAssetPrefix::from_object_key(&normalized) { + Some(LegacyAssetPrefix::EditorAgent) | None => { + return Err(OssError::InvalidRequest( + "objectKey 必须落在受支持的 generated-* 前缀下".to_string(), + )); + } + Some(_) => {} + } + + validate_object_key_segments(&normalized)?; + Ok(normalized) +} + +fn normalize_editor_agent_messages_object_key(raw: &str) -> Result { + let normalized = raw.trim().trim_start_matches('/').trim().to_string(); + if normalized.is_empty() { + return Err(OssError::InvalidRequest("objectKey 不能为空".to_string())); + } + validate_object_key_segments(&normalized)?; + + let mut segments = normalized.split('/'); + let prefix = segments.next(); + let file_name = segments.next(); + if prefix != Some(LegacyAssetPrefix::EditorAgent.as_str()) + || segments.next().is_some() + || !file_name + .map(|value| { + value.starts_with("editor-agent-conv-") + && value.ends_with(".json") + && value.len() > "editor-agent-conv-.json".len() + }) + .unwrap_or(false) + { return Err(OssError::InvalidRequest( - "objectKey 必须落在受支持的 generated-* 前缀下".to_string(), + "objectKey 必须是 editor-agent 消息文档".to_string(), )); } + Ok(normalized) +} + +fn validate_object_key_segments(normalized: &str) -> Result<(), OssError> { let segments = normalized.split('/').collect::>(); if segments.len() < 2 { return Err(OssError::InvalidRequest( @@ -1058,7 +1121,7 @@ fn normalize_object_key(raw: &str) -> Result { } } - Ok(normalized) + Ok(()) } fn build_key_prefix(prefix: LegacyAssetPrefix, path_segments: &[String]) -> String { @@ -1860,6 +1923,51 @@ mod tests { ); } + #[test] + fn sign_internal_get_object_url_allows_editor_agent_messages_document() { + let client = build_client(); + + let response = client + .sign_internal_get_object_url(OssSignedGetObjectUrlRequest { + object_key: "editor-agent/editor-agent-conv-1.json".to_string(), + expire_seconds: Some(300), + }) + .expect("editor agent messages document should build signed url"); + + assert_eq!( + response.object_key, + "editor-agent/editor-agent-conv-1.json".to_string() + ); + assert!(response.signed_url.starts_with( + "https://genarrative-assets.oss-cn-shanghai.aliyuncs.com/editor-agent/editor-agent-conv-1.json?" + )); + } + + #[test] + fn sign_internal_get_object_url_rejects_non_editor_agent_messages_document() { + let client = build_client(); + + for object_key in [ + "workflow-cache/task-1.json", + "generated-characters/hero/master.png", + "editor-agent/editor-agent-conv-1/part.json", + "editor-agent/other.json", + "editor-agent/editor-agent-conv-1.png", + ] { + let error = client + .sign_internal_get_object_url(OssSignedGetObjectUrlRequest { + object_key: object_key.to_string(), + expire_seconds: Some(300), + }) + .expect_err("non editor agent messages document should fail"); + + assert_eq!( + error, + OssError::InvalidRequest("objectKey 必须是 editor-agent 消息文档".to_string()) + ); + } + } + #[test] fn legacy_prefix_can_be_resolved_from_object_key() { assert_eq!( diff --git a/server-rs/crates/shared-contracts/src/editor_agent.rs b/server-rs/crates/shared-contracts/src/editor_agent.rs new file mode 100644 index 000000000..7fa5a17d2 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/editor_agent.rs @@ -0,0 +1,384 @@ +//! 画布Agent对话契约:与 packages/shared/src/contracts/editorAgent.ts 保持同形。 +//! 会话元数据存 SpacetimeDB,消息正文整体存 OSS(editor-agent/{conversationId}.json)。 + +use serde::{Deserialize, Serialize}; + +pub const EDITOR_AGENT_MAX_ATTACHMENTS: usize = 9; +pub const EDITOR_AGENT_TITLE_MAX_CHARS: usize = 20; +pub const EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE: &str = "新对话"; +pub const EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION: u32 = 1; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentStage { + Idle, + Thinking, + Responding, + Generating, + Completed, + Failed, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum EditorAgentMessageRole { + User, + Assistant, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentMessageKind { + Chat, + Stage, + Error, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentMessageStatus { + Streaming, + Generating, + Completed, + Failed, + Stopped, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentAttachmentSource { + CanvasResource, + LibraryAsset, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentToolName { + GenerateImage, + EditImage, + GenerateCharacter, + GenerateIconSpritesheet, + GenerateUiDesign, +} + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentGenerationStatus { + Generating, + Completed, + Failed, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentAttachmentRef { + pub source: EditorAgentAttachmentSource, + pub reference_id: String, + #[serde(default)] + pub object_key: Option, + pub image_src: String, + #[serde(default)] + pub thumbnail_src: Option, + #[serde(default)] + pub label: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentGeneratedImage { + #[serde(default)] + pub resource_id: Option, + pub image_src: String, + #[serde(default)] + pub thumbnail_src: Option, + #[serde(default)] + pub width: Option, + #[serde(default)] + pub height: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentGenerationRecord { + pub tool_call_id: String, + pub tool_name: EditorAgentToolName, + #[serde(default)] + pub task_id: Option, + pub status: EditorAgentGenerationStatus, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub images: Vec, + #[serde(default)] + pub error: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentMessage { + pub id: String, + pub role: EditorAgentMessageRole, + pub kind: EditorAgentMessageKind, + pub text: String, + #[serde(default)] + pub attachments: Vec, + #[serde(default)] + pub generations: Vec, + pub status: EditorAgentMessageStatus, + pub created_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentConversationSummary { + pub conversation_id: String, + pub project_id: String, + pub title: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentConversationDetail { + pub conversation_id: String, + pub project_id: String, + pub title: String, + pub created_at: String, + pub updated_at: String, + pub messages: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentConversationMessagesDocument { + pub version: u32, + pub conversation_id: String, + pub messages: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct CreateEditorAgentConversationRequest { + #[serde(default)] + pub title: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentConversationListResponse { + pub conversations: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentConversationResponse { + pub conversation: EditorAgentConversationDetail, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct StreamEditorAgentMessageRequest { + pub client_message_id: String, + pub text: String, + #[serde(default)] + pub attachments: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum EditorAgentSseEventType { + Stage, + MessageDelta, + ToolStarted, + ToolCompleted, + GenerationResult, + Error, + Done, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentSseEnvelope { + pub event: EditorAgentSseEventType, + pub data: serde_json::Value, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentStageEvent { + pub conversation_id: String, + pub stage: EditorAgentStage, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentMessageDeltaEvent { + pub conversation_id: String, + pub message_id: String, + pub role: EditorAgentMessageRole, + pub kind: EditorAgentMessageKind, + pub text_delta: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentToolEvent { + pub conversation_id: String, + pub message_id: String, + pub tool_call_id: String, + pub tool_name: EditorAgentToolName, + #[serde(default)] + pub summary: Option, + #[serde(default)] + pub task_id: Option, + #[serde(default)] + pub model: Option, + #[serde(default)] + pub status: Option, + #[serde(default)] + pub error: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentGenerationResultEvent { + pub conversation_id: String, + pub message_id: String, + pub tool_call_id: String, + pub tool_name: EditorAgentToolName, + #[serde(default)] + pub model: Option, + pub images: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentErrorEvent { + #[serde(default)] + pub conversation_id: Option, + pub code: String, + pub message: String, + pub recoverable: bool, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct EditorAgentDoneEvent { + pub conversation_id: String, + #[serde(default)] + pub title: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn editor_agent_conversation_detail_uses_camel_case() { + let detail = EditorAgentConversationDetail { + conversation_id: "editor-agent-conv-1".to_string(), + project_id: "project-1".to_string(), + title: "新对话".to_string(), + created_at: "2026-07-03T00:00:00Z".to_string(), + updated_at: "2026-07-03T00:00:01Z".to_string(), + messages: vec![EditorAgentMessage { + id: "message-1".to_string(), + role: EditorAgentMessageRole::User, + kind: EditorAgentMessageKind::Chat, + text: "帮我生成一张森林背景".to_string(), + attachments: vec![EditorAgentAttachmentRef { + source: EditorAgentAttachmentSource::CanvasResource, + reference_id: "resource-1".to_string(), + object_key: Some("editor/resource-1.png".to_string()), + image_src: "https://example.test/resource-1.png".to_string(), + thumbnail_src: None, + label: Some("森林参考".to_string()), + width: Some(1024), + height: Some(768), + }], + generations: vec![], + status: EditorAgentMessageStatus::Completed, + created_at: "2026-07-03T00:00:00Z".to_string(), + }], + }; + + let payload = serde_json::to_value(&detail).expect("detail should serialize"); + assert_eq!(payload["conversationId"], json!("editor-agent-conv-1")); + assert_eq!(payload["messages"][0]["role"], json!("user")); + assert_eq!( + payload["messages"][0]["attachments"][0]["source"], + json!("canvas_resource") + ); + assert_eq!( + payload["messages"][0]["attachments"][0]["referenceId"], + json!("resource-1") + ); + assert_eq!(payload["messages"][0]["status"], json!("completed")); + + let decoded: EditorAgentConversationDetail = + serde_json::from_value(payload).expect("detail should deserialize"); + assert_eq!(decoded, detail); + } + + #[test] + fn editor_agent_sse_events_serialize_event_names() { + let envelope = EditorAgentSseEnvelope { + event: EditorAgentSseEventType::GenerationResult, + data: serde_json::to_value(EditorAgentGenerationResultEvent { + conversation_id: "editor-agent-conv-1".to_string(), + message_id: "message-2".to_string(), + tool_call_id: "tool-call-1".to_string(), + tool_name: EditorAgentToolName::GenerateImage, + model: Some("z-image".to_string()), + images: vec![EditorAgentGeneratedImage { + resource_id: Some("resource-2".to_string()), + image_src: "https://example.test/generated.png".to_string(), + thumbnail_src: None, + width: Some(1024), + height: Some(1024), + }], + }) + .expect("event data should serialize"), + }; + + let payload = serde_json::to_value(envelope).expect("envelope should serialize"); + assert_eq!(payload["event"], json!("generation_result")); + assert_eq!(payload["data"]["toolName"], json!("generate_image")); + assert_eq!( + payload["data"]["images"][0]["resourceId"], + json!("resource-2") + ); + + let error_envelope = EditorAgentSseEnvelope { + event: EditorAgentSseEventType::Error, + data: serde_json::to_value(EditorAgentErrorEvent { + conversation_id: Some("editor-agent-conv-1".to_string()), + code: "INSUFFICIENT_BALANCE".to_string(), + message: "泥点余额不足,本次生成未执行".to_string(), + recoverable: true, + }) + .expect("event data should serialize"), + }; + let error_payload = + serde_json::to_value(error_envelope).expect("envelope should serialize"); + assert_eq!(error_payload["event"], json!("error")); + assert_eq!(error_payload["data"]["recoverable"], json!(true)); + } + + #[test] + fn stream_request_defaults_attachments_to_empty() { + let payload = json!({ + "clientMessageId": "client-message-1", + "text": "随便聊聊" + }); + let decoded: StreamEditorAgentMessageRequest = + serde_json::from_value(payload).expect("request should deserialize"); + assert!(decoded.attachments.is_empty()); + } +} diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 8f21eacb1..6298f0cc5 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -11,6 +11,7 @@ pub mod creation_agent_document_input; pub mod creation_audio; pub mod creation_entry_config; pub mod creative_agent; +pub mod editor_agent; pub mod external_generation; pub mod hyper3d; pub mod jump_hop; diff --git a/server-rs/crates/spacetime-client/src/editor_agent.rs b/server-rs/crates/spacetime-client/src/editor_agent.rs new file mode 100644 index 000000000..c9b9a2d71 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/editor_agent.rs @@ -0,0 +1,136 @@ +use super::*; + +impl SpacetimeClient { + pub async fn create_editor_agent_conversation( + &self, + input: EditorAgentConversationCreateRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "create_editor_agent_conversation_and_return", + move |connection, sender| { + connection + .procedures() + .create_editor_agent_conversation_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_agent_conversation_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn list_editor_agent_conversations( + &self, + project_id: String, + owner_user_id: String, + ) -> Result, SpacetimeClientError> { + let procedure_input = EditorAgentConversationListInput { + project_id, + owner_user_id, + }; + + self.call_after_connect( + "list_editor_agent_conversations_and_return", + move |connection, sender| { + connection + .procedures() + .list_editor_agent_conversations_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_agent_conversation_list_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn get_editor_agent_conversation( + &self, + conversation_id: String, + owner_user_id: String, + ) -> Result { + let procedure_input = EditorAgentConversationGetInput { + conversation_id, + owner_user_id, + }; + + self.call_after_connect( + "get_editor_agent_conversation_and_return", + move |connection, sender| { + connection + .procedures() + .get_editor_agent_conversation_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_agent_conversation_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn touch_editor_agent_conversation( + &self, + input: EditorAgentConversationTouchRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "touch_editor_agent_conversation_and_return", + move |connection, sender| { + connection + .procedures() + .touch_editor_agent_conversation_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_agent_conversation_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + + pub async fn delete_editor_agent_conversation( + &self, + input: EditorAgentConversationDeleteRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "delete_editor_agent_conversation_and_return", + move |connection, sender| { + connection + .procedures() + .delete_editor_agent_conversation_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_editor_agent_conversation_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } +} diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 1ad8c2924..09978f973 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -30,9 +30,12 @@ pub use mapper::{ CustomWorldPublishGateRecord, CustomWorldPublishWorldRecord, CustomWorldPublishWorldRecordInput, CustomWorldPublishedProfileCompileRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, - CustomWorldWorkSummaryRecord, EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, - EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, - EditorAssetFolderRecord, EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, + CustomWorldWorkSummaryRecord, EditorAgentConversationCreateRecordInput, + EditorAgentConversationDeleteRecordInput, EditorAgentConversationRecord, + EditorAgentConversationTouchRecordInput, EditorAssetCreateRecordInput, + EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, + EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, + EditorAssetFolderUpdateRecordInput, EditorAssetLibraryRecord, EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, EditorProjectLayoutSaveAckRecord, @@ -129,6 +132,7 @@ pub use bark_battle::{ pub mod big_fish; pub mod combat; pub mod custom_world; +pub mod editor_agent; pub mod editor_project; pub mod external_api_key; pub mod external_generation; @@ -202,8 +206,7 @@ use module_runtime::{ RuntimeProfileFeedbackSubmissionRecord, RuntimeProfileInviteCodeAdminListRecord, RuntimeProfileInviteCodeRecord, RuntimeProfilePlayStatsRecord, RuntimeProfileRechargeCenterRecord, RuntimeProfileRechargeOrderRecord, - RuntimeProfileRechargeProductConfigRecord, - RuntimeProfileRedeemCodeAdminListRecord, + RuntimeProfileRechargeProductConfigRecord, RuntimeProfileRedeemCodeAdminListRecord, RuntimeProfileRedeemCodeMode as DomainRuntimeProfileRedeemCodeMode, RuntimeProfileRedeemCodeRecord, RuntimeProfileRewardCodeRedeemRecord, RuntimeProfileSaveArchiveRecord, RuntimeProfileTaskCenterRecord, RuntimeProfileTaskClaimRecord, @@ -214,9 +217,8 @@ use module_runtime::{ RuntimeTrackingScopeKind as DomainRuntimeTrackingScopeKind, build_analytics_metric_query_input, build_runtime_browse_history_clear_input, build_runtime_browse_history_list_input, build_runtime_browse_history_record, build_runtime_browse_history_sync_input, - build_runtime_profile_dashboard_get_input, build_runtime_profile_dashboard_record, - build_runtime_profile_code_operation_record, - build_runtime_profile_feedback_submission_input, + build_runtime_profile_code_operation_record, build_runtime_profile_dashboard_get_input, + build_runtime_profile_dashboard_record, build_runtime_profile_feedback_submission_input, build_runtime_profile_feedback_submission_record, build_runtime_profile_invite_code_admin_list_input, build_runtime_profile_invite_code_admin_upsert_input, build_runtime_profile_invite_code_record, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 10247312e..0b2c95fcc 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -8,6 +8,7 @@ mod big_fish; mod combat; mod common; mod custom_world; +mod editor_agent; mod editor_project; mod external_api_key; mod external_generation; @@ -72,6 +73,10 @@ pub use self::common::{ VisualNovelRunSnapshotRecordInput, VisualNovelRunStartRecordInput, VisualNovelWorkCompileRecordInput, }; +pub use self::editor_agent::{ + EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput, + EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput, +}; pub use self::editor_project::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderRecord, @@ -207,6 +212,10 @@ pub(crate) use self::custom_world::{ parse_rpg_agent_operation_status_record, parse_rpg_agent_operation_type_record, parse_rpg_agent_stage_record, }; +pub(crate) use self::editor_agent::{ + map_editor_agent_conversation_list_procedure_result, + map_editor_agent_conversation_procedure_result, +}; pub(crate) use self::editor_project::{ map_editor_asset_folder_library_procedure_result, map_editor_asset_folder_procedure_result, map_editor_asset_library_procedure_result, map_editor_asset_procedure_result, diff --git a/server-rs/crates/spacetime-client/src/mapper/editor_agent.rs b/server-rs/crates/spacetime-client/src/mapper/editor_agent.rs new file mode 100644 index 000000000..65cd0a5c3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/mapper/editor_agent.rs @@ -0,0 +1,122 @@ +use super::*; + +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct EditorAgentConversationRecord { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub deleted: bool, + pub created_at: String, + pub updated_at: String, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorAgentConversationCreateRecordInput { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub created_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorAgentConversationTouchRecordInput { + pub conversation_id: String, + pub owner_user_id: String, + pub title: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EditorAgentConversationDeleteRecordInput { + pub conversation_id: String, + pub owner_user_id: String, + pub updated_at_micros: i64, +} + +impl From + for crate::module_bindings::EditorAgentConversationCreateInput +{ + fn from(input: EditorAgentConversationCreateRecordInput) -> Self { + Self { + conversation_id: input.conversation_id, + project_id: input.project_id, + owner_user_id: input.owner_user_id, + title: input.title, + messages_object_key: input.messages_object_key, + created_at_micros: input.created_at_micros, + } + } +} + +impl From + for crate::module_bindings::EditorAgentConversationTouchInput +{ + fn from(input: EditorAgentConversationTouchRecordInput) -> Self { + Self { + conversation_id: input.conversation_id, + owner_user_id: input.owner_user_id, + title: input.title, + updated_at_micros: input.updated_at_micros, + } + } +} + +impl From + for crate::module_bindings::EditorAgentConversationDeleteInput +{ + fn from(input: EditorAgentConversationDeleteRecordInput) -> Self { + Self { + conversation_id: input.conversation_id, + owner_user_id: input.owner_user_id, + updated_at_micros: input.updated_at_micros, + } + } +} + +fn map_editor_agent_conversation_snapshot( + snapshot: crate::module_bindings::EditorAgentConversationSnapshot, +) -> EditorAgentConversationRecord { + EditorAgentConversationRecord { + conversation_id: snapshot.conversation_id, + project_id: snapshot.project_id, + owner_user_id: snapshot.owner_user_id, + title: snapshot.title, + messages_object_key: snapshot.messages_object_key, + deleted: snapshot.deleted, + created_at: format_timestamp_micros(snapshot.created_at_micros), + updated_at: format_timestamp_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, + } +} + +pub(crate) fn map_editor_agent_conversation_procedure_result( + result: crate::module_bindings::EditorAgentConversationProcedureResult, +) -> Result { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + result + .conversation + .map(map_editor_agent_conversation_snapshot) + .ok_or_else(|| SpacetimeClientError::missing_snapshot("画布Agent会话快照")) +} + +pub(crate) fn map_editor_agent_conversation_list_procedure_result( + result: crate::module_bindings::EditorAgentConversationListProcedureResult, +) -> Result, SpacetimeClientError> { + if !result.ok { + return Err(SpacetimeClientError::procedure_failed(result.error_message)); + } + + Ok(result + .conversations + .into_iter() + .map(map_editor_agent_conversation_snapshot) + .collect()) +} diff --git a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs index 825b463d2..8fbad4d71 100644 --- a/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs +++ b/server-rs/crates/spacetime-client/src/mapper/runtime_profile.rs @@ -675,14 +675,14 @@ pub(crate) fn map_runtime_profile_redeem_code_admin_list_procedure_result( Ok(RuntimeProfileRedeemCodeAdminListRecord { entries: result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_profile_redeem_code_record(map_runtime_profile_redeem_code_snapshot( - snapshot, - )) - }) - .collect(), + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_redeem_code_record(map_runtime_profile_redeem_code_snapshot( + snapshot, + )) + }) + .collect(), operations: result .operations .into_iter() @@ -724,14 +724,14 @@ pub(crate) fn map_runtime_profile_invite_code_admin_list_procedure_result( Ok(RuntimeProfileInviteCodeAdminListRecord { entries: result - .entries - .into_iter() - .map(|snapshot| { - build_runtime_profile_invite_code_record(map_runtime_profile_invite_code_snapshot( - snapshot, - )) - }) - .collect(), + .entries + .into_iter() + .map(|snapshot| { + build_runtime_profile_invite_code_record(map_runtime_profile_invite_code_snapshot( + snapshot, + )) + }) + .collect(), operations: result .operations .into_iter() diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index a19453595..46dcda62b 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -1,7 +1,7 @@ // THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE // WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. -// This was generated using spacetimedb cli version 2.5.0 (commit ca16958ef0a5f8c816700d2255a0b20ecacff901). +// This was generated using spacetimedb cli version 2.5.0 (commit 19cbf2b219ac9fd7fdca6c5e2c61a3a2f0319515). #![allow(unused, clippy::all)] use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; @@ -240,6 +240,7 @@ pub mod create_battle_state_and_return_procedure; pub mod create_battle_state_reducer; pub mod create_big_fish_session_procedure; pub mod create_custom_world_agent_session_procedure; +pub mod create_editor_agent_conversation_and_return_procedure; pub mod create_editor_asset_and_return_procedure; pub mod create_editor_asset_folder_and_return_procedure; pub mod create_editor_project_and_return_procedure; @@ -345,6 +346,7 @@ pub mod delete_bark_battle_work_procedure; pub mod delete_big_fish_work_procedure; pub mod delete_custom_world_agent_session_procedure; pub mod delete_custom_world_profile_and_return_procedure; +pub mod delete_editor_agent_conversation_and_return_procedure; pub mod delete_editor_asset_and_return_procedure; pub mod delete_editor_asset_folder_and_return_procedure; pub mod delete_editor_project_and_return_procedure; @@ -357,6 +359,16 @@ pub mod delete_visual_novel_work_procedure; pub mod delete_wooden_fish_work_procedure; pub mod drag_puzzle_piece_or_group_procedure; pub mod drop_square_hole_shape_procedure; +pub mod editor_agent_conversation_create_input_type; +pub mod editor_agent_conversation_delete_input_type; +pub mod editor_agent_conversation_get_input_type; +pub mod editor_agent_conversation_list_input_type; +pub mod editor_agent_conversation_list_procedure_result_type; +pub mod editor_agent_conversation_procedure_result_type; +pub mod editor_agent_conversation_snapshot_type; +pub mod editor_agent_conversation_table; +pub mod editor_agent_conversation_touch_input_type; +pub mod editor_agent_conversation_type; pub mod editor_asset_create_input_type; pub mod editor_asset_delete_input_type; pub mod editor_asset_folder_create_input_type; @@ -459,6 +471,7 @@ pub mod get_custom_world_agent_session_procedure; pub mod get_custom_world_gallery_detail_by_code_procedure; pub mod get_custom_world_gallery_detail_procedure; pub mod get_custom_world_library_detail_procedure; +pub mod get_editor_agent_conversation_and_return_procedure; pub mod get_editor_asset_library_and_return_procedure; pub mod get_editor_project_and_return_procedure; pub mod get_external_generation_job_and_return_procedure; @@ -572,6 +585,7 @@ pub mod list_big_fish_works_procedure; pub mod list_custom_world_gallery_entries_procedure; pub mod list_custom_world_profiles_procedure; pub mod list_custom_world_works_procedure; +pub mod list_editor_agent_conversations_and_return_procedure; pub mod list_editor_projects_and_return_procedure; pub mod list_external_api_keys_and_return_procedure; pub mod list_external_generation_jobs_and_return_procedure; @@ -1110,6 +1124,7 @@ pub mod submit_visual_novel_agent_message_procedure; pub mod swap_puzzle_clear_cards_procedure; pub mod swap_puzzle_pieces_procedure; pub mod sync_auth_store_projection_procedure; +pub mod touch_editor_agent_conversation_and_return_procedure; pub mod tracking_daily_stat_table; pub mod tracking_daily_stat_type; pub mod tracking_event_table; @@ -1471,6 +1486,7 @@ pub use create_battle_state_and_return_procedure::create_battle_state_and_return pub use create_battle_state_reducer::create_battle_state; pub use create_big_fish_session_procedure::create_big_fish_session; pub use create_custom_world_agent_session_procedure::create_custom_world_agent_session; +pub use create_editor_agent_conversation_and_return_procedure::create_editor_agent_conversation_and_return; pub use create_editor_asset_and_return_procedure::create_editor_asset_and_return; pub use create_editor_asset_folder_and_return_procedure::create_editor_asset_folder_and_return; pub use create_editor_project_and_return_procedure::create_editor_project_and_return; @@ -1576,6 +1592,7 @@ pub use delete_bark_battle_work_procedure::delete_bark_battle_work; pub use delete_big_fish_work_procedure::delete_big_fish_work; pub use delete_custom_world_agent_session_procedure::delete_custom_world_agent_session; pub use delete_custom_world_profile_and_return_procedure::delete_custom_world_profile_and_return; +pub use delete_editor_agent_conversation_and_return_procedure::delete_editor_agent_conversation_and_return; pub use delete_editor_asset_and_return_procedure::delete_editor_asset_and_return; pub use delete_editor_asset_folder_and_return_procedure::delete_editor_asset_folder_and_return; pub use delete_editor_project_and_return_procedure::delete_editor_project_and_return; @@ -1588,6 +1605,16 @@ pub use delete_visual_novel_work_procedure::delete_visual_novel_work; pub use delete_wooden_fish_work_procedure::delete_wooden_fish_work; pub use drag_puzzle_piece_or_group_procedure::drag_puzzle_piece_or_group; pub use drop_square_hole_shape_procedure::drop_square_hole_shape; +pub use editor_agent_conversation_create_input_type::EditorAgentConversationCreateInput; +pub use editor_agent_conversation_delete_input_type::EditorAgentConversationDeleteInput; +pub use editor_agent_conversation_get_input_type::EditorAgentConversationGetInput; +pub use editor_agent_conversation_list_input_type::EditorAgentConversationListInput; +pub use editor_agent_conversation_list_procedure_result_type::EditorAgentConversationListProcedureResult; +pub use editor_agent_conversation_procedure_result_type::EditorAgentConversationProcedureResult; +pub use editor_agent_conversation_snapshot_type::EditorAgentConversationSnapshot; +pub use editor_agent_conversation_table::*; +pub use editor_agent_conversation_touch_input_type::EditorAgentConversationTouchInput; +pub use editor_agent_conversation_type::EditorAgentConversation; pub use editor_asset_create_input_type::EditorAssetCreateInput; pub use editor_asset_delete_input_type::EditorAssetDeleteInput; pub use editor_asset_folder_create_input_type::EditorAssetFolderCreateInput; @@ -1690,6 +1717,7 @@ pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gallery_detail_by_code; pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail; pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail; +pub use get_editor_agent_conversation_and_return_procedure::get_editor_agent_conversation_and_return; pub use get_editor_asset_library_and_return_procedure::get_editor_asset_library_and_return; pub use get_editor_project_and_return_procedure::get_editor_project_and_return; pub use get_external_generation_job_and_return_procedure::get_external_generation_job_and_return; @@ -1803,6 +1831,7 @@ pub use list_big_fish_works_procedure::list_big_fish_works; pub use list_custom_world_gallery_entries_procedure::list_custom_world_gallery_entries; pub use list_custom_world_profiles_procedure::list_custom_world_profiles; pub use list_custom_world_works_procedure::list_custom_world_works; +pub use list_editor_agent_conversations_and_return_procedure::list_editor_agent_conversations_and_return; pub use list_editor_projects_and_return_procedure::list_editor_projects_and_return; pub use list_external_api_keys_and_return_procedure::list_external_api_keys_and_return; pub use list_external_generation_jobs_and_return_procedure::list_external_generation_jobs_and_return; @@ -2341,6 +2370,7 @@ pub use submit_visual_novel_agent_message_procedure::submit_visual_novel_agent_m pub use swap_puzzle_clear_cards_procedure::swap_puzzle_clear_cards; pub use swap_puzzle_pieces_procedure::swap_puzzle_pieces; pub use sync_auth_store_projection_procedure::sync_auth_store_projection; +pub use touch_editor_agent_conversation_and_return_procedure::touch_editor_agent_conversation_and_return; pub use tracking_daily_stat_table::*; pub use tracking_daily_stat_type::TrackingDailyStat; pub use tracking_event_table::*; @@ -2778,6 +2808,7 @@ pub struct DbUpdate { custom_world_session: __sdk::TableUpdate, database_migration_import_chunk: __sdk::TableUpdate, database_migration_operator: __sdk::TableUpdate, + editor_agent_conversation: __sdk::TableUpdate, editor_asset: __sdk::TableUpdate, editor_asset_folder: __sdk::TableUpdate, editor_canvas: __sdk::TableUpdate, @@ -2997,6 +3028,9 @@ impl TryFrom<__ws::v2::TransactionUpdate> for DbUpdate { "database_migration_operator" => db_update.database_migration_operator.append( database_migration_operator_table::parse_table_update(table_update)?, ), + "editor_agent_conversation" => db_update.editor_agent_conversation.append( + editor_agent_conversation_table::parse_table_update(table_update)?, + ), "editor_asset" => db_update .editor_asset .append(editor_asset_table::parse_table_update(table_update)?), @@ -3481,6 +3515,12 @@ impl __sdk::DbUpdate for DbUpdate { &self.database_migration_operator, ) .with_updates_by_pk(|row| &row.operator_identity); + diff.editor_agent_conversation = cache + .apply_diff_to_table::( + "editor_agent_conversation", + &self.editor_agent_conversation, + ) + .with_updates_by_pk(|row| &row.conversation_id); diff.editor_asset = cache .apply_diff_to_table::("editor_asset", &self.editor_asset) .with_updates_by_pk(|row| &row.asset_id); @@ -4053,6 +4093,9 @@ impl __sdk::DbUpdate for DbUpdate { "database_migration_operator" => db_update .database_migration_operator .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), + "editor_agent_conversation" => db_update + .editor_agent_conversation + .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), "editor_asset" => db_update .editor_asset .append(__sdk::parse_row_list_as_inserts(table_rows.rows)?), @@ -4447,6 +4490,9 @@ impl __sdk::DbUpdate for DbUpdate { "database_migration_operator" => db_update .database_migration_operator .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), + "editor_agent_conversation" => db_update + .editor_agent_conversation + .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), "editor_asset" => db_update .editor_asset .append(__sdk::parse_row_list_as_deletes(table_rows.rows)?), @@ -4769,6 +4815,7 @@ pub struct AppliedDiff<'r> { custom_world_session: __sdk::TableAppliedDiff<'r, CustomWorldSession>, database_migration_import_chunk: __sdk::TableAppliedDiff<'r, DatabaseMigrationImportChunk>, database_migration_operator: __sdk::TableAppliedDiff<'r, DatabaseMigrationOperator>, + editor_agent_conversation: __sdk::TableAppliedDiff<'r, EditorAgentConversation>, editor_asset: __sdk::TableAppliedDiff<'r, EditorAsset>, editor_asset_folder: __sdk::TableAppliedDiff<'r, EditorAssetFolder>, editor_canvas: __sdk::TableAppliedDiff<'r, EditorCanvas>, @@ -5054,6 +5101,11 @@ impl<'r> __sdk::AppliedDiff<'r> for AppliedDiff<'r> { &self.database_migration_operator, event, ); + callbacks.invoke_table_row_callbacks::( + "editor_agent_conversation", + &self.editor_agent_conversation, + event, + ); callbacks.invoke_table_row_callbacks::( "editor_asset", &self.editor_asset, @@ -6185,6 +6237,7 @@ impl __sdk::SpacetimeModule for RemoteModule { custom_world_session_table::register_table(client_cache); database_migration_import_chunk_table::register_table(client_cache); database_migration_operator_table::register_table(client_cache); + editor_agent_conversation_table::register_table(client_cache); editor_asset_table::register_table(client_cache); editor_asset_folder_table::register_table(client_cache); editor_canvas_table::register_table(client_cache); @@ -6314,6 +6367,7 @@ impl __sdk::SpacetimeModule for RemoteModule { "custom_world_session", "database_migration_import_chunk", "database_migration_operator", + "editor_agent_conversation", "editor_asset", "editor_asset_folder", "editor_canvas", diff --git a/server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs new file mode 100644 index 000000000..6e45ee7c2 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/create_editor_agent_conversation_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_create_input_type::EditorAgentConversationCreateInput; +use super::editor_agent_conversation_procedure_result_type::EditorAgentConversationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct CreateEditorAgentConversationAndReturnArgs { + pub input: EditorAgentConversationCreateInput, +} + +impl __sdk::InModule for CreateEditorAgentConversationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `create_editor_agent_conversation_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait create_editor_agent_conversation_and_return { + fn create_editor_agent_conversation_and_return( + &self, + input: EditorAgentConversationCreateInput, + ) { + self.create_editor_agent_conversation_and_return_then(input, |_, _| {}); + } + + fn create_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl create_editor_agent_conversation_and_return for super::RemoteProcedures { + fn create_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationCreateInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( + "create_editor_agent_conversation_and_return", + CreateEditorAgentConversationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs new file mode 100644 index 000000000..e7bf76bfd --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/delete_editor_agent_conversation_and_return_procedure.rs @@ -0,0 +1,62 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_delete_input_type::EditorAgentConversationDeleteInput; +use super::editor_agent_conversation_procedure_result_type::EditorAgentConversationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct DeleteEditorAgentConversationAndReturnArgs { + pub input: EditorAgentConversationDeleteInput, +} + +impl __sdk::InModule for DeleteEditorAgentConversationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `delete_editor_agent_conversation_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait delete_editor_agent_conversation_and_return { + fn delete_editor_agent_conversation_and_return( + &self, + input: EditorAgentConversationDeleteInput, + ) { + self.delete_editor_agent_conversation_and_return_then(input, |_, _| {}); + } + + fn delete_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationDeleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl delete_editor_agent_conversation_and_return for super::RemoteProcedures { + fn delete_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationDeleteInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( + "delete_editor_agent_conversation_and_return", + DeleteEditorAgentConversationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_create_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_create_input_type.rs new file mode 100644 index 000000000..cc5fdf357 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_create_input_type.rs @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationCreateInput { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub created_at_micros: i64, +} + +impl __sdk::InModule for EditorAgentConversationCreateInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_delete_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_delete_input_type.rs new file mode 100644 index 000000000..b58f724da --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_delete_input_type.rs @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationDeleteInput { + pub conversation_id: String, + pub owner_user_id: String, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorAgentConversationDeleteInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_get_input_type.rs new file mode 100644 index 000000000..fbd9797ac --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationGetInput { + pub conversation_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for EditorAgentConversationGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_input_type.rs new file mode 100644 index 000000000..c2b916e83 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationListInput { + pub project_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for EditorAgentConversationListInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_procedure_result_type.rs new file mode 100644 index 000000000..210496963 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_list_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_snapshot_type::EditorAgentConversationSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationListProcedureResult { + pub ok: bool, + pub conversations: Vec, + pub error_message: Option, +} + +impl __sdk::InModule for EditorAgentConversationListProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_procedure_result_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_procedure_result_type.rs new file mode 100644 index 000000000..347e6a577 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_procedure_result_type.rs @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_snapshot_type::EditorAgentConversationSnapshot; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationProcedureResult { + pub ok: bool, + pub conversation: Option, + pub error_message: Option, +} + +impl __sdk::InModule for EditorAgentConversationProcedureResult { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_snapshot_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_snapshot_type.rs new file mode 100644 index 000000000..e1b35531c --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_snapshot_type.rs @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationSnapshot { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub deleted: bool, + pub created_at_micros: i64, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorAgentConversationSnapshot { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_table.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_table.rs new file mode 100644 index 000000000..09558b858 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_table.rs @@ -0,0 +1,162 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use super::editor_agent_conversation_type::EditorAgentConversation; +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +/// Table handle for the table `editor_agent_conversation`. +/// +/// Obtain a handle from the [`EditorAgentConversationTableAccess::editor_agent_conversation`] method on [`super::RemoteTables`], +/// like `ctx.db.editor_agent_conversation()`. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_agent_conversation().on_insert(...)`. +pub struct EditorAgentConversationTableHandle<'ctx> { + imp: __sdk::TableHandle, + ctx: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the table `editor_agent_conversation`. +/// +/// Implemented for [`super::RemoteTables`]. +pub trait EditorAgentConversationTableAccess { + #[allow(non_snake_case)] + /// Obtain a [`EditorAgentConversationTableHandle`], which mediates access to the table `editor_agent_conversation`. + fn editor_agent_conversation(&self) -> EditorAgentConversationTableHandle<'_>; +} + +impl EditorAgentConversationTableAccess for super::RemoteTables { + fn editor_agent_conversation(&self) -> EditorAgentConversationTableHandle<'_> { + EditorAgentConversationTableHandle { + imp: self + .imp + .get_table::("editor_agent_conversation"), + ctx: std::marker::PhantomData, + } + } +} + +pub struct EditorAgentConversationInsertCallbackId(__sdk::CallbackId); +pub struct EditorAgentConversationDeleteCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::Table for EditorAgentConversationTableHandle<'ctx> { + type Row = EditorAgentConversation; + type EventContext = super::EventContext; + + fn count(&self) -> u64 { + self.imp.count() + } + fn iter(&self) -> impl Iterator + '_ { + self.imp.iter() + } + + type InsertCallbackId = EditorAgentConversationInsertCallbackId; + + fn on_insert( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorAgentConversationInsertCallbackId { + EditorAgentConversationInsertCallbackId(self.imp.on_insert(Box::new(callback))) + } + + fn remove_on_insert(&self, callback: EditorAgentConversationInsertCallbackId) { + self.imp.remove_on_insert(callback.0) + } + + type DeleteCallbackId = EditorAgentConversationDeleteCallbackId; + + fn on_delete( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row) + Send + 'static, + ) -> EditorAgentConversationDeleteCallbackId { + EditorAgentConversationDeleteCallbackId(self.imp.on_delete(Box::new(callback))) + } + + fn remove_on_delete(&self, callback: EditorAgentConversationDeleteCallbackId) { + self.imp.remove_on_delete(callback.0) + } +} + +pub struct EditorAgentConversationUpdateCallbackId(__sdk::CallbackId); + +impl<'ctx> __sdk::TableWithPrimaryKey for EditorAgentConversationTableHandle<'ctx> { + type UpdateCallbackId = EditorAgentConversationUpdateCallbackId; + + fn on_update( + &self, + callback: impl FnMut(&Self::EventContext, &Self::Row, &Self::Row) + Send + 'static, + ) -> EditorAgentConversationUpdateCallbackId { + EditorAgentConversationUpdateCallbackId(self.imp.on_update(Box::new(callback))) + } + + fn remove_on_update(&self, callback: EditorAgentConversationUpdateCallbackId) { + self.imp.remove_on_update(callback.0) + } +} + +/// Access to the `conversation_id` unique index on the table `editor_agent_conversation`, +/// which allows point queries on the field of the same name +/// via the [`EditorAgentConversationConversationIdUnique::find`] method. +/// +/// Users are encouraged not to explicitly reference this type, +/// but to directly chain method calls, +/// like `ctx.db.editor_agent_conversation().conversation_id().find(...)`. +pub struct EditorAgentConversationConversationIdUnique<'ctx> { + imp: __sdk::UniqueConstraintHandle, + phantom: std::marker::PhantomData<&'ctx super::RemoteTables>, +} + +impl<'ctx> EditorAgentConversationTableHandle<'ctx> { + /// Get a handle on the `conversation_id` unique index on the table `editor_agent_conversation`. + pub fn conversation_id(&self) -> EditorAgentConversationConversationIdUnique<'ctx> { + EditorAgentConversationConversationIdUnique { + imp: self.imp.get_unique_constraint::("conversation_id"), + phantom: std::marker::PhantomData, + } + } +} + +impl<'ctx> EditorAgentConversationConversationIdUnique<'ctx> { + /// Find the subscribed row whose `conversation_id` column value is equal to `col_val`, + /// if such a row is present in the client cache. + pub fn find(&self, col_val: &String) -> Option { + self.imp.find(col_val) + } +} + +#[doc(hidden)] +pub(super) fn register_table(client_cache: &mut __sdk::ClientCache) { + let _table = + client_cache.get_or_make_table::("editor_agent_conversation"); + _table.add_unique_constraint::("conversation_id", |row| &row.conversation_id); +} + +#[doc(hidden)] +pub(super) fn parse_table_update( + raw_updates: __ws::v2::TableUpdate, +) -> __sdk::Result<__sdk::TableUpdate> { + __sdk::TableUpdate::parse_table_update(raw_updates).map_err(|e| { + __sdk::InternalError::failed_parse("TableUpdate", "TableUpdate") + .with_cause(e) + .into() + }) +} + +#[allow(non_camel_case_types)] +/// Extension trait for query builder access to the table `EditorAgentConversation`. +/// +/// Implemented for [`__sdk::QueryTableAccessor`]. +pub trait editor_agent_conversationQueryTableAccess { + #[allow(non_snake_case)] + /// Get a query builder for the table `EditorAgentConversation`. + fn editor_agent_conversation(&self) -> __sdk::__query_builder::Table; +} + +impl editor_agent_conversationQueryTableAccess for __sdk::QueryTableAccessor { + fn editor_agent_conversation(&self) -> __sdk::__query_builder::Table { + __sdk::__query_builder::Table::new("editor_agent_conversation") + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_touch_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_touch_input_type.rs new file mode 100644 index 000000000..8142bd62b --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_touch_input_type.rs @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversationTouchInput { + pub conversation_id: String, + pub owner_user_id: String, + pub title: Option, + pub updated_at_micros: i64, +} + +impl __sdk::InModule for EditorAgentConversationTouchInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_type.rs new file mode 100644 index 000000000..e50ec9088 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/editor_agent_conversation_type.rs @@ -0,0 +1,77 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct EditorAgentConversation { + pub conversation_id: String, + pub project_id: String, + pub owner_user_id: String, + pub title: String, + pub messages_object_key: String, + pub deleted: bool, + pub created_at: __sdk::Timestamp, + pub updated_at: __sdk::Timestamp, +} + +impl __sdk::InModule for EditorAgentConversation { + type Module = super::RemoteModule; +} + +/// Column accessor struct for the table `EditorAgentConversation`. +/// +/// Provides typed access to columns for query building. +pub struct EditorAgentConversationCols { + pub conversation_id: __sdk::__query_builder::Col, + pub project_id: __sdk::__query_builder::Col, + pub owner_user_id: __sdk::__query_builder::Col, + pub title: __sdk::__query_builder::Col, + pub messages_object_key: __sdk::__query_builder::Col, + pub deleted: __sdk::__query_builder::Col, + pub created_at: __sdk::__query_builder::Col, + pub updated_at: __sdk::__query_builder::Col, +} + +impl __sdk::__query_builder::HasCols for EditorAgentConversation { + type Cols = EditorAgentConversationCols; + fn cols(table_name: &'static str) -> Self::Cols { + EditorAgentConversationCols { + conversation_id: __sdk::__query_builder::Col::new(table_name, "conversation_id"), + project_id: __sdk::__query_builder::Col::new(table_name, "project_id"), + owner_user_id: __sdk::__query_builder::Col::new(table_name, "owner_user_id"), + title: __sdk::__query_builder::Col::new(table_name, "title"), + messages_object_key: __sdk::__query_builder::Col::new( + table_name, + "messages_object_key", + ), + deleted: __sdk::__query_builder::Col::new(table_name, "deleted"), + created_at: __sdk::__query_builder::Col::new(table_name, "created_at"), + updated_at: __sdk::__query_builder::Col::new(table_name, "updated_at"), + } + } +} + +/// Indexed column accessor struct for the table `EditorAgentConversation`. +/// +/// Provides typed access to indexed columns for query building. +pub struct EditorAgentConversationIxCols { + pub conversation_id: __sdk::__query_builder::IxCol, + pub owner_user_id: __sdk::__query_builder::IxCol, + pub project_id: __sdk::__query_builder::IxCol, +} + +impl __sdk::__query_builder::HasIxCols for EditorAgentConversation { + type IxCols = EditorAgentConversationIxCols; + fn ix_cols(table_name: &'static str) -> Self::IxCols { + EditorAgentConversationIxCols { + conversation_id: __sdk::__query_builder::IxCol::new(table_name, "conversation_id"), + owner_user_id: __sdk::__query_builder::IxCol::new(table_name, "owner_user_id"), + project_id: __sdk::__query_builder::IxCol::new(table_name, "project_id"), + } + } +} + +impl __sdk::__query_builder::CanBeLookupTable for EditorAgentConversation {} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs new file mode 100644 index 000000000..e45514af6 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_editor_agent_conversation_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_get_input_type::EditorAgentConversationGetInput; +use super::editor_agent_conversation_procedure_result_type::EditorAgentConversationProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetEditorAgentConversationAndReturnArgs { + pub input: EditorAgentConversationGetInput, +} + +impl __sdk::InModule for GetEditorAgentConversationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_editor_agent_conversation_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_editor_agent_conversation_and_return { + fn get_editor_agent_conversation_and_return(&self, input: EditorAgentConversationGetInput) { + self.get_editor_agent_conversation_and_return_then(input, |_, _| {}); + } + + fn get_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_editor_agent_conversation_and_return for super::RemoteProcedures { + fn get_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( + "get_editor_agent_conversation_and_return", + GetEditorAgentConversationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs new file mode 100644 index 000000000..b1f34b959 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/list_editor_agent_conversations_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_list_input_type::EditorAgentConversationListInput; +use super::editor_agent_conversation_list_procedure_result_type::EditorAgentConversationListProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct ListEditorAgentConversationsAndReturnArgs { + pub input: EditorAgentConversationListInput, +} + +impl __sdk::InModule for ListEditorAgentConversationsAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `list_editor_agent_conversations_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait list_editor_agent_conversations_and_return { + fn list_editor_agent_conversations_and_return(&self, input: EditorAgentConversationListInput) { + self.list_editor_agent_conversations_and_return_then(input, |_, _| {}); + } + + fn list_editor_agent_conversations_and_return_then( + &self, + input: EditorAgentConversationListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl list_editor_agent_conversations_and_return for super::RemoteProcedures { + fn list_editor_agent_conversations_and_return_then( + &self, + input: EditorAgentConversationListInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAgentConversationListProcedureResult>( + "list_editor_agent_conversations_and_return", + ListEditorAgentConversationsAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs new file mode 100644 index 000000000..e331ab348 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/touch_editor_agent_conversation_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::editor_agent_conversation_procedure_result_type::EditorAgentConversationProcedureResult; +use super::editor_agent_conversation_touch_input_type::EditorAgentConversationTouchInput; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct TouchEditorAgentConversationAndReturnArgs { + pub input: EditorAgentConversationTouchInput, +} + +impl __sdk::InModule for TouchEditorAgentConversationAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `touch_editor_agent_conversation_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait touch_editor_agent_conversation_and_return { + fn touch_editor_agent_conversation_and_return(&self, input: EditorAgentConversationTouchInput) { + self.touch_editor_agent_conversation_and_return_then(input, |_, _| {}); + } + + fn touch_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationTouchInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl touch_editor_agent_conversation_and_return for super::RemoteProcedures { + fn touch_editor_agent_conversation_and_return_then( + &self, + input: EditorAgentConversationTouchInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, EditorAgentConversationProcedureResult>( + "touch_editor_agent_conversation_and_return", + TouchEditorAgentConversationAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-module/Cargo.toml b/server-rs/crates/spacetime-module/Cargo.toml index d2bcf4501..2a62b2921 100644 --- a/server-rs/crates/spacetime-module/Cargo.toml +++ b/server-rs/crates/spacetime-module/Cargo.toml @@ -19,6 +19,7 @@ module-big-fish = { workspace = true, features = ["spacetime-types"] } module-combat = { workspace = true, features = ["spacetime-types"] } module-inventory = { workspace = true, features = ["spacetime-types"] } module-custom-world = { workspace = true, features = ["spacetime-types"] } +module-editor-agent = { workspace = true, features = ["spacetime-types"] } module-jump-hop = { workspace = true, features = ["spacetime-types"] } module-wooden-fish = { workspace = true, features = ["spacetime-types"] } module-match3d = { workspace = true } diff --git a/server-rs/crates/spacetime-module/src/editor_agent_storage.rs b/server-rs/crates/spacetime-module/src/editor_agent_storage.rs new file mode 100644 index 000000000..3b5a1a607 --- /dev/null +++ b/server-rs/crates/spacetime-module/src/editor_agent_storage.rs @@ -0,0 +1,320 @@ +// 中文注释:画布Agent对话会话元数据表。消息正文整体存 OSS(editor-agent/{conversationId}.json), +// 表中只保存会话归属、标题、OSS 对象键与软删标记;领域校验沉在 module-editor-agent。 + +use crate::*; +use module_editor_agent::{ + EditorAgentConversationCreateInput, EditorAgentConversationDeleteInput, + EditorAgentConversationGetInput, EditorAgentConversationListInput, + EditorAgentConversationSnapshot, EditorAgentConversationTouchInput, + validate_create_conversation, validate_delete_conversation, validate_touch_conversation, +}; + +#[spacetimedb::table( + accessor = editor_agent_conversation, + index(accessor = by_editor_agent_conversation_project_id, btree(columns = [project_id])), + index(accessor = by_editor_agent_conversation_owner_user_id, btree(columns = [owner_user_id])) +)] +pub struct EditorAgentConversation { + #[primary_key] + conversation_id: String, + project_id: String, + owner_user_id: String, + title: String, + messages_object_key: String, + deleted: bool, + created_at: Timestamp, + updated_at: Timestamp, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorAgentConversationProcedureResult { + pub ok: bool, + pub conversation: Option, + pub error_message: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct EditorAgentConversationListProcedureResult { + pub ok: bool, + pub conversations: Vec, + pub error_message: Option, +} + +#[spacetimedb::procedure] +pub fn create_editor_agent_conversation_and_return( + ctx: &mut ProcedureContext, + input: EditorAgentConversationCreateInput, +) -> EditorAgentConversationProcedureResult { + match ctx.try_with_tx(|tx| create_editor_agent_conversation(tx, input.clone())) { + Ok(conversation) => editor_agent_conversation_ok(Some(conversation)), + Err(message) => editor_agent_conversation_error(message), + } +} + +#[spacetimedb::procedure] +pub fn list_editor_agent_conversations_and_return( + ctx: &mut ProcedureContext, + input: EditorAgentConversationListInput, +) -> EditorAgentConversationListProcedureResult { + match ctx.try_with_tx(|tx| list_editor_agent_conversations(tx, input.clone())) { + Ok(conversations) => EditorAgentConversationListProcedureResult { + ok: true, + conversations, + error_message: None, + }, + Err(message) => EditorAgentConversationListProcedureResult { + ok: false, + conversations: Vec::new(), + error_message: Some(message), + }, + } +} + +#[spacetimedb::procedure] +pub fn get_editor_agent_conversation_and_return( + ctx: &mut ProcedureContext, + input: EditorAgentConversationGetInput, +) -> EditorAgentConversationProcedureResult { + match ctx.try_with_tx(|tx| get_editor_agent_conversation(tx, input.clone())) { + Ok(conversation) => editor_agent_conversation_ok(Some(conversation)), + Err(message) => editor_agent_conversation_error(message), + } +} + +#[spacetimedb::procedure] +pub fn touch_editor_agent_conversation_and_return( + ctx: &mut ProcedureContext, + input: EditorAgentConversationTouchInput, +) -> EditorAgentConversationProcedureResult { + match ctx.try_with_tx(|tx| touch_editor_agent_conversation(tx, input.clone())) { + Ok(conversation) => editor_agent_conversation_ok(Some(conversation)), + Err(message) => editor_agent_conversation_error(message), + } +} + +#[spacetimedb::procedure] +pub fn delete_editor_agent_conversation_and_return( + ctx: &mut ProcedureContext, + input: EditorAgentConversationDeleteInput, +) -> EditorAgentConversationProcedureResult { + match ctx.try_with_tx(|tx| delete_editor_agent_conversation(tx, input.clone())) { + Ok(conversation) => editor_agent_conversation_ok(Some(conversation)), + Err(message) => editor_agent_conversation_error(message), + } +} + +fn create_editor_agent_conversation( + ctx: &ReducerContext, + input: EditorAgentConversationCreateInput, +) -> Result { + validate_create_conversation(&input).map_err(|error| error.to_string())?; + let now = Timestamp::from_micros_since_unix_epoch(input.created_at_micros); + + if ctx + .db + .editor_agent_conversation() + .conversation_id() + .find(&input.conversation_id) + .is_some() + { + return Err("画布Agent会话已存在".to_string()); + } + crate::ensure_owned_editor_project( + ctx, + input.project_id.as_str(), + input.owner_user_id.as_str(), + )?; + + ctx.db + .editor_agent_conversation() + .insert(EditorAgentConversation { + conversation_id: input.conversation_id.clone(), + project_id: input.project_id, + owner_user_id: input.owner_user_id, + title: input.title, + messages_object_key: input.messages_object_key, + deleted: false, + created_at: now, + updated_at: now, + }); + + build_editor_agent_conversation_snapshot(ctx, input.conversation_id.as_str()) +} + +fn list_editor_agent_conversations( + ctx: &ReducerContext, + input: EditorAgentConversationListInput, +) -> Result, String> { + if input.project_id.trim().is_empty() { + return Err("editor_agent_conversation.project_id 缺失".to_string()); + } + if input.owner_user_id.trim().is_empty() { + return Err("editor_agent_conversation.owner_user_id 缺失".to_string()); + } + let project_id = input.project_id.trim().to_string(); + let owner_user_id = input.owner_user_id.trim(); + + let mut conversations = ctx + .db + .editor_agent_conversation() + .by_editor_agent_conversation_project_id() + .filter(&project_id) + .filter(|conversation| conversation.owner_user_id == owner_user_id && !conversation.deleted) + .map(|conversation| conversation_row_to_snapshot(&conversation)) + .collect::>(); + + conversations.sort_by(|left, right| { + right + .updated_at_micros + .cmp(&left.updated_at_micros) + .then_with(|| right.conversation_id.cmp(&left.conversation_id)) + }); + + Ok(conversations) +} + +fn get_editor_agent_conversation( + ctx: &ReducerContext, + input: EditorAgentConversationGetInput, +) -> Result { + let conversation = require_owned_editor_agent_conversation( + ctx, + input.conversation_id.as_str(), + input.owner_user_id.as_str(), + )?; + Ok(conversation_row_to_snapshot(&conversation)) +} + +fn touch_editor_agent_conversation( + ctx: &ReducerContext, + input: EditorAgentConversationTouchInput, +) -> Result { + validate_touch_conversation(&input).map_err(|error| error.to_string())?; + let conversation = require_owned_editor_agent_conversation( + ctx, + input.conversation_id.as_str(), + input.owner_user_id.as_str(), + )?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + let title = match input.title { + Some(title) if !title.trim().is_empty() => title.trim().to_string(), + _ => conversation.title.clone(), + }; + + ctx.db + .editor_agent_conversation() + .conversation_id() + .delete(&conversation.conversation_id); + ctx.db + .editor_agent_conversation() + .insert(EditorAgentConversation { + conversation_id: conversation.conversation_id.clone(), + project_id: conversation.project_id, + owner_user_id: conversation.owner_user_id, + title, + messages_object_key: conversation.messages_object_key, + deleted: conversation.deleted, + created_at: conversation.created_at, + updated_at: now, + }); + + build_editor_agent_conversation_snapshot(ctx, conversation.conversation_id.as_str()) +} + +fn delete_editor_agent_conversation( + ctx: &ReducerContext, + input: EditorAgentConversationDeleteInput, +) -> Result { + validate_delete_conversation(&input).map_err(|error| error.to_string())?; + let conversation = require_owned_editor_agent_conversation( + ctx, + input.conversation_id.as_str(), + input.owner_user_id.as_str(), + )?; + let now = Timestamp::from_micros_since_unix_epoch(input.updated_at_micros); + + ctx.db + .editor_agent_conversation() + .conversation_id() + .delete(&conversation.conversation_id); + ctx.db + .editor_agent_conversation() + .insert(EditorAgentConversation { + conversation_id: conversation.conversation_id.clone(), + project_id: conversation.project_id, + owner_user_id: conversation.owner_user_id, + title: conversation.title, + messages_object_key: conversation.messages_object_key, + deleted: true, + created_at: conversation.created_at, + updated_at: now, + }); + + build_editor_agent_conversation_snapshot(ctx, conversation.conversation_id.as_str()) +} + +fn require_owned_editor_agent_conversation( + ctx: &ReducerContext, + conversation_id: &str, + owner_user_id: &str, +) -> Result { + let conversation = ctx + .db + .editor_agent_conversation() + .conversation_id() + .find(&conversation_id.to_string()) + .ok_or_else(|| "画布Agent会话不存在".to_string())?; + if conversation.owner_user_id != owner_user_id { + return Err("当前用户不是会话拥有者".to_string()); + } + if conversation.deleted { + return Err("画布Agent会话已删除".to_string()); + } + Ok(conversation) +} + +fn build_editor_agent_conversation_snapshot( + ctx: &ReducerContext, + conversation_id: &str, +) -> Result { + let conversation = ctx + .db + .editor_agent_conversation() + .conversation_id() + .find(&conversation_id.to_string()) + .ok_or_else(|| "画布Agent会话不存在".to_string())?; + Ok(conversation_row_to_snapshot(&conversation)) +} + +fn conversation_row_to_snapshot( + conversation: &EditorAgentConversation, +) -> EditorAgentConversationSnapshot { + EditorAgentConversationSnapshot { + conversation_id: conversation.conversation_id.clone(), + project_id: conversation.project_id.clone(), + owner_user_id: conversation.owner_user_id.clone(), + title: conversation.title.clone(), + messages_object_key: conversation.messages_object_key.clone(), + deleted: conversation.deleted, + created_at_micros: conversation.created_at.to_micros_since_unix_epoch(), + updated_at_micros: conversation.updated_at.to_micros_since_unix_epoch(), + } +} + +fn editor_agent_conversation_ok( + conversation: Option, +) -> EditorAgentConversationProcedureResult { + EditorAgentConversationProcedureResult { + ok: true, + conversation, + error_message: None, + } +} + +fn editor_agent_conversation_error(message: String) -> EditorAgentConversationProcedureResult { + EditorAgentConversationProcedureResult { + ok: false, + conversation: None, + error_message: Some(message), + } +} diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index ba0fbb3cf..17fa92866 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -1586,6 +1586,15 @@ fn canvas_snapshot_from_row(row: EditorCanvas) -> EditorCanvasSnapshot { } } +// 中文注释:供 editor_agent_storage 校验画布工程归属,仅返回是否可访问,不暴露表行。 +pub(crate) fn ensure_owned_editor_project( + ctx: &ReducerContext, + project_id: &str, + owner_user_id: &str, +) -> Result<(), String> { + require_owned_project(ctx, project_id, owner_user_id).map(|_| ()) +} + fn require_owned_project( ctx: &ReducerContext, project_id: &str, @@ -2520,10 +2529,30 @@ mod tests { fn public_showcase_cursor_filters_strictly_after_desc_position() { let cursor = parse_public_showcase_cursor(Some("2000:resource-b")); - assert!(is_after_public_showcase_cursor(1999, "resource-z", cursor.as_ref())); - assert!(is_after_public_showcase_cursor(2000, "resource-a", cursor.as_ref())); - assert!(!is_after_public_showcase_cursor(2000, "resource-b", cursor.as_ref())); - assert!(!is_after_public_showcase_cursor(2000, "resource-c", cursor.as_ref())); - assert!(!is_after_public_showcase_cursor(2001, "resource-a", cursor.as_ref())); + assert!(is_after_public_showcase_cursor( + 1999, + "resource-z", + cursor.as_ref() + )); + assert!(is_after_public_showcase_cursor( + 2000, + "resource-a", + cursor.as_ref() + )); + assert!(!is_after_public_showcase_cursor( + 2000, + "resource-b", + cursor.as_ref() + )); + assert!(!is_after_public_showcase_cursor( + 2000, + "resource-c", + cursor.as_ref() + )); + assert!(!is_after_public_showcase_cursor( + 2001, + "resource-a", + cursor.as_ref() + )); } } diff --git a/server-rs/crates/spacetime-module/src/lib.rs b/server-rs/crates/spacetime-module/src/lib.rs index a6fe765b5..6b9b5e7e5 100644 --- a/server-rs/crates/spacetime-module/src/lib.rs +++ b/server-rs/crates/spacetime-module/src/lib.rs @@ -30,6 +30,7 @@ mod bark_battle; mod big_fish; mod custom_world; mod domain_types; +mod editor_agent_storage; mod editor_project_storage; mod entry; mod external_api_key_storage; @@ -53,6 +54,7 @@ pub use bark_battle::*; pub use big_fish::*; pub use custom_world::*; pub use domain_types::*; +pub use editor_agent_storage::*; pub use editor_project_storage::*; pub use entry::*; pub use external_api_key_storage::*; diff --git a/server-rs/crates/spacetime-module/src/migration.rs b/server-rs/crates/spacetime-module/src/migration.rs index ff23fe2db..d8ad66721 100644 --- a/server-rs/crates/spacetime-module/src/migration.rs +++ b/server-rs/crates/spacetime-module/src/migration.rs @@ -232,6 +232,7 @@ macro_rules! migration_tables { asset_entity_binding, asset_event, external_api_key, + editor_agent_conversation, editor_project, editor_canvas, editor_project_resource, diff --git a/server-rs/crates/spacetime-module/src/runtime/profile.rs b/server-rs/crates/spacetime-module/src/runtime/profile.rs index e085c38ae..492130b3c 100644 --- a/server-rs/crates/spacetime-module/src/runtime/profile.rs +++ b/server-rs/crates/spacetime-module/src/runtime/profile.rs @@ -3624,21 +3624,23 @@ fn insert_profile_code_operation( .by_profile_code_operation_kind_code() .filter((code_kind, code)) .count(); - ctx.db.profile_code_operation().insert(ProfileCodeOperation { - operation_id: format!( - "{}:{}:{}:{}:{}", - code_kind.trim(), - code.trim(), - action.trim(), - created_at_micros, - operation_index - ), - code_kind: code_kind.to_string(), - code: code.to_string(), - action: action.to_string(), - operator_user_id: operator_user_id.to_string(), - created_at, - }); + ctx.db + .profile_code_operation() + .insert(ProfileCodeOperation { + operation_id: format!( + "{}:{}:{}:{}:{}", + code_kind.trim(), + code.trim(), + action.trim(), + created_at_micros, + operation_index + ), + code_kind: code_kind.to_string(), + code: code.to_string(), + action: action.to_string(), + operator_user_id: operator_user_id.to_string(), + created_at, + }); } fn profile_code_operation_snapshots( diff --git a/src/components/image-editor/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversationPanelView.test.tsx new file mode 100644 index 000000000..6bf4024d4 --- /dev/null +++ b/src/components/image-editor/EditorAgentConversationPanelView.test.tsx @@ -0,0 +1,281 @@ +/* @vitest-environment jsdom */ + +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView'; +import type { EditorAgentConversationClient } from './useEditorAgentConversation'; + +function createClient(): EditorAgentConversationClient { + return { + listConversations: vi.fn().mockResolvedValue([ + { + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + }, + ]), + createConversation: vi.fn().mockResolvedValue({ + conversationId: 'conversation-2', + projectId: 'project-1', + title: '新对话', + messages: [], + createdAt: '2026-07-03T00:01:00.000Z', + updatedAt: '2026-07-03T00:01:00.000Z', + }), + getConversation: vi.fn().mockResolvedValue({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [ + { + id: 'assistant-old', + role: 'assistant', + kind: 'chat', + text: '已经看到画布内容', + attachments: [], + generations: [], + status: 'completed', + createdAt: '2026-07-03T00:00:10.000Z', + }, + ], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:10.000Z', + }), + deleteConversation: vi.fn().mockResolvedValue({ + conversationId: 'conversation-1', + projectId: 'project-1', + title: '角色参考', + messages: [], + createdAt: '2026-07-03T00:00:00.000Z', + updatedAt: '2026-07-03T00:00:00.000Z', + }), + streamMessage: vi + .fn() + .mockImplementation(async (_conversationId, _payload, options) => { + options.onEvent?.({ + event: 'stage', + data: { conversationId: _conversationId, stage: 'responding' }, + }); + options.onEvent?.({ + event: 'message_delta', + data: { + conversationId: _conversationId, + messageId: 'assistant-stream', + role: 'assistant', + kind: 'chat', + textDelta: '收到,我会参考这张图。', + }, + }); + options.onEvent?.({ + event: 'done', + data: { conversationId: _conversationId, title: null }, + }); + }), + }; +} + +describe('EditorAgentConversationPanelView', () => { + it('manages conversations, attachments and message sending inside the panel', async () => { + const client = createClient(); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole('button', { name: '新建对话' })); + await waitFor(() => { + expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); + }); + + fireEvent.click(screen.getByRole('button', { name: '添加附件' })); + const attachmentDialog = screen.getByRole('dialog', { + name: '选择图片附件', + }); + expect( + within(attachmentDialog).queryByRole('checkbox', { + name: '选择画布图片 未入库图层', + }), + ).toBeNull(); + fireEvent.click( + within(attachmentDialog).getByRole('checkbox', { + name: '选择画布图片 角色图层', + }), + ); + fireEvent.click( + within(attachmentDialog).getByRole('button', { name: '应用' }), + ); + expect(screen.getByText('角色图层')).toBeTruthy(); + + fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { + target: { value: '参考附件做像素风' }, + }); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + + await waitFor(() => { + expect(screen.getByText('收到,我会参考这张图。')).toBeTruthy(); + }); + expect(client.streamMessage).toHaveBeenCalledWith( + 'conversation-2', + expect.objectContaining({ + text: '参考附件做像素风', + attachments: [ + expect.objectContaining({ + source: 'canvas_resource', + referenceId: 'resource-1', + }), + ], + }), + expect.any(Object), + ); + }); + + it('sends selected attachments even when the text input is empty', async () => { + const client = createClient(); + + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole('button', { name: '添加附件' })); + const attachmentDialog = screen.getByRole('dialog', { + name: '选择图片附件', + }); + fireEvent.click( + within(attachmentDialog).getByRole('checkbox', { + name: '选择画布图片 角色图层', + }), + ); + fireEvent.click( + within(attachmentDialog).getByRole('button', { name: '应用' }), + ); + fireEvent.click(screen.getByRole('button', { name: '发送' })); + + await waitFor(() => { + expect(client.streamMessage).toHaveBeenCalledWith( + 'conversation-1', + expect.objectContaining({ + text: '', + attachments: [ + expect.objectContaining({ + source: 'canvas_resource', + referenceId: 'resource-1', + }), + ], + }), + expect.any(Object), + ); + }); + }); + + it('confirms conversation deletion from an independent dialog', async () => { + const client = createClient(); + render( + , + ); + + await waitFor(() => { + expect(screen.getByText('已经看到画布内容')).toBeTruthy(); + }); + + fireEvent.click(screen.getByRole('button', { name: '删除当前对话' })); + const confirmDialog = screen.getByRole('dialog', { name: '删除对话' }); + fireEvent.click( + within(confirmDialog).getByRole('button', { name: '确认删除' }), + ); + + await waitFor(() => { + expect(client.deleteConversation).toHaveBeenCalledWith('conversation-1'); + }); + }); +}); diff --git a/src/components/image-editor/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversationPanelView.tsx new file mode 100644 index 000000000..48e288f13 --- /dev/null +++ b/src/components/image-editor/EditorAgentConversationPanelView.tsx @@ -0,0 +1,721 @@ +import { + Bot, + Image as ImageIcon, + Loader2, + MessageCircle, + Paperclip, + Plus, + Send, + Square, + Trash2, + X, +} from 'lucide-react'; +import { type FormEvent, useMemo, useState } from 'react'; + +import { + EDITOR_AGENT_MAX_ATTACHMENTS, + type EditorAgentAttachmentRef, + type EditorAgentGenerationRecord, + type EditorAgentMessage, + type EditorAgentStage, +} from '../../../packages/shared/src/contracts/editorAgent'; +import { PlatformActionButton } from '../common/PlatformActionButton'; +import { PlatformDangerConfirmDialog } from '../common/PlatformDangerConfirmDialog'; +import { UnifiedModal } from '../common/UnifiedModal'; +import type { CanvasLayer, EditorAsset } from './ImageCanvasEditorTypes'; +import { + type EditorAgentConversationClient, + useEditorAgentConversation, +} from './useEditorAgentConversation'; + +type AttachmentPickerTab = 'canvas' | 'library'; + +type EditorAgentAttachmentOption = { + key: string; + sourceLabel: string; + attachment: EditorAgentAttachmentRef; +}; + +type EditorAgentConversationPanelViewProps = { + projectId?: string | null; + open: boolean; + onToggleOpen: () => void; + layers?: CanvasLayer[]; + assets?: EditorAsset[]; + onFocusResource?: (resourceId: string) => void; + client?: EditorAgentConversationClient; +}; + +function attachmentKey(attachment: EditorAgentAttachmentRef) { + return `${attachment.source}:${attachment.referenceId}`; +} + +function isImageLayer(layer: CanvasLayer) { + return ( + (layer.mediaType ?? 'image') === 'image' && + Boolean(layer.resourceId?.trim()) && + layer.src.trim() + ); +} + +function isImageAsset(asset: EditorAsset) { + return (asset.mediaType ?? 'image') === 'image' && asset.src.trim(); +} + +function createCanvasAttachmentOptions( + layers: CanvasLayer[] = [], +): EditorAgentAttachmentOption[] { + return layers.filter(isImageLayer).map((layer) => { + const attachment: EditorAgentAttachmentRef = { + source: 'canvas_resource', + referenceId: layer.resourceId || layer.id, + objectKey: layer.objectKey ?? null, + imageSrc: layer.src, + thumbnailSrc: layer.thumbnailSrc ?? null, + label: layer.title, + width: layer.width, + height: layer.height, + }; + return { + key: attachmentKey(attachment), + sourceLabel: '画布', + attachment, + }; + }); +} + +function createLibraryAttachmentOptions( + assets: EditorAsset[] = [], +): EditorAgentAttachmentOption[] { + return assets.filter(isImageAsset).map((asset) => { + const attachment: EditorAgentAttachmentRef = { + source: 'library_asset', + referenceId: asset.id, + objectKey: asset.objectKey ?? null, + imageSrc: asset.src, + thumbnailSrc: asset.thumbnailSrc ?? null, + label: asset.label, + width: asset.width, + height: asset.height, + }; + return { + key: attachmentKey(attachment), + sourceLabel: '素材库', + attachment, + }; + }); +} + +function stageLabel(stage: EditorAgentStage) { + if (stage === 'thinking') { + return '思考中'; + } + if (stage === 'responding') { + return '回复中'; + } + if (stage === 'generating') { + return '生成中'; + } + if (stage === 'completed') { + return '完成'; + } + if (stage === 'failed') { + return '失败'; + } + return ''; +} + +function toolLabel(toolName: EditorAgentGenerationRecord['toolName']) { + if (toolName === 'edit_image') { + return '修改图片'; + } + if (toolName === 'generate_character') { + return '生成角色'; + } + if (toolName === 'generate_icon_spritesheet') { + return '生成图标'; + } + if (toolName === 'generate_ui_design') { + return '生成 UI'; + } + return '生成图片'; +} + +function messageRoleLabel(role: EditorAgentMessage['role']) { + return role === 'user' ? '你' : 'Agent'; +} + +function AttachmentChip({ + attachment, + onRemove, +}: { + attachment: EditorAgentAttachmentRef; + onRemove?: () => void; +}) { + const label = attachment.label?.trim() || attachment.referenceId; + return ( + + + ); +} + +function GenerationRecordsView({ + generations, + onFocusResource, +}: { + generations: EditorAgentGenerationRecord[]; + onFocusResource?: (resourceId: string) => void; +}) { + if (!generations.length) { + return null; + } + return ( +
+ {generations.map((generation) => ( +
+
+ {generation.status === 'generating' ? ( +
+ {generation.error ? ( +
{generation.error}
+ ) : null} + {generation.images.length ? ( +
+ {generation.images.map((image, index) => ( + + ))} +
+ ) : null} +
+ ))} +
+ ); +} + +function MessageBubble({ + message, + onFocusResource, +}: { + message: EditorAgentMessage; + onFocusResource?: (resourceId: string) => void; +}) { + const isUser = message.role === 'user'; + return ( +
+
+
+ {message.text || (message.status === 'streaming' ? '...' : '')} +
+ {message.attachments.length ? ( +
+ {message.attachments.map((attachment) => ( + + ))} +
+ ) : null} + +
+
+ ); +} + +function AttachmentPickerModal({ + open, + tab, + canvasOptions, + libraryOptions, + selectedKeys, + attachmentError, + onTabChange, + onToggleKey, + onApply, + onClose, +}: { + open: boolean; + tab: AttachmentPickerTab; + canvasOptions: EditorAgentAttachmentOption[]; + libraryOptions: EditorAgentAttachmentOption[]; + selectedKeys: Set; + attachmentError: string | null; + onTabChange: (tab: AttachmentPickerTab) => void; + onToggleKey: (key: string) => void; + onApply: () => void; + onClose: () => void; +}) { + const visibleOptions = tab === 'canvas' ? canvasOptions : libraryOptions; + + return ( + + + 取消 + + + 应用 + + + } + > +
+
+ + +
+ {attachmentError ? ( +
+ {attachmentError} +
+ ) : null} +
+ {visibleOptions.map((option) => { + const label = + option.attachment.label?.trim() || option.attachment.referenceId; + return ( + + ); + })} +
+
+
+ ); +} + +export function EditorAgentConversationPanelView({ + projectId, + open, + onToggleOpen, + layers = [], + assets = [], + onFocusResource, + client, +}: EditorAgentConversationPanelViewProps) { + const effectiveProjectId = open ? projectId : null; + const { + conversations, + activeConversationId, + messages, + stage, + isLoadingConversations, + isLoadingMessages, + isCreatingConversation, + isDeletingConversation, + isStreaming, + errorMessage, + createConversation, + selectConversation, + sendMessage, + stopCurrentTurn, + deleteActiveConversation, + } = useEditorAgentConversation({ projectId: effectiveProjectId, client }); + const [draftText, setDraftText] = useState(''); + const [attachments, setAttachments] = useState( + [], + ); + const [attachmentPickerOpen, setAttachmentPickerOpen] = useState(false); + const [attachmentPickerTab, setAttachmentPickerTab] = + useState('canvas'); + const [attachmentError, setAttachmentError] = useState(null); + const [draftAttachmentKeys, setDraftAttachmentKeys] = useState>( + () => new Set(), + ); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + + const canvasAttachmentOptions = useMemo( + () => createCanvasAttachmentOptions(layers), + [layers], + ); + const libraryAttachmentOptions = useMemo( + () => createLibraryAttachmentOptions(assets), + [assets], + ); + const attachmentOptionsByKey = useMemo(() => { + const optionMap = new Map(); + [...canvasAttachmentOptions, ...libraryAttachmentOptions].forEach( + (option) => optionMap.set(option.key, option), + ); + return optionMap; + }, [canvasAttachmentOptions, libraryAttachmentOptions]); + + const currentStageLabel = stageLabel(stage); + const hasProject = Boolean(projectId?.trim()); + + const openAttachmentPicker = () => { + setAttachmentError(null); + setDraftAttachmentKeys(new Set(attachments.map(attachmentKey))); + setAttachmentPickerOpen(true); + }; + + const toggleAttachmentKey = (key: string) => { + setDraftAttachmentKeys((currentKeys) => { + const nextKeys = new Set(currentKeys); + if (nextKeys.has(key)) { + nextKeys.delete(key); + } else { + nextKeys.add(key); + } + return nextKeys; + }); + }; + + const applyAttachmentSelection = () => { + const nextAttachments = Array.from(draftAttachmentKeys) + .map((key) => attachmentOptionsByKey.get(key)?.attachment) + .filter((attachment): attachment is EditorAgentAttachmentRef => + Boolean(attachment), + ); + if (nextAttachments.length > EDITOR_AGENT_MAX_ATTACHMENTS) { + setAttachmentError(`最多 ${EDITOR_AGENT_MAX_ATTACHMENTS} 张`); + return; + } + setAttachments(nextAttachments); + setAttachmentPickerOpen(false); + }; + + const submitMessage = (event: FormEvent) => { + event.preventDefault(); + if (isStreaming) { + stopCurrentTurn(); + return; + } + const text = draftText.trim(); + if (!text && !attachments.length) { + return; + } + setDraftText(''); + const nextAttachments = attachments; + setAttachments([]); + void sendMessage(text, nextAttachments).catch(() => { + setDraftText((currentText) => (currentText ? currentText : text)); + setAttachments((currentAttachments) => + currentAttachments.length ? currentAttachments : nextAttachments, + ); + }); + }; + + const removeAttachment = (targetAttachment: EditorAgentAttachmentRef) => { + const key = attachmentKey(targetAttachment); + setAttachments((currentAttachments) => + currentAttachments.filter( + (attachment) => attachmentKey(attachment) !== key, + ), + ); + }; + + if (!open) { + return ( + + ); + } + + return ( + <> +