diff --git a/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md b/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md
index 194a23ea0..c1c881dbe 100644
--- a/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md
+++ b/docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md
@@ -4,7 +4,7 @@
## 背景
-画布Agent对话需要保存历史记录并支持新开会话。消息正文是流式追加、体量随对话增长的 JSON 数据;SpacetimeDB 是后端唯一结构化真相存储,但表行不适合承载不断增长的长文本;画布工程快照已有独立保存链路,且对话流式更新频率远高于布局保存。
+画布Agent对话需要保存历史记录并支持新开会话。消息正文会随对话和工具结果持续增长;SpacetimeDB 是后端唯一结构化真相存储,但表行不适合承载不断增长的长文本;画布工程快照已有独立保存链路,对话消息也不应与布局保存互相竞争。
## 决策
@@ -14,7 +14,7 @@
- 浏览器禁止直接上传、覆盖或签名写入 `editor-agent/` 对象;前端只通过 `api-server` 的会话接口创建会话、发送消息、读取历史,OSS 读写由服务端完成。
- 消息文档序列化后的读写上限为 2 MiB;超过上限时后端拒绝继续读写该会话消息文档,并返回 payload too large 语义错误。
- 同一会话内的消息追加采用 `conversationId` 级串行锁,避免同一会话的“读-改-写”整对象过程互相覆盖。
-- Agent 工具调用失败时,仍保留 assistant 消息和 `failed` generation record,包括 tool call、模型、错误信息等排障字段;失败记录是会话历史的一部分,不因 SSE 已返回错误而丢弃。
+- Agent 规划失败使用 `role=system`、正文以 `ERROR ` 开头的消息持久化;首次响应和同 `clientMessageId` 重放都通过 `deltaMessages` 返回该消息,`errorMessage` 不重复携带。前端隐藏前缀并显示红色错误气泡,后端构建后续 LLM memory 时仍保留该消息,让 Agent 获取上一轮失败上下文。工具调用失败继续保留 `status=failed` 工具记录、模型和错误信息;失败记录都是会话历史的一部分。
- 不把消息明细写入 SpacetimeDB 表,不把对话混入画布工程快照,不在 api-server 内存中保存会话真相。
## 备选方案与取舍
@@ -31,5 +31,5 @@
- 会话表仍只保存元数据和 `messagesObjectKey`;API 可以在读取时把 OSS 消息文档拼装为会话详情返回,但完整消息正文的持久化真相仍是 OSS JSON 文档。
- 消息文档最大 2 MiB;该限制用于阻止单个会话无限增长。后续如果需要更长历史,应引入归档、分页对象或摘要压缩,不应把正文回填进 SpacetimeDB 表。
- 会话软删只打表标记,OSS 对象保留,便于恢复与审计。
-- 工具生成失败也会写入消息文档:assistant 消息可标记为失败 / 错误类型,`generations` 中保留 `failed` record、模型和错误信息,便于用户回看失败原因和后续排障。
+- 规划或工具生成失败也必须写入消息文档:规划失败保存 `ERROR ` system 消息,工具失败保存失败状态、模型和错误信息,便于用户回看失败原因和后续排障。
- 若未来出现跨会话消息检索需求,需另建投影或索引,不回退为消息入表。
diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md
index 74aa4db36..7af62ba89 100644
--- a/docs/project-memory/shared-memory/decision-log.md
+++ b/docs/project-memory/shared-memory/decision-log.md
@@ -15,6 +15,21 @@
```
---
+## 2026-07-17 画布 Agent 普通消息不提供客户端停止
+
+- 背景:普通消息进入 LLM 前,后端已经把用户消息写入 OSS;前端中断 fetch 只能停止本地等待,不能保证后端停止规划,且会保留无法与后端消息对齐的 optimistic message。
+- 决策:移除画布 Agent 普通消息的“停止”按钮和 `stopCurrentTurn`,发送期间保持按钮禁用并等待后端响应。待确认工具调用的“取消”仍保留,不受本决策影响。
+- 影响范围:画布 Agent 对话 hook、发送区交互、前端测试和专题文档。
+- 验证方式:运行画布 Agent hook / 面板定向测试、`npm run typecheck`、`npm run check:encoding` 和 `git diff --check`。
+- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`。
+
+## 2026-07-10 画布 Agent 工具确认分离执行参数与展示投影
+
+- 背景:画布 Agent 已在实际生成前进入 `pending_confirmation`,但 `EditorAgentToolCall.args` 只保存工具私有 JSON,其中图片参数是保护真实 data key 的 SHA-256 opaque ID。前端直接解析 raw args 只能显示内部哈希或图片数量,无法向用户准确展示即将使用的目标图、参考图和完整参数;若直接把图片 URL 或对象塞回 raw args,又会破坏确认执行反序列化和 LLM 不可见真实 data key 的安全边界。
+- 决策:`EditorAgentToolCall.args` 继续作为确认执行唯一真相,不允许前端改写或回传替代参数;新增必填 `displayArgs` 只读展示投影,内含 `stringArgs`、`imageArgs` 和 `extras.priceMudPoints`。`stringArgs` 承载提示词与规格等用户可见字段,`imageArgs.refs` 承载 `imageId` 及后端解析出的 `objectKey`、`imageSrc`、可选缩略图、标签和尺寸;`extras.priceMudPoints` 由 api-server 在创建待确认消息时使用后端运行时模型定价快照计算,前端只显示“预计消耗 N泥点”,不自行计算或回传价格。api-server 必须按已注册 tool 白名单,从已校验 args 与 OSS 会话文档的附件 / 历史生成结果构建该投影;前端只渲染投影,以 `ResolvedAssetImage` 换签显示图片,不解析 tool 私有 schema、不展示 SHA-256 ID。展示价格不参与确认执行或实际扣费,确认后仍由既有生成 BFF 按后端运行时定价预扣费。删除只重复 `args` 且没有稳定语义的 `EditorAgentToolCall.summary`。模块尚未上线,不保留缺少 `displayArgs` 时读取 raw `args` 的旧消息降级路径。
+- 影响范围:`shared-contracts` / `packages/shared` 的 `editorAgent` DTO、`api-server/src/editor_agent/api.rs` 的待确认消息构建、画布 Agent 待确认卡、OSS 会话消息文档与相关测试。
+- 验证方式:`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml editor_agent`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml editor_agent`、`npm run test -- src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx src/services/image-editor/editorAgentClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
+- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md`、`docs/adr/【ADR】画布Agent会话消息存OSS-2026-07-03.md`。
## 2026-07-15 角色动作 BgFilter 请求超时按帧数扩展
@@ -275,10 +290,10 @@
## 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 只属于编辑器画布域,不能复用拼图 `creative-agent` 内存会话。
+- 决策:`module-editor-agent` 只承载可供 SpacetimeDB WASM 使用的纯领域规则;Agent runner、工具实现和资产 DTO 迁入原生 `platform-editor-agent`,仅由 `api-server` 依赖。`editor_agent_conversation` 只保存会话元数据,完整消息以 `editor-agent/{conversationId}.json` 会话粒度存 OSS;`api-server` 负责编排 LLM、普通 JSON 消息、OSS 读写和既有生成工具调用。用户消息以独立 `clientMessageId` 在会话锁内幂等,数字 `message.id` 只作后端定位;旧 OSS 消息允许缺失幂等键,早期用户消息字符串 `id` 在读取时迁入 `clientMessageId`。画布 Agent 只与任务侧栏互斥,不与左侧素材 / 图层栏互斥。
- 影响范围:图片画布右侧 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`。
+- 验证方式:`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 面板与 JSON 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 同步正式表
@@ -3233,6 +3248,13 @@
- 影响范围:`server-rs/crates/api-server/src/editor_generation_queue.rs`、`server-rs/crates/api-server/src/external_generation_worker.rs`、`server-rs/crates/api-server/src/editor_project.rs`、`server-rs/crates/api-server/src/character_animation_assets.rs`、`server-rs/crates/api-server/src/vector_engine_audio_generation/generation.rs`、`src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.ts`、`src/services/image-editor/editorProjectClient.ts`。
- 验证方式:`cargo test -p api-server external_generation_worker --manifest-path server-rs/Cargo.toml`、`cargo test -p api-server editor_canvas_generation --manifest-path server-rs/Cargo.toml`、`cargo test -p shared-contracts --manifest-path server-rs/Cargo.toml`、`npm run test -- src/components/image-editor/useImageCanvasGenerationSubmissionWorkflow.test.tsx src/services/image-editor/editorProjectClient.test.ts`、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
+## 2026-07-13 画布 Agent 工具执行状态复用外部生成任务
+
+- 决策:画布 Agent 的 OSS 工具消息使用 `status=not_completed|completed|failed|cancelled` 和可选 `externalJobId`;不使用 `cancelledAt`,不新增关联表。`external_generation_job` 是排队、执行、lease 与计费结算的唯一真相;OSS status 只表达该消息回填结果,不复制 queued / running。确认接口按 `conversationId + messageId + toolName` 稳定去重并复用既有编辑器 worker job kind。
+- 懒回填:`GET /conversation` 会在持有 conversation lock 后扫描 `status=not_completed` 且有 `externalJobId` 的工具消息,按 job id 定向读取主任务;完成时复用原工具 formatter 更新 system text、写入轻量媒体引用并标记 `completed`,任务本身失败时写入 `error` 并标记 `failed`。任务结果读取或 completed payload 解析 / formatter 首次失败后,在同一次 GET 内最多重试 3 次,每次等待 100ms 并重新读取主任务;读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后保留 `not_completed + externalJobId` 供下次 GET 继续 reconcile,确定性的 payload 损坏、结构不兼容或 formatter 错误才在重试耗尽后写为 `failed`,避免致命错误永久循环。排队 / 执行保持 `not_completed`。worker 的 `result_payload_json` 只保留 formatter 与媒体引用所需的轻量生成回包,不向通用 summary 状态接口投影。
+- 影响范围:画布 Agent 共享契约、确认/取消接口、编辑器生成入队 helper、对话状态展示与恢复。
+- 验证方式:`cargo check -p api-server -p shared-contracts --manifest-path server-rs/Cargo.toml`、画布 Agent 定向前端测试、`npm run typecheck`、`npm run check:encoding`、`git diff --check`。
+
## 2026-06-18 原生壳 WebView 刷新能力只保留受控当前页刷新
- 背景:Expo 移动壳和 Tauri 桌面壳都需要一个真实的宿主级刷新入口,供 H5 在检测到资源、登录态或运行态需要重新载入时请求宿主刷新当前容器;该能力不能演变成任意 URL 导航或原生 WebView ref 透传。
@@ -4055,7 +4077,7 @@
- 背景:VectorEngine Apifox `api-349239079` 暴露 OpenAI-compatible `POST /v1/chat/completions`;创意 Agent 和通用 LLM 代理需要统一到 VectorEngine 文本服务,并将默认文本模型切换为 `gpt-5.4-mini`。
- 决策:创意 Agent 的 `CREATIVE_AGENT_GPT5_MODEL` 固定为 `gpt-5.4-mini`,协议切到 Chat Completions,不再携带旧 APIMart `official_fallback` 字段;画布 Agent 侧边栏聊天规划请求也复用该模型和 Chat Completions 协议,不再显式使用 `gpt-4o` / Responses。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`。未单独配置 `GENARRATIVE_LLM_API_KEY` 时,api-server 可复用 `VECTOR_ENGINE_API_KEY`;前端 LLM 客户端必须兼容 OpenAI `choices`、api-server raw `{content}` 和项目 envelope `{ok,data:{content}}` 三种非流式响应,以及 OpenAI SSE delta 和 api-server `event: delta` 两种流式响应。
-- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `summary` / `toolName` / `resourceId` / `objectKey` 等轻量摘要。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit_image` 并引用该结果;不能因为本轮没有手动附件而退回 `generate_image`。
+- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `toolName` / `resourceId` / `objectKey` 等轻量元数据。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit_image` 并引用该结果;不能因为本轮没有手动附件而退回 `generate_image`。
- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。
- 影响范围:`server-rs/crates/platform-agent`、`server-rs/crates/api-server/src/config.rs`、`src/services/llmClient.ts`、`.env.example`、`deploy/env/api-server.env.example`、`scripts/test-ve-llm.mjs`。
- 验证方式:`npm run test -- src/services/llmClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。
diff --git a/docs/project-memory/shared-memory/document-map.md b/docs/project-memory/shared-memory/document-map.md
index 974e0d77c..8aa4b638f 100644
--- a/docs/project-memory/shared-memory/document-map.md
+++ b/docs/project-memory/shared-memory/document-map.md
@@ -1,6 +1,6 @@
# 文档地图与阅读索引
-更新时间:`2026-07-03`
+更新时间:`2026-07-16`
## 当前文档入口
@@ -18,7 +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` |
+| 图片画布右侧 Agent 对话、会话消息 OSS 持久化、普通 JSON 消息、工具任务懒回填及侧栏并存规则 | `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 567120384..e6198b20e 100644
--- a/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md
+++ b/docs/technical/【前端架构】SSE客户端传输层收口约定-2026-06-03.md
@@ -1,6 +1,6 @@
# SSE 客户端传输层收口约定
-更新时间:`2026-06-03`
+更新时间:`2026-07-16`
## 背景
@@ -13,7 +13,6 @@
- `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`。
## 落地范围
@@ -26,27 +25,15 @@
- `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` 和画布视图层。
+画布 Agent 已改为 `POST /api/editor/agent-conversations/{conversationId}/messages` 普通 JSON 请求,不属于本 SSE 传输层的落地范围;其客户端只通过 `requestJson` 读取 `EditorAgentMessageResponse`,不得为了恢复旧文档口径重新增加私有 SSE parser。
## 验收
- `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 逻辑,应视为违反本收口约定。
+- `src/services/image-editor/editorAgentClient.test.ts` 覆盖会话 CRUD 和 `/messages` 普通 JSON 路由;画布 Agent 不纳入 SSE parser 验收。
- 已有 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 84c136ca2..0a33c8375 100644
--- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
+++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md
@@ -28,7 +28,7 @@
- 图片快速编辑面板只保留一个提示词输入框和模型选择,不展示额外参考图或比例 / 尺寸控件;原图 / 原素材作为 `/api/editor/images/edits` 的 `sourceImageSrc` 直接提交,不作为 `referenceImageSrcs`。完整图标图集 `icon-spritesheet` 支持快速编辑,拆分后的单个 `icon` 不提供该入口,前后端必须使用同一素材类型规则。打开快速编辑时画布必须自动平移缩放,让原素材完整落在可视区上半部分,底部面板固定出现在素材下方且不遮挡内容,竖屏 UI 素材也必须完整展示。快速编辑右侧显示矩形、椭圆、画笔框选工具,但进入时不默认启用;点击工具后显示选中态,再点同一工具取消启用。完成框选后,画布红色细框显示连续序号,提示词可按这些编号填写每个区域怎么改。点击 `修改` 后仍停留在当前快速编辑面板显示修改中,不创建独立 `Quick Edit Generator` 画布占位;生成成功后直接用结果覆盖原图图层,失败时保留当前面板并在错误红框中显示具体错误文案。
- 底部生成类按钮每次点击都必须创建独立的画布生成对象;新建规范、角色形象或图标素材时,只切换当前编辑面板,不得销毁此前尚未生成或已生成后的其它生成对象状态。归档为非当前编辑对象的生成占位仍可拖动、删除和等待异步完成,完成 / 失败回写必须按生成对象 ID 读取最新占位状态,不能使用提交瞬间的旧快照。
- 画布右上角提供自动隐藏任务侧栏。列表为空且侧栏关闭时只保留图标开关;生成或去背景任务进入时默认打开;用户可手动切换开关状态。进行中阶段只使用外部生成 BFF 返回的 `phaseDetail`:调用或等待图片 / 视频生成服务时显示“正在生成”,进入 BgFilter、逐帧抠图或手动去背景时显示“正在处理”;前端不得按耗时或任务类型猜测阶段。
-- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板与素材侧栏、图层侧栏、右上角任务侧栏互斥,打开 Agent 时必须收起其它侧栏,打开其它侧栏或任务侧栏时也必须收起 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。
+- 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。
- 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。
## 交互规则
@@ -59,7 +59,7 @@
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 ID 和 groupId 保存在 `editor_canvas` 的布局 JSON。布局 JSON 是混合数组:普通图层按 `layerId/resourceId` 保存,生成器占位和生成器对话框按 `itemType: "generation-dialog"` 保存,不新增单独表。普通图层的新保存不再把 `assetKind/generationInputs` 写入布局 JSON;刷新时优先从 `editor_project_resource` 恢复,旧布局中的同名字段只作为兼容兜底。生成器快照必须包含生成器 ID、模式、提示词、参数、参考图、状态、占位框位置和可选 `generatedLayerId`;角色、图标等纯色抠图生成器的前端用户路径不保存或恢复 `screenColor` / `segModel`,同源重绘也不再从 `generationInputs.fields` 恢复 `抠图背景色` 或 `抠图模型`;宣发素材生成器还必须保存并恢复 `publicationWorkflowId`、`publicationGameInfo` 和 `publicationReferences`,避免刷新后生成卡片字段或参考图丢失。生成器快照中的参考图同样只保存 `resourceId/sourceAssetId` 行引用和展示所需 label,不保存图片 Data URL、signed URL 或 `objectKey`;刷新时用 `editor_project_resource` / `editor_asset` 行恢复临时生成请求所需图片源。生成成功后仍保存该快照,只是渲染时由 `generatedLayerId` 锚定到成品图层而不重复显示灰色占位框。`generationInputs.references` 是用户可见输入快照中的行级索引,只允许保存 `{ title, label, refType, refId }`;生成接口只接收提交前临时状态解析出的 `objectKey` 或资源 ID;Data URL、Blob URL 和 signed URL 不进入请求体,不进入资源 / 素材元数据。图层展示尺寸不再作为独立 `Size` 真相保存,刷新与新建图层均按 `Resolution`(`originalWidth/originalHeight`)原分辨率显示。图层组第一版是画布内布局语义,不单独建表。
- 图片类、生成视频和音频结果除作为 `editor_project_resource` 和画布图层保存外,还要写入账号级 `editor_asset` 素材库;该写入由生成 BFF 在请求携带 `assetFolderId` 时完成。角色、图标图集、UI 提取和角色动作等多产物任务把实际产生的 provider 原始输出及后处理结果分别入库:所有条目沿用 `character`、`icon-spritesheet`、`character-animation` 等真实类型,provider 原始输出承载任务模型成本,后处理派生产物阶段成本为 0。后台素材查询以最终产物为父行、每个中间产物为可展开的独立子行,分页只计算父任务;手动重拆图集保留独立 `taskId` 用于存储隔离和日志排障,通过私有 provenance 从服务端生成账号素材的 source resource、asset object 或 Object Key 取得可信来源任务,并把它写入 `groupTaskId`,不信任客户端可提交的 resource `taskId/assetKind`;跨项目复用后仍可通过稳定媒体引用找回来源。没有可信来源的新拆分显式归到自身任务,不走历史资源链回溯。每个手动切片同时写入 `groupTaskExpectedAssetCount`,全部切片落库后写独立 cohort 完成事实;后台 read model 只让同一根任务的一个已完成拆分批次并入原图集父项,用户后来删除单片不会让批次脱组,部分失败批次和后续重复拆分批次按各自真实任务分页,避免残缺批次抢占根任务、单组无限增长或素材丢失。历史行在项目资源仍存在时兼容回溯,删除项目资源前只固化直接受影响行的真实来源字段,有界展示 ID 不反写数据库。`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 下,画布图片、改图、图标素材、UI 素材提取、角色动作、视频、音效和背景音乐生成都先返回 `queueState`,前端轮询 `/api/runtime/external-generation/jobs/{jobId}` 到完成后重新读取项目快照;`inline` 或无项目上下文时才使用响应中的 resource / asset 快照做本地落画布兜底,不再把同一生成结果二次调用素材创建接口。生成请求失败、inline 完成或 queue 任务终态完成 / 失败后,右上角泥点 chip 必须通过 `/profile/dashboard` 回读余额,不做本地乐观扣减。生成视频会单独抽取首帧封面写入 `thumbnailSrc`,素材栏和拖回画布时沿用该封面作为 poster。
- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `assetLabel`,最大 80 字符并在提交时 trim;历史状态或内部调用携带非空名称时,同一个名称必须贯穿 `assetLabel`、`canvasCompletion.title`、项目资源、账号素材和本地兜底图层,刷新后不得退回模板名。图标图集与角色动作请求同样兼容该字段,中间原图使用主名称加固定后缀,拆分素材继续按素材描述命名。
-- 画布 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 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 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:` 内联媒体,且在后端项目快照返回前不得触发自动保存。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。
@@ -79,7 +79,7 @@
- `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,不得只返回一次性错误。
+- `POST /api/editor/agent-conversations/{conversationId}/messages`:发送画布 Agent 消息并返回普通 JSON `EditorAgentMessageResponse`。请求体包含 `clientMessageId`、`text` 和可选 `attachments`;文本与附件不可同时为空,同一会话重复 `clientMessageId` 必须幂等返回或拒绝重复追加。响应包含权威会话摘要、`deltaMessages` 和可选 `errorMessage`。LLM / 规划失败写入 `role=system`、正文以 `ERROR ` 开头的 OSS 消息并放入 `deltaMessages`,不再重复设置 `errorMessage`;前端隐藏前缀后显示红色错误气泡。工具失败继续保存工具状态和错误信息。
- `GET /api/editor/assets/library`:读取当前账号的素材文件夹和素材。首次读取时自动创建“项目素材”默认文件夹。
- `POST /api/editor/assets/folders`:新建素材文件夹。
- `PATCH /api/editor/assets/folders/{folderId}`:重命名、折叠 / 展开素材文件夹。
@@ -103,8 +103,7 @@
- 示例素材可继续复用 `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 错误文案。
+- 画布 Agent 前端分层固定为:`editorAgentClient.ts` 负责普通 JSON BFF 请求、鉴权和错误映射;`useEditorAgentConversation.ts` 负责会话列表、当前会话、消息请求等待态、工具任务状态和客户端取消等待;`EditorAgentConversationPanelView.tsx` 只负责右侧面板展示与交互。画布 Agent 不新增私有 SSE parser。
- 不复用或改写 `CreativeImageInputPanel` 的单图资产编辑语义;`/editor/canvas` 是独立图片画布工程的画布入口。
## 验收用例
@@ -124,11 +123,11 @@
- 快速编辑面板底部只显示模型选择和 `修改` 按钮;打开时视口聚焦必须预留底部面板空间,面板位于素材下方,不得遮挡原素材,且素材在当前屏幕内完整可见。快速编辑请求只把原图或红框序号标注图作为 `sourceImageSrc` 直接提交,信息面板输入快照只展示用户填写的快速编辑提示词。
- 点击生成、生成规范、生成角色形象或生成图标素材后创建的占位图可继续保留;点击画布空白区域让当前图片或占位图失焦时,关闭当前生成面板并移除图片选中样式,但不删除占位图本身。
- 生成资源显示元数据按钮,元数据窗口展示来源、生成输入快照、model、task、Resolution 和 OSS 引用;生成输入快照只包含用户面板输入和参考图行引用,不包含后端拼接 Prompt,不再展示独立 Size 字段,也不渲染参考图 Data URL 缩略图。
-- 点击底部 Dock 的“画布 Agent”后,右侧独立 Agent 面板打开;素材 / 图层侧栏和任务侧栏被收起。再次点击或点击面板关闭按钮后收起 Agent。打开素材 / 图层侧栏或任务侧栏时,Agent 面板同步关闭。
+- 点击底部 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。
+- 发送消息后,面板先展示本地用户消息和请求等待态,再应用普通 JSON 响应中的 `deltaMessages`;客户端取消等待只终止本次 transport 等待,不把已经确认入队的外部生成任务改成停止态。
+- Agent 工具任务完成并懒回填后,消息内缩略图只作纯预览,不显示名称也不点击聚焦图层;前端同时重新读取工程快照和素材库。对话入口触发生成时不创建“即将生成”画布占位,生成完成后由后端 `canvasCompletion` 落新图层。规划或工具失败时消息内必须保留可回读的失败状态和错误气泡,不能只弹一次性 toast 或返回瞬时 `errorMessage`。
- 画布 Agent 会话刷新后能从后端恢复会话标题、消息、附件和生成记录;前端不得根据本地临时状态伪造会话持久化结果。
- 图片选中后的浮动工具栏按钮顺序固定为:快速编辑、分割线、裁扩按钮、去除背景按钮、UI设计图专属提取素材、角色图专属生成动画、分割线、重绘、下载按钮。裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`,由 api-server 通过共享 BgFilter `background_mode=complex` 链路去背景并持久化结果;有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
index 18b59ea78..7428ce850 100644
--- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
+++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md
@@ -31,6 +31,7 @@
- `acknowledge_external_generation_job_summaries_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入摘要投影的 `notification_acknowledged_at` 并追加审计事件。
- `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。
- `get_external_generation_job_summary_and_return`:按 `job_id` 从轻量摘要投影读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。
+- `get_external_generation_job_result_and_return`:仅供后端内部回填异步编辑器 Agent 工具调用;按 `job_id + owner_user_id` 返回 `status`、`last_error_message` 和已持久化的 `result_payload_json`,不返回请求 payload、lease 或其它 worker 字段。该 procedure 不替代摘要状态读取接口,也不经 BFF 暴露给前端。
不带 `summary / summaries` 的旧 `get / list / acknowledge_external_generation_job*` procedure 只保留给受控内部兼容,不是 BFF 正式读取入口。
diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
index 6c1dc704f..a5bc0abfc 100644
--- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
+++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md
@@ -78,12 +78,15 @@ npm run check:server-rs-ddd
### 图片画布 Agent 对话
-- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取和软删;`/api/editor/agent-conversations/{conversationId}/messages/stream` 负责发送消息并返回 SSE。
+- `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。消息请求必须携带最长 128 字符的 `clientMessageId`;前端对该 POST 显式启用 1 次瞬时 transport 重试,并复用同一个序列化 body、`clientMessageId` 和 `x-request-id`。同一会话在锁内按该键幂等,重复键同内容返回已有回合或从已保存用户消息继续,异内容返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。
- `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`,也不作为画布布局真相。
+- 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM / 规划失败必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。工具失败同样必须形成可回读记录,不能只返回瞬时错误。
- 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。
- 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。
+- `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。
+- 会话详情的终态懒回填必须在单次 GET 和同一 conversation lock 内完成有界重试:任务结果读取、completed payload 解析或工具 formatter 首次失败后最多重试 3 次,每次等待 100ms 并重新读取主任务。任务读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后仍保留 OSS 工具消息的 `not_completed + externalJobId`,由下次会话读取继续 reconcile;JSON 损坏、结果结构不兼容或 formatter 失败等确定性致命错误在重试耗尽后原子写为 `failed`,保存“重试 3 次后仍失败”的最后错误,避免永久循环。
+- 画布 Agent 是“正式任务 payload 不进入通用用户 read model”规则的窄例外消费者:`GET /api/editor/agent-conversations/{conversationId}` 只按会话中已有的 `externalJobId` 定向读取主任务,完成后由对应工具 formatter 从 `result_payload_json` 提取并归一有界的图片 / 视频 / 音频引用,写入 OSS 工具消息后返回。前端仍不得通过通用任务列表 / 状态接口读取或解析 `request_payload_json` / `result_payload_json`;OSS 轻量媒体引用只是会话展示与后续 Agent 上下文,不替代 `editor_project_resource`、`editor_asset`、`editor_canvas.layers_json` 或 `external_generation_job` 的业务真相。
### 创作 / 游玩统一流程主干
@@ -282,8 +285,8 @@ npm run check:server-rs-ddd
- Rust 结构体:`ExternalGenerationJob`
- 源码:`server-rs/crates/spacetime-module/src/external_generation.rs`
- 用途:外部生成 worker 的内部持久任务队列;`GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,`api-server` HTTP 角色只入队,`external-generation-worker` 角色通过 claim lease 领取、续租、执行,并用 `lease_token` 栅栏回写阶段、完成 / 失败。队列行继续保存 worker 执行、计费与滚动发布兼容所需字段,末尾可选 `phase` 只取 `generating / processing`;claim 写 `generating`,真实进入抠图处理时由受 `job_id + worker_id + lease_token` 保护的 procedure 写 `processing`。phase procedure 以结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试一次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。用户可见任务列表、价格、状态、阶段、未确认终态数量和通知确认时间的正式读取事实源已经迁到 `external_generation_job_summary`;BFF 不得再为列表 / 详情 / acknowledge 读取该大表。拼图 `compile_puzzle_draft` 的前置 `compile_puzzle_agent_draft`、`generate_puzzle_images` 与 `generate_puzzle_ui_background` 的业务写回也在对应 SpacetimeDB transaction 内校验 `job_id + worker_id + lease_token`、job kind、owner 和 source entity,避免过期 worker 写 session / work profile;图片画布编辑器的 `editor_image_generation`、`editor_image_edit`、`editor_background_removal`、`editor_icon_spritesheet_generation`、`editor_ui_design_asset_extraction`、`editor_character_animation_generation`、`editor_video_generation`、`editor_sound_effect_generation` 和 `editor_background_music_generation` 复用同一队列表,worker 成功后经 `api-server` facade 写入 `editor_project_resource` / `editor_asset` / `editor_canvas.layers_json`,前端只通过 BFF job 状态轮询和项目快照读取恢复完成态。`GENARRATIVE_EXTERNAL_GENERATION_MODE=inline` 时不创建该队列行,三个 external generation guard 字段必须同时为空才允许 api-server 受控同步写回,半空 guard 仍会拒绝。worker 成功写回业务事实后才能 complete job;业务失败态写回成功后才能 fail job,失败态未写回时保留租约等待后续重领。
-- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用;其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行和受控维护读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得再返回或解析这两个 payload。
-- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 互斥。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`,且通用 `warning` 优先并原样保留完整 `reason`,只有 `sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀。队列结果不保存图片、切片列表或媒体 URL。
+- 载荷约束:本次先对 `source_module = editor-canvas` 的 `request_payload_json` / `result_payload_json` 实施有限大小合法 JSON、任意层级禁止 `data:` / `blob:` 的双层门禁,只保存 worker 执行必需的普通参数和已登记媒体引用。画布 Agent 来源的任务可在 `result_payload_json.editor-agent-tool-call-result` 中保存有界的轻量结果和已登记媒体引用,供后端按已有 `externalJobId + owner_user_id` 定向懒回填;其它编辑器任务保持元数据结果,并可保存有界的 `warning.code/reason`。其它玩法在完成各自参考图资源化之前不由本次门禁静默改变既有请求契约。该主表只供 worker claim / 执行、受控维护以及画布 Agent 的定向结果回填读取;正式用户任务列表、单任务状态、队列概览与 acknowledge 不得返回或解析这两个 payload。画布 Agent 懒回填必须经对应工具 formatter 归一为有界轻量媒体引用后写入 OSS 会话,不能把原始 payload 直接透传前端。
+- 非阻断告警:角色形象、图标图集和 UI 素材提取已保存 provider 原图、但透明背景处理最终失败时,以原图唯一主图完成任务;透明图和切片不写入画布。这个 source-only 降级只包住透明背景处理的最终失败,phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。图标 / UI 透明图集成功但自动拆分降级时仍保留透明图集;通用 `warning` 与 `sliceWarning` 互斥。两类成功降级都以既有 `completed` 状态收口,不新增状态值:source-only 的 inline / external v1 响应使用结构化 `warning.code/reason`,仅拆分失败的 inline / external v1 响应继续使用既有 `sliceWarning.code/reason`,其 `reason` 保留原始诊断;queue worker 才把两者归一为有界的 `result_payload_json.warning`,且通用 `warning` 优先并原样保留完整 `reason`,只有 `sliceWarning.reason` 由 worker 添加“图集已生成,但自动拆分未完成:”前缀。除上述画布 Agent 定向回填的轻量结果外,队列结果不保存图片、切片列表或媒体 URL。
### `external_generation_job_summary`
diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
index 632598beb..bbb9f3286 100644
--- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md
+++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md
@@ -1,6 +1,6 @@
# 画布Agent对话面板
-日期:`2026-07-03`
+日期:`2026-07-16`
## 定位与边界
@@ -8,9 +8,9 @@
- 它是画布域工具,**不承接玩法创作**、不产出玩法作品或模板,与 `CONTEXT.md` 中「表单/图片输入创作工作台」的 Avoid 边界不冲突。
- 独立于拼图专用的 `/api/runtime/creative-agent/sessions`(该会话为 api-server 内存态、拼图领域专用,不复用)。
-## 能力范围(第一期)
+## 能力范围
-对话 Agent 可通过 function-calling 触发以下五类工具,全部复用既有计费收口接口:
+对话 Agent 可通过 function-calling 触发以下八类工具,全部复用既有计费收口接口:
| 工具 | 后端接口 |
| --- | --- |
@@ -19,19 +19,22 @@
| 生成角色形象 | 既有角色形象生成入口对应接口 |
| 生成图标素材 | `POST /api/editor/icon-spritesheets/generations` |
| 生成 UI 设计图 | 既有 UI 设计图生成入口对应接口 |
+| 生成视频 | `POST /api/editor/videos/generations` |
+| 生成游戏音效 | `POST /api/editor/audios/sound-effects/generations` |
+| 生成背景音乐 | `POST /api/editor/audios/background-music/generations` |
- 意图解析与工具编排在后端 api-server,前端只渲染状态,不承接业务规则。
- 下面的工具选择口径属于 Agent 规划 prompt / function-calling 约束,不是侧边栏 UI 说明文案;侧边栏面板不展示这些规则解释。
- 用户要求“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”时,规划默认选择 `generate_image`,并在 prompt 中明确要求生成规范展板,包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等可落地的视觉规范元素。
- 用户要求“角色规范图”且语义是角色的规范展板、风格展板或设定板时,仍走 `generate_image`,不要误分流到 `generate_character`;只有实际生成角色立绘、角色主形象或角色视觉资产时才走 `generate_character`。用户要求多个图标素材、图集或 spritesheet 时才走 `generate_icon_spritesheet`。
- 所有生成必须走 `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 回合记录,不作为后台任务队列真相。
+- 已落地:会话元数据、OSS 消息文档、会话 CRUD、带 `clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用。
+- 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。LLM 未配置、请求失败或规划结果解析失败时,后端把 `role=system`、正文以 `ERROR ` 开头的消息写入 OSS,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏 wire 前缀并以红色错误气泡展示。工具执行失败继续保存 `status=failed`、模型和错误信息,不能只返回瞬时错误。
+- 未落地:附件弹窗末尾上传格。`external_generation_job` 继续作为后台任务队列真相,对话消息只保存确认、回填状态和轻量媒体结果引用。
## 会话与持久化
@@ -39,24 +42,27 @@
- SpacetimeDB 新表 `editor_agent_conversation` 只存会话元数据:会话 ID、projectId、ownerUserId、标题、软删标记、聊天记录 OSS 对象引用、创建/更新时间。
- 完整消息内容存 OSS:`editor-agent/{conversationId}.json`,**会话粒度整体读写**(追加消息=重写对象),不按消息拆对象。
- 不把对话塞进画布工程快照 payload,不在 api-server 内存中保存会话真相。
-- 会话标题:新会话默认「新对话」,首条用户消息发出后自动截取前 N 字作为标题;第一期不做手动重命名。
+- 会话标题:新会话默认「新对话」,首条含文本的用户消息发出后自动截取前 N 字作为标题;列表摘要、详情和消息回包均携带同一必填标题,前端只展示该标题,不以会话 ID 或本地推导兜底。标题写入失败会使该消息请求失败,不能静默继续。
- 会话删除:列表项 hover 出删除按钮 + 确认;软删(表打 deleted 标记,OSS 对象保留)。
+- 每次用户主动发送生成一个最长 128 字符的 `clientMessageId`;`editorAgentClient` 对网络错误和通用瞬时状态码显式启用 1 次 POST transport 重试,重试复用同一个已序列化 body、`clientMessageId` 和 `x-request-id`。该字段独立于数字 `message.id` 并随用户消息写入 OSS。旧消息缺失时按 `None` 兼容;早期 SSE 文档若把客户端键存成用户消息字符串 `id`,读取时将其迁入 `clientMessageId`,同时重建数字定位符。后端在会话锁内检查重复键:内容一致时返回已持久化的同一回合结果,尚无结果时复用原用户消息继续规划;文本或附件身份不同则返回 `409`,不得再次追加用户消息或调用 LLM。
## 生成结果落画板(对现有占位规则的例外)
-- 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由对话消息流承载。
-- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库,并在对话消息内显示纯缩略图;前端收到 `generation_result` 后立即刷新工程快照与素材库,缩略图本身不显示名称也不承担图层跳转。
+- 对话入口触发的生成**不创建"即将生成"画布占位**(区别于其余生成面板);生成中状态由工具消息和外部任务状态承载。
+- 生成完成后:结果图按统一 placement 避让模型(视口中心就近、避开现有图层、32px 间距)落画板为新图层,同时登记到默认项目素材库;前端轮询到任务终态并重新读取会话后,以回填的轻量媒体引用显示纯缩略图并刷新工程快照与素材库,缩略图本身不显示名称也不承担图层跳转。
- 消息内生成结果缩略图必须携带并优先使用 `objectKey` / `assetObjectId`,前端通过 `ResolvedAssetImage` / `/api/assets/read-url` 换签后渲染,不能把裸 `/generated-*` 私有路径直接交给 `
`。
-- 当前第一阶段通过既有编辑器生成 BFF 的 `canvasCompletion` 写回工程快照;刷新后异步任务恢复和轮询回填属于后续能力,不在本阶段声明为已完成。
+- 既有编辑器 worker 通过 `canvasCompletion` 写回工程快照;刷新后由 external generation task 状态和会话懒回填恢复结果。
- 该例外已同步登记在《生成类面板Lovart统一改造方案-2026-06-17》「画布占位落点」节。
## 右侧布局
- 对话框与既有任务侧栏(`ImageCanvasTaskSidebarView`)**互斥展开**:展开一个自动收起另一个;各自收起后保留入口按钮。
-- 对话框与左侧素材 / 图层侧栏也互斥:打开画布 Agent 时收起左侧栏;再次打开素材、图层或任务侧栏时收起 Agent 面板。
+- 对话框与左侧素材 / 图层侧栏**不互斥**,允许同时展开,便于在对话中选取和核对画布素材;左侧栏切换不改变 Agent 面板开关状态。
- 桌面端对话框固定宽约 360–400px;移动端抽屉式全宽覆盖;收起态为胶囊/圆形入口按钮。
- 会话管理入口在对话框头部:当前会话标题 + 历史会话下拉(按更新时间倒序)+ 新建对话按钮,全部包在对话框内。
-- 收起对话框只是隐藏面板,不卸载当前会话 hook;流式回复、`生成中` 阶段和停止按钮状态必须在收起 / 重新打开之间保持一致。
+- 快速切换会话或会话轮询刷新产生并发详情请求时,前端只允许最后发起的请求更新当前会话、消息、错误和加载态;旧响应不得覆盖用户最新选择。
+- 普通 JSON 消息请求的回包必须绑定发送时的会话:用户在等待期间切换到其他会话后,只更新原会话的列表摘要,不得把原会话的 `deltaMessages` 、错误或画布刷新副作用应用到当前面板。
+- 收起对话框只是隐藏面板,不卸载当前会话 hook;普通 JSON 消息请求的等待态和外部生成任务状态必须在收起 / 重新打开之间保持一致。
## 附件
@@ -68,11 +74,29 @@
- 应用后附件以胶囊 chip 挂在输入框上方;发出的消息内附件渲染为纯文本胶囊 chip(名称 + 小图标),**默认无缩略图,鼠标悬浮才浮出缩略图预览**。
- 附件领域形状:统一为画布资源 / 素材库对象引用(`resourceId` / `assetId` + 可选 `objectKey`),不存在只属于对话的第三种图;单条消息上限 9 张(前后端共同校验)。前端可携带展示用 `imageSrc` / `thumbnailSrc`,后端必须按当前工程和当前账号重新归一、校验归属与 `objectKey`。
+## 工具调用确认展示契约
+
+- Agent 规划出生成或编辑工具后,先把 `status=not_completed` 且没有 `externalJobId` 的工具消息持久化为待确认记录;确认卡必须在真正调用生成 provider 前展示本次提示词、规格参数、目标图和参考图缩略图,用户确认后才执行,取消后保留同一条 `status=cancelled` 记录。内部 system 文本和图片哈希 ID 不直接展示给用户。
+- 工具消息只保存 `status`(`not_completed` / `completed` / `failed` / `cancelled`)和可选 `externalJobId`。`external_generation_job` 仍是队列、执行、lease 与计费结算真相;OSS status 仅表示该条对话消息是否已经回填完成结果或失败,不复制 queued / running。
+- 确认接口必须先把工具参数转换为既有编辑器 worker payload,再使用 `editor-agent:{conversationId}:{messageId}:{toolName}` 稳定 dedupe key 入队;同一确认的请求重试只能得到同一个 external job。入队成功后把返回的 job id 写回同一条 OSS 工具消息,不新增 Agent 工具执行关联表。
+- 前端根据 `externalJobId` 查询通用 external-generation job 状态;worker 继续通过 `canvasCompletion` 把生成结果写回工程与素材库。浏览器断线、刷新或 api-server 重启不得导致确认接口重新扣费或重新提交 provider。
+- `GET /conversation` 会在同一个 conversation lock 内扫描 `status=not_completed` 且已有 `externalJobId` 的工具消息:只对这些消息按 job id 定向读取主任务;任务完成后复用对应工具的 `format_execute_message` 替换 system text、回填轻量图片 / 视频 / 音频引用并写为 `completed`,任务失败则回填 `error` 并写为 `failed`。任务结果读取或 completed payload 解析 / formatter 回填失败时,必须在同一次 GET 内完成首次尝试及最多 3 次重试,三次重试各间隔 100ms 并重新读取任务结果;仍失败才把该工具消息写为 `failed` 并保存最后错误。该重试不依赖前端再次刷新。排队和执行中都保持 `not_completed`,整轮扫描结果一次性写回 OSS。
+- `EditorAgentToolCall.args` 保留为工具返回的原始 JSON,是确认接口重新反序列化并执行工具的唯一参数真相。图片参数继续只保存由真实 data key 计算出的 opaque SHA-256 `imageId`;不得为了前端预览把 `args` 中的图片 ID 改写成 `objectKey`、URL 或展示对象,也不得由前端重组或回传一份新的执行参数。
+- `EditorAgentToolCall.displayArgs` 是必填、只读的用户确认展示投影,与 `args` 分离:
+ - `stringArgs` 保存提示词、比例、清晰度、模型、时长等可展示参数的稳定名称、用户可见标题和值;
+ - `imageArgs` 按“目标图片 / 参考图片”等参数分组,每个 `refs` 项包含与原始参数对应的 `imageId`,以及后端从已校验会话上下文解析出的 `objectKey`、`imageSrc`、可选 `thumbnailSrc` / `label` / `width` / `height`。
+ - `extras.priceMudPoints` 保存创建待确认消息时按后端运行时模型定价快照计算的预计泥点消耗;前端统一展示为“预计消耗 N泥点”,不自行计算价格。
+- `displayArgs` 只能由 api-server 按已注册 tool 白名单,基于已经通过 ToolArgs 校验的 `args` 和当前 OSS 会话文档中的附件 / 历史生成结果构建;不能信任 LLM 自报的展示地址、标题或素材元数据。展示投影不参与确认执行,确认接口仍只读取同一条持久化 tool call 的 `args`,避免“看到的素材”和“实际执行的素材”分叉。
+- `extras.priceMudPoints` 同样只属于展示投影,不作为扣费输入;确认后仍由既有生成 BFF 按后端运行时定价执行预扣费,因此该字段表达用户确认时看到的价格快照,而不是前端可提交或覆盖的计费真相。
+- `EditorAgentToolCall.summary` 只是 `args` 的重复字符串且没有稳定语义,当前契约删除该字段,不再作为展示或执行输入。
+- 前端待确认卡只消费必填 `displayArgs`,不解析各 tool 私有的 snake_case / camelCase schema,也不把 `sha256:*` ID 当标题或图片地址。图片统一通过 `ResolvedAssetImage` 使用 `objectKey` 换签后显示,签名 URL 不进入消息文档。模块尚未上线,不保留缺少 `displayArgs` 时读取 raw `args` 的旧消息降级路径。
+
## LLM 与计费
-- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册五类工具。
-- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入明确错误消息,不使用本地关键词或“收到:...”回显兜底。
-- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `summary` / `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
+- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册八类工具。
+- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。
+- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
+- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`。
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。
- 画布 Agent 规划请求使用 Chat Completions、1024 `max_tokens` 和 60 秒 Agent 专用请求超时;生成图片/编辑图片仍走对应生成工具和模型计费。
@@ -83,13 +107,13 @@
做(第一期):
-1. 助手文本 SSE 流式输出;
-2. 阶段提示行(思考中 → 思考完成 → 生成中 → 完成/失败);
+1. 助手文本随普通 JSON 消息响应一次性返回;
+2. 消息请求等待态,以及工具任务的待确认、生成中、完成 / 失败状态;
3. 工具/模型标注行(生成时显示模型名 + 图标);
4. 消息内生成结果缩略图(纯预览,不显示名称,不点击聚焦图层);
5. 生成中的进行中动画;
6. 错误气泡(失败/余额不足,带原因);
-7. 发送中断:进行中时发送按钮变「停止」,可中断当前回合(已提交的生成任务不追回,照常落画板)。
+7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。
不做(明确排除,防止后人补齐):
@@ -113,22 +137,22 @@
- `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);
+ - `POST /api/editor/agent-conversations/{conversationId}/messages`(JSON);
- 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`)。
+- `shared-contracts` + `packages/shared`:`editorAgent` 会话、消息、工具确认展示与轻量媒体结果 DTO;消息响应返回 `conversation`、`deltaMessages` 和可选 `errorMessage`。
## 实施顺序
1. 契约与领域规则(shared-contracts / packages/shared + module-editor-agent);
2. 存储层(spacetime-module 表 + procedure + migration + spacetime-client + schema check);
-3. api-server 会话 CRUD + 消息 OSS 读写 + SSE 回显桩(不接 LLM,先保证会话链路端到端真实落库);
+3. api-server 会话 CRUD + 消息 OSS 读写 + JSON 回显桩(不接 LLM,先保证会话链路端到端真实落库);
4. 前端最小纵切(对话框、会话管理、消息流、附件弹窗、侧边栏/小地图默认值)——可与 3 并行:3 只碰 `server-rs/`,4 只碰 `src/` 且先以契约 mock 客户端联调,汇合点在 4 末接真实 API;
-5. LLM 编排 + 五类工具接入 + 生成落画板 + 停止/错误态(替换回显桩这一个点);
+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 消息内生成结果缩略图只用于预览,不显示名称,也不点击跳转图层;收到 `generation_result` 时统一刷新工程快照和素材库。
+- 打开画布 Agent 后任务侧栏应关闭,再次打开任务侧栏时 Agent 面板应关闭;素材 / 图层面板与 Agent 可同时展开,互不改写开关状态。
+- 发送消息时先本地追加用户消息,再应用 JSON 响应中的 `deltaMessages`;请求等待期间发送按钮保持禁用,前端不主动中断当前回合。
+- Agent 消息内生成结果缩略图只用于预览,不显示名称,也不点击跳转图层;轮询到任务终态并完成会话懒回填后统一刷新工程快照和素材库。
- 对话内容可被用户选中复制;用户从输入框或对话内容点击回画布图层 / 生成器时,焦点应回到画布对象,Backspace / Delete 等画布快捷键继续生效。
diff --git a/packages/shared/src/contracts/editorAgent.ts b/packages/shared/src/contracts/editorAgent.ts
index a7daaeb33..b85143eae 100644
--- a/packages/shared/src/contracts/editorAgent.ts
+++ b/packages/shared/src/contracts/editorAgent.ts
@@ -1,38 +1,18 @@
// 画布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 const EDITOR_AGENT_ERROR_MESSAGE_PREFIX = 'ERROR ';
-export type EditorAgentStage =
- | 'idle'
- | 'thinking'
- | 'responding'
- | 'generating'
- | 'completed'
- | 'failed';
+export type EditorAgentMessageRole = 'user' | 'assistant' | 'system';
-export type EditorAgentMessageRole = 'user' | 'assistant';
-
-export type EditorAgentMessageKind = 'chat' | 'stage' | 'error';
-
-export type EditorAgentMessageStatus =
- | 'streaming'
- | 'generating'
+export type EditorAgentToolCallStatus =
+ | 'not_completed'
| 'completed'
| 'failed'
- | 'stopped';
+ | 'cancelled';
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;
@@ -45,36 +25,87 @@ export interface EditorAgentAttachmentRef {
}
export interface EditorAgentGeneratedImage {
- resourceId: string | null;
+ resourceId?: string | null;
objectKey?: string | null;
assetObjectId?: string | null;
imageSrc: string;
- thumbnailSrc: string | null;
- width: number | null;
- height: number | null;
+ thumbnailSrc?: string | null;
+ width?: number | null;
+ height?: number | null;
}
-export type EditorAgentGenerationStatus = 'generating' | 'completed' | 'failed';
+export interface EditorAgentGeneratedVideo {
+ resourceId?: string | null;
+ objectKey?: string | null;
+ assetObjectId?: string | null;
+ videoSrc: string;
+ thumbnailSrc?: string | null;
+ width?: number | null;
+ height?: number | null;
+}
-export interface EditorAgentGenerationRecord {
- toolCallId: string;
- toolName: EditorAgentToolName;
- summary?: string | null;
- taskId: string | null;
- status: EditorAgentGenerationStatus;
- model: string | null;
+export interface EditorAgentGeneratedAudio {
+ resourceId?: string | null;
+ objectKey?: string | null;
+ assetObjectId?: string | null;
+ audioSrc: string;
+}
+
+export interface EditorAgentToolCallStringArg {
+ name: string;
+ label: string;
+ value: string;
+}
+
+export interface EditorAgentToolCallImageRef {
+ imageId: string;
+ imageSrc: string;
+ objectKey?: string | null;
+ thumbnailSrc?: string | null;
+ label?: string | null;
+ width?: number | null;
+ height?: number | null;
+}
+
+export interface EditorAgentToolCallImageArg {
+ name: string;
+ label: string;
+ refs: EditorAgentToolCallImageRef[];
+}
+
+export interface EditorAgentToolCallDisplayExtras {
+ priceMudPoints: number;
+}
+
+export interface EditorAgentToolCallDisplayArgs {
+ stringArgs: EditorAgentToolCallStringArg[];
+ imageArgs: EditorAgentToolCallImageArg[];
+ extras: EditorAgentToolCallDisplayExtras;
+}
+
+export interface EditorAgentToolCall {
+ toolName: string;
+ status: EditorAgentToolCallStatus;
+ args: unknown;
+ displayArgs: EditorAgentToolCallDisplayArgs;
+ externalJobId?: string | null;
images: EditorAgentGeneratedImage[];
+ // Older persisted conversation documents do not contain these media fields.
+ videos?: EditorAgentGeneratedVideo[];
+ audios?: EditorAgentGeneratedAudio[];
error?: string | null;
}
export interface EditorAgentMessage {
- id: string;
+ // Frontend must not use this to organize messages.
+ // It is an opaque backend locator for pending tool-call operations.
+ id: number;
+ // Present only on user messages created from a client send request.
+ clientMessageId?: string | null;
role: EditorAgentMessageRole;
- kind: EditorAgentMessageKind;
text: string;
attachments: EditorAgentAttachmentRef[];
- generations: EditorAgentGenerationRecord[];
- status: EditorAgentMessageStatus;
+ toolCall: EditorAgentToolCall | null;
createdAt: string;
}
@@ -82,12 +113,15 @@ export interface EditorAgentConversationSummary {
conversationId: string;
projectId: string;
title: string;
- createdAt: string;
updatedAt: string;
}
-export interface EditorAgentConversationDetail
- extends EditorAgentConversationSummary {
+export interface EditorAgentConversationDetail {
+ conversationId: string;
+ projectId: string;
+ title: string;
+ createdAt: string;
+ updatedAt: string;
messages: EditorAgentMessage[];
}
@@ -109,63 +143,14 @@ export interface EditorAgentConversationResponse {
conversation: EditorAgentConversationDetail;
}
-export interface StreamEditorAgentMessageRequest {
+export interface EditorAgentMessageRequest {
clientMessageId: string;
text: string;
attachments?: EditorAgentAttachmentRef[];
}
-export interface EditorAgentStageEvent {
- conversationId: string;
- stage: EditorAgentStage;
+export interface EditorAgentMessageResponse {
+ conversation: EditorAgentConversationSummary;
+ deltaMessages: EditorAgentMessage[];
+ errorMessage: string | null;
}
-
-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/scripts/dev.mjs b/scripts/dev.mjs
index ebb73c5f0..5bdf33f72 100644
--- a/scripts/dev.mjs
+++ b/scripts/dev.mjs
@@ -921,7 +921,8 @@ function readLinuxApiServerProcessSnapshot(pid) {
if (
error?.code === 'ENOENT' ||
error?.code === 'EACCES' ||
- error?.code === 'EPERM'
+ error?.code === 'EPERM' ||
+ error?.code === 'ESRCH'
) {
return null;
}
diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock
index d1d376428..d3058ee33 100644
--- a/server-rs/Cargo.lock
+++ b/server-rs/Cargo.lock
@@ -238,6 +238,7 @@ dependencies = [
"platform-agent",
"platform-audio",
"platform-auth",
+ "platform-editor-agent",
"platform-hyper3d",
"platform-image",
"platform-llm",
@@ -4441,6 +4442,19 @@ dependencies = [
"urlencoding",
]
+[[package]]
+name = "platform-editor-agent"
+version = "0.1.0"
+dependencies = [
+ "hmac",
+ "platform-agent",
+ "platform-llm",
+ "serde",
+ "serde_json",
+ "sha2",
+ "shared-contracts",
+]
+
[[package]]
name = "platform-hyper3d"
version = "0.1.0"
diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml
index 60814bfc5..61d585844 100644
--- a/server-rs/Cargo.toml
+++ b/server-rs/Cargo.toml
@@ -42,6 +42,7 @@ members = [
"crates/platform-wechat",
"crates/platform-speech",
"crates/platform-agent",
+ "crates/platform-editor-agent",
"crates/pingora-gateway",
"crates/server-manager-panel",
"crates/shared-contracts",
@@ -84,6 +85,7 @@ module-square-hole = { path = "crates/module-square-hole", default-features = fa
module-story = { path = "crates/module-story", default-features = false }
module-visual-novel = { path = "crates/module-visual-novel", default-features = false }
platform-agent = { path = "crates/platform-agent", default-features = false }
+platform-editor-agent = { path = "crates/platform-editor-agent", default-features = false }
platform-auth = { path = "crates/platform-auth", default-features = false }
platform-audio = { path = "crates/platform-audio", default-features = false }
platform-hyper3d = { path = "crates/platform-hyper3d", default-features = false }
diff --git a/server-rs/crates/api-server/Cargo.toml b/server-rs/crates/api-server/Cargo.toml
index 8949fdca5..29a238b70 100644
--- a/server-rs/crates/api-server/Cargo.toml
+++ b/server-rs/crates/api-server/Cargo.toml
@@ -38,6 +38,7 @@ module-square-hole = { workspace = true }
module-story = { workspace = true }
module-visual-novel = { workspace = true }
platform-agent = { workspace = true }
+platform-editor-agent = { workspace = true }
platform-audio = { workspace = true }
platform-auth = { workspace = true }
platform-hyper3d = { workspace = true }
diff --git a/server-rs/crates/api-server/src/editor_agent.rs b/server-rs/crates/api-server/src/editor_agent.rs
deleted file mode 100644
index 5d79a37a8..000000000
--- a/server-rs/crates/api-server/src/editor_agent.rs
+++ /dev/null
@@ -1,2813 +0,0 @@
-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::{LlmError, LlmErrorKind, 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_LLM_PLANNING_MODEL: &str = platform_agent::CREATIVE_AGENT_GPT5_MODEL;
-const EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS: u32 = 1024;
-const EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS: u64 = 60_000;
-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();
- require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
- 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();
- require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
- 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();
- require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
- 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();
- require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
- 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();
- require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
- 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 latest_generation_reference = latest_editor_agent_generated_image_reference(&document);
-
- 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_latest_generation_reference = latest_generation_reference.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 mut assistant_status = EditorAgentMessageStatus::Completed;
- let mut generation_records = Vec::new();
- let mut planning_failed = false;
-
- let turn_plan = match plan_editor_agent_turn(
- &stream_state,
- &stream_conversation,
- &document,
- &stream_user_message,
- stream_latest_generation_reference.as_ref(),
- )
- .await {
- Ok(turn_plan) => turn_plan,
- Err(error) => {
- let app_error = error.into_app_error();
- let error_message = app_error.body_text();
- assistant_status = EditorAgentMessageStatus::Failed;
- planning_failed = true;
- EditorAgentTurnPlan {
- reply_text: error_message,
- tool_call: None,
- }
- }
- };
- let assistant_text = turn_plan.reply_text;
- let assistant_kind = if assistant_status == EditorAgentMessageStatus::Failed {
- EditorAgentMessageKind::Error
- } else {
- EditorAgentMessageKind::Chat
- };
-
- 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: assistant_kind,
- text_delta: assistant_text.clone(),
- },
- ));
- if planning_failed {
- yield Ok::(editor_agent_sse_json_event_or_error(
- "stage",
- EditorAgentStageEvent {
- conversation_id: stream_conversation.conversation_id.clone(),
- stage: EditorAgentStage::Failed,
- },
- ));
- }
-
- 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,
- stream_latest_generation_reference.as_ref(),
- 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,
- summary: tool_summary.clone(),
- 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: tool_summary,
- 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(Clone, Debug)]
-struct EditorAgentGeneratedImageReference {
- source_message_id: String,
- tool_call_id: String,
- tool_name: EditorAgentToolName,
- summary: Option,
- model: Option,
- resource_id: Option,
- object_key: Option,
- asset_object_id: Option,
- image_src: String,
- thumbnail_src: Option,
- width: Option,
- height: Option,
-}
-
-#[derive(Debug)]
-struct EditorAgentToolExecutionError {
- tool_name: EditorAgentToolName,
- model: Option,
- error: AppError,
-}
-
-#[derive(Debug)]
-enum EditorAgentPlanningError {
- LlmUnavailable,
- LlmRequestFailed(LlmError),
- InvalidLlmResponse,
-}
-
-impl EditorAgentPlanningError {
- fn into_app_error(self) -> AppError {
- match self {
- Self::LlmUnavailable => AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
- .with_details(json!({
- "provider": "editor-agent-llm",
- "message": "画布 Agent 的 LLM 未配置,无法处理这句话。",
- })),
- Self::LlmRequestFailed(error) => {
- let status = match error.kind() {
- LlmErrorKind::Timeout => StatusCode::GATEWAY_TIMEOUT,
- LlmErrorKind::InvalidConfig | LlmErrorKind::InvalidRequest => {
- StatusCode::SERVICE_UNAVAILABLE
- }
- LlmErrorKind::Connectivity
- | LlmErrorKind::Upstream
- | LlmErrorKind::StreamUnavailable
- | LlmErrorKind::EmptyResponse
- | LlmErrorKind::Transport
- | LlmErrorKind::Deserialize => StatusCode::BAD_GATEWAY,
- };
- AppError::from_status(status).with_details(json!({
- "provider": "editor-agent-llm",
- "message": format!("画布 Agent 调用 LLM 失败:{error}"),
- }))
- }
- Self::InvalidLlmResponse => AppError::from_status(StatusCode::BAD_GATEWAY)
- .with_details(json!({
- "provider": "editor-agent-llm",
- "message": "画布 Agent 的 LLM 返回格式错误,未能解析回复。",
- })),
- }
- }
-}
-
-async fn plan_editor_agent_turn(
- state: &AppState,
- conversation: &EditorAgentConversationRecord,
- document: &EditorAgentConversationMessagesDocument,
- user_message: &EditorAgentMessage,
- latest_generation_reference: Option<&EditorAgentGeneratedImageReference>,
-) -> Result {
- let Some(llm_client) = state.creative_agent_gpt5_client() else {
- return Err(EditorAgentPlanningError::LlmUnavailable);
- };
-
- let request = build_editor_agent_llm_request(
- state,
- conversation,
- document,
- user_message,
- latest_generation_reference,
- )
- .await;
- let response = llm_client
- .request_text(request)
- .await
- .map_err(EditorAgentPlanningError::LlmRequestFailed)?;
- parse_editor_agent_turn_plan(response.content.as_str())
- .map(|plan| {
- apply_previous_generation_edit_default(plan, user_message, latest_generation_reference)
- })
- .ok_or(EditorAgentPlanningError::InvalidLlmResponse)
-}
-
-async fn build_editor_agent_llm_request(
- state: &AppState,
- conversation: &EditorAgentConversationRecord,
- document: &EditorAgentConversationMessagesDocument,
- user_message: &EditorAgentMessage,
- latest_generation_reference: Option<&EditorAgentGeneratedImageReference>,
-) -> LlmTextRequest {
- let mut user_parts = vec![LlmMessageContentPart::InputText {
- text: build_editor_agent_llm_user_prompt(
- conversation,
- document,
- user_message,
- latest_generation_reference,
- ),
- }];
- 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 });
- }
- }
- if user_message.attachments.is_empty()
- && editor_agent_text_mentions_previous_generation(user_message.text.as_str())
- {
- if let Some(reference) = latest_generation_reference {
- if let Some(image_url) = sign_editor_agent_generated_image_url(state, reference) {
- user_parts.push(LlmMessageContentPart::InputImage { image_url });
- }
- }
- }
-
- LlmTextRequest::new(vec![
- LlmMessage::system(editor_agent_llm_system_prompt()),
- LlmMessage::user_multimodal(user_parts),
- ])
- .with_model(EDITOR_AGENT_LLM_PLANNING_MODEL)
- .with_max_tokens(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS)
- .with_request_timeout_ms(EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS)
-}
-
-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。",
- "工具说明:",
- "generate_image:从文字生成一张全新图片,适合新场景、新物体、新插画、新背景、规范图/视觉规范图/风格规范图/素材规范展板;不要用于修改上一张图或附件图。prompt 必须写完整画面、主体、风格、构图和背景。",
- "edit_image:修改已有图片,适合换衣服、改颜色、替换背景、局部重绘、保持主体/构图/姿势不变的编辑请求;必须使用 latestAttachments 或 latestGeneratedImage 作为源图。",
- "generate_character:生成新的角色形象、人物立绘或角色设定图;如果用户是在改上一张角色图的服装、颜色、表情、姿势或背景,应改用 edit_image。",
- "generate_icon_spritesheet:生成一组图标素材或图标图集,适合用户明确要多个 icon / 图标 / spritesheet;必须有 latestAttachments 作为图标规范或风格参考,并填写 iconDescriptions。",
- "generate_ui_design:生成一张完整 UI 设计图或界面稿,适合 HUD、弹窗、面板、按钮组合和整页界面;不要用于提取图标、拆素材或修改上一张图。",
- "规范图要求:用户要求生成规范图/视觉规范图/风格规范图/素材规范展板时,当前使用 generate_image;prompt 必须明确这是规范展板,并写入统一视角、线条粗细、描边、填充风格、材质、阴影、圆角、状态层级、色卡/色号、尺寸标注和排版层级。",
- "角色规范图/角色美术视觉规范设定图属于规范展板时使用 generate_image,prompt 要包含头身比例、标准立绘、动作帧样例、服饰配饰分层和专属角色色卡;只有用户要生成单个全新角色形象/立绘/普通角色设定图时才使用 generate_character。",
- "图标规范图/图标视觉规范展板属于规范展板时使用 generate_image;只有用户明确要生成多个图标成品、图标素材图集或 spritesheet,并提供图标规范/风格参考附件时才使用 generate_icon_spritesheet。",
- "UI 规范图/组件规范展板如果是规范展板而非完整可用界面稿,也使用 generate_image;完整界面稿/HUD/弹窗/面板才使用 generate_ui_design。",
- "工具选择优先级:明确修改/指代已有图 => edit_image;规范图/视觉规范图/风格规范图/素材规范展板 => generate_image;明确新角色且不是规范展板或修改已有图 => generate_character;多个图标成品/图标图集 => generate_icon_spritesheet;完整界面稿 => generate_ui_design;其他全新图片 => generate_image。",
- "edit_image 必须依赖 latestAttachments 或 latestGeneratedImage;generate_icon_spritesheet 必须依赖 latestAttachments。",
- "当 latestGeneratedImage 存在,且用户说“这张/刚才那个/上一张/把衣服换成/改成/换成/修改上一张图”等指代或修改上一轮结果的话,必须选择 edit_image,prompt 保留用户修改要求,不要要求用户重新选择参考图,也不要降级成 generate_image。",
- "没有 latestAttachments 且没有 latestGeneratedImage 时,不要调用 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,
- latest_generation_reference: Option<&EditorAgentGeneratedImageReference>,
-) -> 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(),
- "generations": message
- .generations
- .iter()
- .map(editor_agent_generation_context_json)
- .collect::>(),
- })
- })
- .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,
- "latestGeneratedImage": latest_generation_reference
- .map(editor_agent_generated_image_reference_context_json),
- "recentMessages": history,
- })
- .to_string()
-}
-
-fn editor_agent_generation_context_json(generation: &EditorAgentGenerationRecord) -> Value {
- json!({
- "toolCallId": generation.tool_call_id,
- "toolName": generation.tool_name,
- "summary": generation.summary,
- "status": generation.status,
- "model": generation.model,
- "images": generation.images.iter().take(4).map(|image| json!({
- "resourceId": image.resource_id,
- "objectKey": image.object_key,
- "assetObjectId": image.asset_object_id,
- "imageSrc": image.image_src,
- "thumbnailSrc": image.thumbnail_src,
- "width": image.width,
- "height": image.height,
- "hasObjectKey": image.object_key.as_ref().is_some_and(|value| !value.trim().is_empty()),
- })).collect::>(),
- })
-}
-
-fn editor_agent_generated_image_reference_context_json(
- reference: &EditorAgentGeneratedImageReference,
-) -> Value {
- json!({
- "sourceMessageId": reference.source_message_id,
- "toolCallId": reference.tool_call_id,
- "toolName": reference.tool_name,
- "summary": reference.summary,
- "model": reference.model,
- "resourceId": reference.resource_id,
- "objectKey": reference.object_key,
- "assetObjectId": reference.asset_object_id,
- "imageSrc": reference.image_src,
- "thumbnailSrc": reference.thumbnail_src,
- "width": reference.width,
- "height": reference.height,
- "hasObjectKey": reference.object_key.as_ref().is_some_and(|value| !value.trim().is_empty()),
- })
-}
-
-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 sign_editor_agent_generated_image_url(
- state: &AppState,
- reference: &EditorAgentGeneratedImageReference,
-) -> Option {
- let object_key = reference
- .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 latest_editor_agent_generated_image_reference(
- document: &EditorAgentConversationMessagesDocument,
-) -> Option {
- document.messages.iter().rev().find_map(|message| {
- message
- .generations
- .iter()
- .rev()
- .filter(|generation| generation.status == EditorAgentGenerationStatus::Completed)
- .find_map(|generation| {
- generation.images.iter().rev().find_map(|image| {
- let has_reference_source = image
- .object_key
- .as_deref()
- .is_some_and(|value| !value.trim().is_empty())
- || image
- .resource_id
- .as_deref()
- .is_some_and(|value| !value.trim().is_empty())
- || !image.image_src.trim().is_empty();
- has_reference_source.then(|| EditorAgentGeneratedImageReference {
- source_message_id: message.id.clone(),
- tool_call_id: generation.tool_call_id.clone(),
- tool_name: generation.tool_name,
- summary: generation.summary.clone(),
- model: generation.model.clone(),
- resource_id: image.resource_id.clone(),
- object_key: image.object_key.clone(),
- asset_object_id: image.asset_object_id.clone(),
- image_src: image.image_src.clone(),
- thumbnail_src: image.thumbnail_src.clone(),
- width: image.width,
- height: image.height,
- })
- })
- })
- })
-}
-
-fn editor_agent_generated_reference_source(
- reference: &EditorAgentGeneratedImageReference,
-) -> Option {
- reference
- .object_key
- .as_deref()
- .map(str::trim)
- .filter(|value| !value.is_empty())
- .map(|value| value.trim_start_matches('/').to_string())
- .or_else(|| {
- reference
- .resource_id
- .as_deref()
- .map(str::trim)
- .filter(|value| !value.is_empty())
- .map(ToOwned::to_owned)
- })
- .or_else(|| {
- reference
- .image_src
- .trim()
- .strip_prefix('/')
- .map(str::to_string)
- .filter(|value| !value.is_empty())
- })
-}
-
-fn editor_agent_text_mentions_previous_generation(text: &str) -> bool {
- let normalized = text.trim().to_lowercase();
- contains_any(
- normalized.as_str(),
- &[
- "这张",
- "这个图",
- "这幅",
- "这张图",
- "刚才",
- "刚刚",
- "刚才那个",
- "刚才那张",
- "上一张",
- "上张",
- "上一幅",
- "上一个",
- "前一张",
- "上次生成",
- "刚生成",
- "latest image",
- "previous image",
- "last image",
- ],
- )
-}
-
-fn editor_agent_text_requests_previous_generation_edit(text: &str) -> bool {
- let normalized = text.trim().to_lowercase();
- if editor_agent_text_mentions_previous_generation(normalized.as_str())
- && contains_any(
- normalized.as_str(),
- &[
- "修改", "改成", "换成", "替换", "重绘", "编辑", "优化", "去掉", "加上", "增加",
- "变成", "调整", "换一", "换掉", "remove", "change", "edit", "redraw",
- ],
- )
- {
- return true;
- }
-
- let has_visual_target = contains_any(
- normalized.as_str(),
- &[
- "衣服", "服装", "发型", "头发", "背景", "颜色", "色调", "表情", "姿势", "主体", "风格",
- "脸", "眼睛", "帽子", "鞋", "裙子", "外套",
- ],
- );
- let has_edit_verb = contains_any(
- normalized.as_str(),
- &[
- "换", "改", "变", "加", "去", "删", "替换", "调整", "change", "edit", "remove",
- ],
- );
- has_visual_target && has_edit_verb
-}
-
-fn apply_previous_generation_edit_default(
- mut plan: EditorAgentTurnPlan,
- user_message: &EditorAgentMessage,
- latest_generation_reference: Option<&EditorAgentGeneratedImageReference>,
-) -> EditorAgentTurnPlan {
- if latest_generation_reference.is_none()
- || !user_message.attachments.is_empty()
- || !editor_agent_text_requests_previous_generation_edit(user_message.text.as_str())
- {
- return plan;
- }
-
- let should_force_edit = plan
- .tool_call
- .as_ref()
- .is_none_or(|tool_call| tool_call.tool_name != EditorAgentToolName::EditImage);
- if !should_force_edit {
- return plan;
- }
-
- let original_tool = plan.tool_call.take();
- let prompt = original_tool
- .as_ref()
- .map(|tool_call| tool_call.prompt.clone())
- .unwrap_or_else(|| user_message.text.trim().to_string());
- let summary = original_tool
- .as_ref()
- .and_then(|tool_call| tool_call.summary.clone())
- .or_else(|| Some("修改上一张图".to_string()));
- let model = original_tool
- .as_ref()
- .and_then(|tool_call| tool_call.model.clone());
- let image_size = original_tool
- .as_ref()
- .and_then(|tool_call| tool_call.image_size.clone())
- .or_else(|| Some("1K".to_string()));
-
- plan.reply_text = "我会基于上一张生成图按你的要求修改。".to_string();
- plan.tool_call = Some(EditorAgentToolCallPlan {
- tool_name: EditorAgentToolName::EditImage,
- prompt,
- summary,
- model,
- aspect_ratio: None,
- image_size,
- icon_descriptions: Vec::new(),
- });
- plan
-}
-
-fn parse_editor_agent_turn_plan(raw_text: &str) -> Option {
- let json_text = extract_editor_agent_json_object(raw_text)?;
- let raw: EditorAgentRawTurnPlan = serde_json::from_str(json_text).ok()?;
- let tool_call = match raw.tool_call {
- Some(tool) => {
- let prompt = normalize_optional_string(tool.prompt)?;
- 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(),
- })
- }
- None => None,
- };
- let reply_text = normalize_editor_agent_reply_text(raw.reply_text, tool_call.as_ref())?;
-
- 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 normalize_editor_agent_reply_text(
- reply_text: Option,
- tool_call: Option<&EditorAgentToolCallPlan>,
-) -> Option {
- let reply_text = normalize_optional_string(reply_text)?;
- if !editor_agent_reply_text_looks_structural(reply_text.as_str()) {
- return Some(reply_text);
- }
- tool_call.map(editor_agent_tool_call_reply_text)
-}
-
-fn editor_agent_reply_text_looks_structural(text: &str) -> bool {
- let trimmed = text.trim();
- if trimmed.is_empty() {
- return true;
- }
- let lower = trimmed.to_ascii_lowercase();
- if matches!(lower.as_str(), "null" | "undefined") {
- return true;
- }
- trimmed.len() <= 2
- && trimmed
- .chars()
- .all(|character| matches!(character, '{' | '}' | '[' | ']' | '"' | ':' | ','))
-}
-
-fn editor_agent_tool_call_reply_text(tool_call: &EditorAgentToolCallPlan) -> String {
- match tool_call.tool_name {
- EditorAgentToolName::GenerateImage => "我来生成图片。".to_string(),
- EditorAgentToolName::EditImage => "我来修改图片。".to_string(),
- EditorAgentToolName::GenerateCharacter => "我来生成角色形象。".to_string(),
- EditorAgentToolName::GenerateIconSpritesheet => "我来生成图标素材。".to_string(),
- EditorAgentToolName::GenerateUiDesign => "我来生成 UI 设计图。".to_string(),
- }
-}
-
-#[cfg(test)]
-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,
- latest_generation_reference: Option<&EditorAgentGeneratedImageReference>,
- 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()),
- phase_reporter: None,
- };
- 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 previous_generation_source = (tool_call.tool_name == EditorAgentToolName::EditImage
- && user_message.attachments.is_empty())
- .then(|| latest_generation_reference.and_then(editor_agent_generated_reference_source))
- .flatten();
- let tool_reference_sources = previous_generation_source
- .clone()
- .map(|source| vec![source])
- .unwrap_or_else(|| attachment_sources.clone());
- let generation_reference_context = editor_agent_tool_generation_reference_context(
- user_message.attachments.as_slice(),
- latest_generation_reference,
- previous_generation_source.as_deref(),
- );
- 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": generation_reference_context,
- }));
-
- 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(),
- screen_color: Some("auto".to_string()),
- seg_model: Some("birefnet".to_string()),
- aspect_ratio: tool_call.aspect_ratio.clone(),
- image_size: tool_call.image_size.clone(),
- reference_image_srcs: Some(tool_reference_sources.clone()),
- project_id: Some(conversation.project_id.clone()),
- asset_kind: Some(editor_agent_tool_asset_kind(tool_name).to_string()),
- generation_inputs,
- asset_folder_id: Some(editor_agent_default_asset_folder_id()),
- asset_label: tool_call.summary.clone(),
- source_resource_id: editor_agent_tool_source_resource_id(
- user_message.attachments.as_slice(),
- latest_generation_reference,
- previous_generation_source.as_deref(),
- ),
- canvas_completion: Some(completion),
- },
- )
- .await
- }
- EditorAgentToolName::EditImage => {
- let (source_image_src, reference_image_srcs) = split_editor_agent_edit_sources(
- tool_reference_sources.clone(),
- )
- .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(),
- aspect_ratio: None,
- image_size: None,
- 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: Some(editor_agent_default_asset_folder_id()),
- asset_label: tool_call.summary.clone(),
- source_resource_id: editor_agent_tool_source_resource_id(
- user_message.attachments.as_slice(),
- latest_generation_reference,
- previous_generation_source.as_deref(),
- ),
- 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(),
- screen_color: Some("auto".to_string()),
- seg_model: Some("birefnet".to_string()),
- 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: Some(editor_agent_default_asset_folder_id()),
- asset_label: 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);
- let resolved_summary = summary.unwrap_or_else(|| editor_agent_tool_default_summary(tool_name));
-
- EditorAgentToolExecution {
- record: EditorAgentGenerationRecord {
- tool_call_id: tool_call_id.to_string(),
- tool_name,
- summary: Some(resolved_summary.clone()),
- task_id,
- status: EditorAgentGenerationStatus::Completed,
- model,
- images,
- error: None,
- },
- summary: resolved_summary,
- }
-}
-
-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,
- object_key: None,
- asset_object_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),
- object_key: resource
- .get("objectKey")
- .and_then(Value::as_str)
- .map(str::to_string),
- asset_object_id: resource
- .get("assetObjectId")
- .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 editor_agent_tool_generation_reference_context(
- attachments: &[EditorAgentAttachmentRef],
- latest_generation_reference: Option<&EditorAgentGeneratedImageReference>,
- previous_generation_source: Option<&str>,
-) -> Vec {
- if previous_generation_source.is_some() {
- return latest_generation_reference
- .map(|reference| {
- vec![json!({
- "title": reference
- .summary
- .clone()
- .unwrap_or_else(|| "上一张生成图".to_string()),
- "label": reference.summary,
- "refType": "previous-generation",
- "refId": reference.resource_id,
- "resourceId": reference.resource_id,
- "objectKey": reference.object_key,
- "assetObjectId": reference.asset_object_id,
- "toolCallId": reference.tool_call_id,
- "toolName": reference.tool_name,
- "sourceMessageId": reference.source_message_id,
- "source": previous_generation_source,
- "implicit": true,
- })]
- })
- .unwrap_or_default();
- }
-
- 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,
- "resourceId": (attachment.source == EditorAgentAttachmentSource::CanvasResource)
- .then(|| attachment.reference_id.clone()),
- "objectKey": attachment.object_key,
- })
- })
- .collect()
-}
-
-fn editor_agent_tool_source_resource_id(
- attachments: &[EditorAgentAttachmentRef],
- latest_generation_reference: Option<&EditorAgentGeneratedImageReference>,
- previous_generation_source: Option<&str>,
-) -> Option {
- if previous_generation_source.is_some() {
- return latest_generation_reference.and_then(|reference| reference.resource_id.clone());
- }
- attachments
- .first()
- .map(|attachment| attachment.reference_id.clone())
-}
-
-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_default_asset_folder_id() -> String {
- "project".to_string()
-}
-
-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),
- )
-}
-
-#[cfg(test)]
-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())
-}
-
-async fn require_editor_agent_sidebar_enabled(
- state: &AppState,
- owner_user_id: &str,
-) -> Result<(), AppError> {
- match state
- .is_image_editor_agent_sidebar_enabled_for_user(Some(owner_user_id))
- .await
- {
- Ok(true) => Ok(()),
- Ok(false) => Err(editor_agent_sidebar_unavailable()),
- Err(error) => Err(AppError::from_status(StatusCode::BAD_GATEWAY)
- .with_message("读取画布 Agent 灰度配置失败")
- .with_details(json!({
- "provider": "spacetimedb",
- "message": error.to_string(),
- }))),
- }
-}
-
-fn editor_agent_sidebar_unavailable() -> AppError {
- AppError::from_status(StatusCode::SERVICE_UNAVAILABLE)
- .with_message("画布 Agent 暂不可用")
- .with_details(json!({
- "provider": "editor-agent",
- "reason": "image_editor_agent_sidebar_disabled",
- "gateKey": module_runtime::IMAGE_EDITOR_AGENT_SIDEBAR_GATE_KEY,
- }))
-}
-
-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::*;
- use crate::AppConfig;
- use platform_llm::LlmTextProtocol;
-
- #[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()),
- generation_cost_mud_points: 0,
- showcase_id: None,
- showcase_review_status: None,
- showcase_display_enabled: None,
- showcase_like_count: None,
- 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 generated_image_from_resource_keeps_private_asset_keys() {
- let image = editor_agent_generated_image_from_resource(
- &json!({
- "resourceId": "resource-1",
- "imageSrc": "/generated-editor-assets/result.png",
- "thumbnailSrc": "/generated-editor-assets/result-thumb.png",
- "objectKey": "generated-editor-assets/result.png",
- "assetObjectId": "asset-object-result",
- "width": 1024,
- "height": 768
- }),
- None,
- None,
- None,
- );
-
- assert_eq!(image.resource_id.as_deref(), Some("resource-1"));
- assert_eq!(
- image.object_key.as_deref(),
- Some("generated-editor-assets/result.png")
- );
- assert_eq!(
- image.asset_object_id.as_deref(),
- Some("asset-object-result")
- );
- assert_eq!(image.width, Some(1024));
- assert_eq!(image.height, Some(768));
- }
-
- #[test]
- fn editor_agent_generations_target_default_asset_library_folder() {
- assert_eq!(editor_agent_default_asset_folder_id(), "project");
- }
-
- #[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"
- }
-}
-```"#,
- )
- .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_turn_plan_rejects_invalid_llm_response() {
- assert!(parse_editor_agent_turn_plan("收到:画一棵树").is_none());
- assert!(
- parse_editor_agent_turn_plan(
- r#"{"toolCall":{"toolName":"generate_image","prompt":"画一棵树"}}"#,
- )
- .is_none()
- );
- assert!(
- parse_editor_agent_turn_plan(
- r#"{"replyText":"我来生成图片。","toolCall":{"toolName":"generate_image"}}"#,
- )
- .is_none()
- );
- }
-
- #[test]
- fn editor_agent_turn_plan_replaces_structural_reply_fragment() {
- let plan = parse_editor_agent_turn_plan(
- r#"{"replyText":"{","toolCall":{"toolName":"generate_image","prompt":"生成一张角色规范图","summary":"生成角色规范图"}}"#,
- )
- .expect("valid tool call should parse");
-
- assert_eq!(plan.reply_text, "我来生成图片。");
- assert_eq!(
- plan.tool_call.as_ref().map(|tool_call| tool_call.tool_name),
- Some(EditorAgentToolName::GenerateImage)
- );
- }
-
- #[test]
- fn editor_agent_llm_system_prompt_describes_each_tool() {
- let prompt = editor_agent_llm_system_prompt();
-
- for expected in [
- "generate_image:从文字生成一张全新图片",
- "edit_image:修改已有图片",
- "generate_character:生成新的角色形象",
- "generate_icon_spritesheet:生成一组图标素材或图标图集",
- "generate_ui_design:生成一张完整 UI 设计图或界面稿",
- "规范图/视觉规范图/风格规范图/素材规范展板",
- "当前使用 generate_image",
- "角色规范图/角色美术视觉规范设定图",
- "图标规范图/图标视觉规范展板",
- "统一视角、线条粗细",
- "色卡/色号",
- "工具选择优先级",
- "明确修改/指代已有图 => edit_image",
- "规范图/视觉规范图/风格规范图/素材规范展板 => generate_image",
- ] {
- assert!(
- prompt.contains(expected),
- "system prompt should contain {expected}"
- );
- }
- }
-
- #[test]
- fn latest_generation_reference_enters_llm_prompt() {
- let conversation = test_conversation_record();
- let user_message = test_user_message("把刚才那个衣服换成蓝色");
- let mut document = empty_messages_document(conversation.conversation_id.as_str());
- document.messages.push(EditorAgentMessage {
- id: "editor-agent-message-assistant-1".to_string(),
- role: EditorAgentMessageRole::Assistant,
- kind: EditorAgentMessageKind::Chat,
- text: "已生成角色图。".to_string(),
- attachments: Vec::new(),
- generations: vec![test_completed_generation_record()],
- status: EditorAgentMessageStatus::Completed,
- created_at: "2026-07-05T00:00:00Z".to_string(),
- });
- document.messages.push(user_message.clone());
-
- let reference = latest_editor_agent_generated_image_reference(&document)
- .expect("latest generated image should be available");
- let prompt = build_editor_agent_llm_user_prompt(
- &conversation,
- &document,
- &user_message,
- Some(&reference),
- );
- let prompt_json: Value = serde_json::from_str(prompt.as_str()).expect("prompt is JSON");
-
- assert_eq!(
- prompt_json["latestGeneratedImage"]["toolName"],
- json!("generate_image")
- );
- assert_eq!(
- prompt_json["latestGeneratedImage"]["summary"],
- json!("生成红衣角色")
- );
- assert_eq!(
- prompt_json["latestGeneratedImage"]["resourceId"],
- json!("resource-generated-1")
- );
- assert_eq!(
- prompt_json["latestGeneratedImage"]["objectKey"],
- json!("generated-editor-assets/result.png")
- );
- assert_eq!(
- prompt_json["recentMessages"][0]["generations"][0]["images"][0]["objectKey"],
- json!("generated-editor-assets/result.png")
- );
- }
-
- #[test]
- fn previous_generation_edit_default_coerces_generate_image_to_edit_image() {
- let user_message = test_user_message("把衣服换成蓝色");
- let reference = test_generated_image_reference();
- let plan = EditorAgentTurnPlan {
- reply_text: "我来生成图片。".to_string(),
- tool_call: Some(EditorAgentToolCallPlan {
- tool_name: EditorAgentToolName::GenerateImage,
- prompt: "把衣服换成蓝色".to_string(),
- summary: Some("生成蓝色衣服".to_string()),
- model: None,
- aspect_ratio: Some("1:1".to_string()),
- image_size: Some("1K".to_string()),
- icon_descriptions: Vec::new(),
- }),
- };
-
- let adjusted =
- apply_previous_generation_edit_default(plan, &user_message, Some(&reference));
- let tool_call = adjusted.tool_call.expect("tool should be forced");
-
- assert_eq!(tool_call.tool_name, EditorAgentToolName::EditImage);
- assert_eq!(tool_call.prompt, "把衣服换成蓝色");
- assert_eq!(adjusted.reply_text, "我会基于上一张生成图按你的要求修改。");
- }
-
- #[test]
- fn previous_generation_reference_becomes_implicit_edit_source() {
- let reference = test_generated_image_reference();
-
- let sources = editor_agent_generated_reference_source(&reference);
- let context = editor_agent_tool_generation_reference_context(
- &[],
- Some(&reference),
- sources.as_deref(),
- );
-
- assert_eq!(
- sources.as_deref(),
- Some("generated-editor-assets/result.png")
- );
- assert_eq!(context[0]["refType"], json!("previous-generation"));
- assert_eq!(context[0]["resourceId"], json!("resource-generated-1"));
- assert_eq!(
- context[0]["objectKey"],
- json!("generated-editor-assets/result.png")
- );
- assert_eq!(context[0]["implicit"], json!(true));
- }
-
- #[test]
- fn editor_agent_tool_execution_persists_summary() {
- let execution = build_editor_agent_tool_execution(
- "editor-agent-tool-1",
- EditorAgentToolName::EditImage,
- Some("换蓝色衣服".to_string()),
- json!({
- "ok": true,
- "data": {
- "model": "gpt-image-2",
- "resource": {
- "resourceId": "resource-generated-2",
- "imageSrc": "/generated-editor-assets/blue.png",
- "objectKey": "generated-editor-assets/blue.png",
- "assetObjectId": "asset-object-blue",
- "width": 1024,
- "height": 1024
- }
- }
- }),
- );
-
- assert_eq!(execution.summary, "换蓝色衣服");
- assert_eq!(execution.record.summary.as_deref(), Some("换蓝色衣服"));
- }
-
- #[tokio::test]
- async fn editor_agent_llm_request_uses_vector_engine_chat_model() {
- let conversation = test_conversation_record();
- let document = empty_messages_document(conversation.conversation_id.as_str());
- let user_message = test_user_message("帮我看看这张图还能怎么改");
- let state = AppState::new(AppConfig::default()).expect("state should build");
-
- let request =
- build_editor_agent_llm_request(&state, &conversation, &document, &user_message, None)
- .await;
-
- assert_eq!(request.protocol, LlmTextProtocol::ChatCompletions);
- assert_eq!(
- request.model.as_deref(),
- Some(platform_agent::CREATIVE_AGENT_GPT5_MODEL)
- );
- assert_eq!(request.max_tokens, Some(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS));
- assert_eq!(
- request.request_timeout_ms,
- Some(EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS)
- );
- }
-
- #[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(),
- }
- }
-
- fn test_user_message(text: &str) -> EditorAgentMessage {
- EditorAgentMessage {
- id: "editor-agent-message-user-1".to_string(),
- role: EditorAgentMessageRole::User,
- kind: EditorAgentMessageKind::Chat,
- text: text.to_string(),
- attachments: Vec::new(),
- generations: Vec::new(),
- status: EditorAgentMessageStatus::Completed,
- created_at: "2026-07-05T00:00:00Z".to_string(),
- }
- }
-
- fn test_completed_generation_record() -> EditorAgentGenerationRecord {
- EditorAgentGenerationRecord {
- tool_call_id: "editor-agent-tool-generated-1".to_string(),
- tool_name: EditorAgentToolName::GenerateImage,
- summary: Some("生成红衣角色".to_string()),
- task_id: Some("task-generated-1".to_string()),
- status: EditorAgentGenerationStatus::Completed,
- model: Some("gpt-image-2".to_string()),
- images: vec![EditorAgentGeneratedImage {
- resource_id: Some("resource-generated-1".to_string()),
- object_key: Some("generated-editor-assets/result.png".to_string()),
- asset_object_id: Some("asset-object-result".to_string()),
- image_src: "/generated-editor-assets/result.png".to_string(),
- thumbnail_src: Some("/generated-editor-assets/result-thumb.png".to_string()),
- width: Some(1024),
- height: Some(1024),
- }],
- error: None,
- }
- }
-
- fn test_generated_image_reference() -> EditorAgentGeneratedImageReference {
- let mut document = empty_messages_document("editor-agent-conv-1");
- document.messages.push(EditorAgentMessage {
- id: "editor-agent-message-assistant-1".to_string(),
- role: EditorAgentMessageRole::Assistant,
- kind: EditorAgentMessageKind::Chat,
- text: "已生成角色图。".to_string(),
- attachments: Vec::new(),
- generations: vec![test_completed_generation_record()],
- status: EditorAgentMessageStatus::Completed,
- created_at: "2026-07-05T00:00:00Z".to_string(),
- });
- latest_editor_agent_generated_image_reference(&document)
- .expect("latest generated image should be available")
- }
-}
diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs
new file mode 100644
index 000000000..04a7e33a2
--- /dev/null
+++ b/server-rs/crates/api-server/src/editor_agent/api.rs
@@ -0,0 +1,1196 @@
+use axum::extract::{Path, State};
+use axum::{Extension, Json};
+use module_editor_agent::{
+ EDITOR_AGENT_CONVERSATION_ID_PREFIX, EDITOR_AGENT_DEFAULT_CONVERSATION_TITLE,
+ derive_conversation_title, editor_agent_messages_object_key, validate_user_message,
+};
+use platform_editor_agent::framework::agent_builder::AgentBuilder;
+use platform_editor_agent::framework::error::PromptError;
+use platform_editor_agent::framework::memory::VecMemory;
+use platform_editor_agent::framework::run::PromptOutput;
+use platform_editor_agent::framework::tool::Tool;
+use platform_llm::LlmMessage;
+use serde::Serialize;
+use serde_json::{Value, json};
+use sha2::{Digest, Sha256};
+use shared_contracts::assets::{
+ EditorBackgroundMusicGenerateRequest, EditorSoundEffectGenerateRequest,
+ EditorVideoGenerateRequest,
+};
+use shared_contracts::editor_agent::{
+ CreateEditorAgentConversationRequest, EDITOR_AGENT_ERROR_MESSAGE_PREFIX,
+ EditorAgentConversationListResponse, EditorAgentConversationMessagesDocument,
+ EditorAgentConversationResponse, EditorAgentConversationSummary, EditorAgentMessage,
+ EditorAgentMessageRequest, EditorAgentMessageResponse, EditorAgentMessageRole,
+ EditorAgentToolCall, EditorAgentToolCallStatus,
+};
+use spacetime_client::{
+ EditorAgentConversationCreateRecordInput, EditorAgentConversationDeleteRecordInput,
+ EditorAgentConversationRecord, EditorAgentConversationTouchRecordInput,
+ EditorProjectGetRecordInput,
+};
+
+use crate::api_response::json_success_body;
+use crate::auth::AuthenticatedAccessToken;
+use crate::editor_agent::pricing::EditorAgentPricedTool;
+use crate::editor_agent::utils::{
+ IntoImageId, build_editor_agent_canvas_completion, conversation_detail_from_record,
+ conversation_summary_from_record, editor_agent_bad_request, empty_messages_document,
+ ensure_editor_project_access, normalize_editor_agent_attachments, now_rfc3339,
+ read_messages_document, require_editor_agent_sidebar_enabled, write_messages_document,
+};
+use crate::editor_agent::{context, display_args, reconcile};
+use crate::editor_generation_config::EditorGenerationPricingConfig;
+use crate::editor_generation_queue::{
+ EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND,
+ EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND,
+ EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND,
+ enqueue_editor_generation_job_with_identity,
+};
+use crate::editor_project::{
+ EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest,
+};
+use crate::editor_project::{current_utc_micros, map_editor_project_error};
+use crate::http_error::AppError;
+use crate::request_context::RequestContext;
+use crate::state::AppState;
+use platform_editor_agent::agent::agent::LlmChatAgentBuilder;
+use platform_editor_agent::agent::tools::context::EditorToolContext;
+use platform_editor_agent::agent::tools::edit_image::{EditImageTool, EditImageToolArgs};
+use platform_editor_agent::agent::tools::generate_background_music::{
+ GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs,
+};
+use platform_editor_agent::agent::tools::generate_character::GenerateCharacterTool;
+use platform_editor_agent::agent::tools::generate_icon_spritesheet::{
+ GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs,
+};
+use platform_editor_agent::agent::tools::generate_image::{
+ GenerateImageTool, GenerateImageToolArgs,
+};
+use platform_editor_agent::agent::tools::generate_sound_effect::{
+ GenerateSoundEffectTool, GenerateSoundEffectToolArgs,
+};
+use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool;
+use platform_editor_agent::agent::tools::generate_video::{
+ GenerateVideoTool, GenerateVideoToolArgs,
+};
+use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string};
+
+const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128;
+
+pub async fn editor_agent_message(
+ State(state): State,
+ Path(conversation_id): Path,
+ Extension(_request_context): Extension,
+ Extension(authenticated): Extension,
+ Json(payload): Json,
+) -> Result, AppError> {
+ let owner_user_id = authenticated.claims().user_id().to_string();
+ require_editor_agent_sidebar_enabled(&state, owner_user_id.as_str()).await?;
+ let client_message_id = validate_editor_agent_message_request(&payload)?;
+ let normalized_text = payload.text.trim().to_string();
+ // Load conversation & attachments
+ let conversation = state
+ .spacetime_client()
+ .get_editor_agent_conversation(conversation_id.clone(), owner_user_id.clone())
+ .await
+ .map_err(|e| {
+ AppError::from_status(axum::http::StatusCode::NOT_FOUND)
+ .with_details(json!({ "message": format!("conversation not found: {e}") }))
+ })?;
+
+ let attachments =
+ normalize_editor_agent_attachments(&state, &conversation, payload.attachments.as_slice())
+ .await?;
+
+ let conversation_lock = crate::editor_agent::utils::editor_agent_conversation_lock(
+ conversation.conversation_id.as_str(),
+ );
+ let _conversation_lock_guard = conversation_lock.lock_owned().await;
+
+ let mut document: EditorAgentConversationMessagesDocument =
+ read_messages_document(&state, &conversation).await?;
+
+ let existing_user_index = find_idempotent_editor_agent_user_message(
+ &document,
+ client_message_id.as_str(),
+ normalized_text.as_str(),
+ attachments.as_slice(),
+ )?;
+
+ let (user_message, history_end, conversation_summary) =
+ if let Some(user_index) = existing_user_index {
+ let delta_messages = document.messages[user_index + 1..]
+ .iter()
+ .take_while(|message| message.role != EditorAgentMessageRole::User)
+ .cloned()
+ .collect::>();
+ if !delta_messages.is_empty() {
+ return Ok(Json(EditorAgentMessageResponse {
+ conversation: conversation_summary_from_record(conversation),
+ delta_messages,
+ error_message: None,
+ }));
+ }
+
+ (
+ document.messages[user_index].clone(),
+ user_index,
+ conversation_summary_from_record(conversation.clone()),
+ )
+ } else {
+ // Determine initialization before attachment bookkeeping adds a system message.
+ let was_empty = document.messages.is_empty();
+ let now = now_rfc3339();
+ if !attachments.is_empty() {
+ let mut attachment_info = String::new();
+ attachment_info.push_str("user has just uploaded attachments of the order: ");
+ for attachment in &attachments {
+ attachment_info.push_str(&format!("{} ,", attachment.clone().into_image_id()));
+ }
+ document.messages.push(EditorAgentMessage {
+ id: document.messages.len(),
+ client_message_id: None,
+ role: EditorAgentMessageRole::System,
+ text: attachment_info,
+ attachments: Vec::new(),
+ tool_call: None,
+ created_at: now.clone(),
+ });
+ }
+
+ let history_end = document.messages.len();
+ let user_message = EditorAgentMessage {
+ id: document.messages.len(),
+ client_message_id: Some(client_message_id),
+ role: EditorAgentMessageRole::User,
+ text: normalized_text,
+ attachments,
+ tool_call: None,
+ created_at: now,
+ };
+ document.messages.push(user_message.clone());
+ write_messages_document(&state, &conversation, &document).await?;
+
+ // Persist and return the authoritative summary for every turn. Initialization sets the
+ // title from the first user prompt; a metadata write failure must fail the request.
+ let updated_conversation = state
+ .spacetime_client()
+ .touch_editor_agent_conversation(EditorAgentConversationTouchRecordInput {
+ conversation_id: conversation.conversation_id.clone(),
+ owner_user_id: conversation.owner_user_id.clone(),
+ title: was_empty.then(|| derive_conversation_title(user_message.text.as_str())),
+ updated_at_micros: current_utc_micros(),
+ })
+ .await
+ .map_err(map_editor_project_error)?;
+
+ (
+ user_message,
+ history_end,
+ conversation_summary_from_record(updated_conversation),
+ )
+ };
+
+ // The current user message is passed separately to prompt(), so memory stops before it.
+ let previous_messages: Vec = document.messages[..history_end]
+ .iter()
+ .map(|message| match message.role {
+ EditorAgentMessageRole::User => LlmMessage::user(&message.text),
+ EditorAgentMessageRole::Assistant => LlmMessage::assistant(&message.text),
+ EditorAgentMessageRole::System => LlmMessage::system(&message.text),
+ })
+ // Tool calls and attachment bookkeeping are separate system messages.
+ .rev()
+ .take(18)
+ .rev()
+ .collect();
+
+ // Build tool context from document
+ let tool_context = context::build_tool_context(&document);
+
+ // Build and run agent
+ let Some(llm_client) = state.creative_agent_gpt5_client() else {
+ return persist_editor_agent_planning_error(
+ &state,
+ &conversation,
+ &mut document,
+ conversation_summary,
+ "Creative Agent GPT-5 client not configured",
+ )
+ .await;
+ };
+ let llm_client = llm_client.clone();
+ let pricing = match state.editor_generation_pricing().await {
+ Ok(pricing) => pricing,
+ Err(error) => {
+ return persist_editor_agent_planning_error(
+ &state,
+ &conversation,
+ &mut document,
+ conversation_summary,
+ format!("failed to load editor generation pricing: {error}"),
+ )
+ .await;
+ }
+ };
+
+ let memory = VecMemory::new(previous_messages);
+
+ let mut agent = LlmChatAgentBuilder::new()
+ .with_client(llm_client)
+ .system_prompt(editor_agent_system_prompt())
+ .tool(EditImageTool {
+ context: tool_context.clone(),
+ })
+ .tool(GenerateImageTool {
+ context: tool_context.clone(),
+ })
+ .tool(GenerateCharacterTool {
+ context: tool_context.clone(),
+ })
+ .tool(GenerateIconSpritesheetTool {
+ context: tool_context.clone(),
+ })
+ .tool(GenerateSoundEffectTool)
+ .tool(GenerateBackgroundMusicTool)
+ .tool(GenerateVideoTool {
+ context: tool_context.clone(),
+ })
+ .tool(GenerateUiDesignTool {
+ context: tool_context.clone(),
+ })
+ .max_turns(3)
+ .memory(memory)
+ .build();
+
+ let agent_result = agent
+ .prompt(LlmMessage::user(user_message.text.clone()))
+ .await;
+
+ let assistant_now = now_rfc3339();
+
+ match build_delta_messages(
+ agent_result,
+ &assistant_now,
+ document.messages.len(),
+ &document,
+ &tool_context,
+ &pricing,
+ ) {
+ Err(error) => {
+ persist_editor_agent_planning_error(
+ &state,
+ &conversation,
+ &mut document,
+ conversation_summary,
+ error.to_string(),
+ )
+ .await
+ }
+ Ok(delta_messages) => {
+ for msg in &delta_messages {
+ document.messages.push(msg.clone());
+ }
+ write_messages_document(&state, &conversation, &document).await?;
+
+ Ok(Json(EditorAgentMessageResponse {
+ conversation: conversation_summary,
+ delta_messages,
+ error_message: None,
+ }))
+ }
+ }
+}
+
+fn build_editor_agent_error_message(
+ message_id: usize,
+ error: impl std::fmt::Display,
+) -> EditorAgentMessage {
+ EditorAgentMessage {
+ id: message_id,
+ client_message_id: None,
+ role: EditorAgentMessageRole::System,
+ text: format!("{EDITOR_AGENT_ERROR_MESSAGE_PREFIX}{error}"),
+ attachments: Vec::new(),
+ tool_call: None,
+ created_at: now_rfc3339(),
+ }
+}
+
+async fn persist_editor_agent_planning_error(
+ state: &AppState,
+ conversation: &EditorAgentConversationRecord,
+ document: &mut EditorAgentConversationMessagesDocument,
+ conversation_summary: EditorAgentConversationSummary,
+ error: impl std::fmt::Display,
+) -> Result, AppError> {
+ let error_message = build_editor_agent_error_message(document.messages.len(), error);
+ document.messages.push(error_message.clone());
+ write_messages_document(state, conversation, document).await?;
+
+ Ok(Json(EditorAgentMessageResponse {
+ conversation: conversation_summary,
+ delta_messages: vec![error_message],
+ error_message: None,
+ }))
+}
+
+fn validate_editor_agent_message_request(
+ payload: &EditorAgentMessageRequest,
+) -> Result {
+ let client_message_id = normalize_required_string(payload.client_message_id.as_str())
+ .ok_or_else(|| editor_agent_bad_request("clientMessageId is required"))?;
+ if client_message_id.chars().count() > EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS {
+ return Err(editor_agent_bad_request(format!(
+ "clientMessageId must not exceed {EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS} characters"
+ )));
+ }
+ let attachment_reference_ids = payload
+ .attachments
+ .iter()
+ .map(|attachment| attachment.reference_id.clone())
+ .collect::>();
+ validate_user_message(payload.text.as_str(), attachment_reference_ids.as_slice())
+ .map_err(|error| editor_agent_bad_request(error.to_string()))?;
+ Ok(client_message_id)
+}
+
+fn find_idempotent_editor_agent_user_message(
+ document: &EditorAgentConversationMessagesDocument,
+ client_message_id: &str,
+ text: &str,
+ attachments: &[shared_contracts::editor_agent::EditorAgentAttachmentRef],
+) -> Result