Compare commits

...

1 Commits

Author SHA1 Message Date
k88936 a3d83b2d06 staged 2026-07-30 15:43:22 +08:00
17 changed files with 901 additions and 77 deletions
@@ -16,6 +16,17 @@
---
## 2026-07-29 画布 Agent 工具生命周期区分确认任务与即时执行
- 背景:原 `EditorAgentTool` 默认要求每个工具都实现定价、确认展示和 external job payload,无法表达不调用生成模型、零收费且无需确认的确定性拆图;同时角色动作已有正式 worker 能力但尚未进入 Agent 工具面。
- 决策:工具统一声明 `ConfirmedExternalJob``Immediate` 生命周期。收费生成工具仅在产生待确认输出后读取运行时定价,确认后复用既有 external job`generate-character-animation` 复用 `editor_character_animation_generation``split-elements` 只接受当前画布项目中的 `ui-design` / `icon-spritesheet`,在规划回合内直接复用连通域切片、项目资源 / 素材库持久化和画布回填,成功消息直接写 `completed`,不读取定价、不展示确认卡、不创建零价队列任务。同一规划运行内按源资源复用即时拆图结果,避免模型重复调用造成重复切片。
- 边界:不修改 SpacetimeDB schema、外部 OpenAPI、钱包规则或现有生成 job;直接拆图仍受现有图片尺寸、像素数、切片数量、资源归属和画布写回门禁约束。
- 影响范围:`platform-editor-agent` 工具定义与 prompt、`api-server/src/editor_agent` 生命周期和结果持久化、既有图集拆分 helper、Agent 前端完成态刷新及编辑器专题文档。
- 验证方式:运行 `cargo test -p platform-editor-agent --manifest-path server-rs/Cargo.toml``cargo test -p api-server editor_agent --manifest-path server-rs/Cargo.toml`、即时 UI 拆图来源类型测试、Agent 前端 hook 定向测试、`cargo check -p api-server --manifest-path server-rs/Cargo.toml``npm run typecheck`、编码和 diff 检查。
- 关联文档:`docs/【编辑器】画布Agent对话面板-2026-07-03.md``docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md``docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`
---
## 2026-07-29 角色带背景原图与透明图统一交付尺寸
- 背景:图片画布已将模型原生回图归一到统一业务像素矩阵,但角色分支为了保留 provider 原生分辨率,先持久化带背景原图,只在扣背后归一透明主图。因此同一个 1K 角色任务会同时给出模型原生大图和长边 `1024` 的透明图。
@@ -64,6 +64,7 @@
- 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/<objectKey>"``objectKey``assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*``data:video/*``data:audio/*``blob:`。旧数据读取时如果已有 `objectKey``imageSrc` 归一成 `/<objectKey>`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 canvas 本地渲染 PNG,也必须先上传 OSS / asset object 并创建 `editor_project_resource`,再把带正式 `resourceId/objectKey/assetObjectId` 的裁扩图层加入画布;不能先把 `local-resource-*` + Data URL 图层交给项目保存或后续去背景。上传到生成面板参考图槽位的图片必须先创建 `editor_project_resource` 行;没有当前工程 ID 时才创建账号级 `editor_asset` 行,随后把对应 `resourceId``assetId` 写入参考图临时状态;生成请求提交前必须把临时状态解析成 `objectKey`、项目资源 ID 或素材 ID,未登记的本地图片和普通图片路径先上传 OSS,不能直接提交 Data URL、Blob URL 或临时图片源。
- 资源表保存资源和素材级元数据;图层位置、层级、分组选中所需 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` 或资源 IDData 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。
- 画布 Agent 额外提供确定性 `split-elements`:只对当前项目中的 `ui-design` / `icon-spritesheet` 资源执行既有连通域切片、零成本素材持久化和画布快照写回;它不调用生成模型、不读取定价、不需要确认,也不进入 `external_generation_job`。角色动作 Agent 工具仍复用正式 `editor_character_animation_generation` 队列任务和收费确认。
- 生成面板不展示资源名称输入,默认使用原有自动编号;提示词输入保持统一可见边框。内部命名契约仍使用可选 `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,同一会话的消息追加和工具结果回填由 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 当作会话长期事实。
@@ -201,6 +201,8 @@ controller 配置:
- `editor_video_generation`:画布视频生成和视频素材快速编辑。
- `editor_sound_effect_generation` / `editor_background_music_generation`:画布音效与背景音乐。
画布 Agent 的 `generate-character-animation` 确认后复用 `editor_character_animation_generation`,保持既有定价、dedupe key、lease、worker、退款和画布回填语义。`split-elements` 不调用外部 provider,直接复用站内连通域切片及资源持久化,不创建零价 `external_generation_job`;它的完成事实直接写入 Agent OSS 消息和项目快照。
画板结果的业务真相仍是 `editor_project_resource`、账号级 `editor_asset``editor_canvas.layers_json`。请求携带 `projectId + canvasCompletion` 时,worker 成功后读取当前项目 layout,用最新 generation dialog placeholder 或无 dialog 完成占位写入结果图层,并保存项目快照;前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload 或本地临时响应重建正式图层。生成器已被删除时,worker 只保留生成出的资源 / 素材记录,不把结果重新塞回画布。
角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已经持久化后,如果透明背景处理最终失败,只用原图完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。
@@ -1,6 +1,6 @@
# 画布Agent对话面板
日期:`2026-07-23`
日期:`2026-07-29`
## 定位与边界
@@ -10,15 +10,17 @@
## 能力范围
对话 Agent 可通过 function-calling 触发以下类工具,全部复用既有计费收口接口
对话 Agent 可通过 function-calling 触发以下类工具;生成类工具复用既有计费收口,确定性拆图即时执行且不计费
| 工具 | 后端接口 |
| --- | --- |
| 生成图片 | `POST /api/editor/images/generations` |
| 修改图片(基于附件/画布素材) | `POST /api/editor/images/edits` |
| 生成角色形象 | 既有角色形象生成入口对应接口 |
| 基于角色图片生成角色动作 | `POST /api/editor/character-animations/generations` |
| 生成图标素材 | `POST /api/editor/icon-spritesheets/generations` |
| 生成 UI 设计图 | 既有 UI 设计图生成入口对应接口 |
| 拆分已有 UI / 图集元素 | 复用 `POST /api/editor/icon-spritesheets/slices` 的确定性连通域拆分逻辑 |
| 生成视频 | `POST /api/editor/videos/generations` |
| 生成游戏音效 | `POST /api/editor/audios/sound-effects/generations` |
| 生成背景音乐 | `POST /api/editor/audios/background-music/generations` |
@@ -28,14 +30,15 @@
- 用户要求“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”时,规划默认选择 `generate_image`,并在 prompt 中明确要求生成规范展板,包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等可落地的视觉规范元素。
- 用户要求“角色规范图”且语义是角色的规范展板、风格展板或设定板时,仍走 `generate_image`,不要误分流到 `generate_character`;只有实际生成角色立绘、角色主形象或角色视觉资产时才走 `generate_character`。用户要求多个图标素材、图集或 spritesheet 时才走 `generate_icon_spritesheet`
- 所有生成必须走 `execute_billable_asset_operation_with_cost` 与模型定价配置,禁止绕过定价收口。
- `split-elements` 只接受当前画布项目中的 `ui-design``icon-spritesheet` 资源,直接执行既有确定性切片、项目资源 / 素材库持久化和画布回填;不调用生成模型、不进入定价、不展示确认卡,也不创建 `external_generation_job`
- function-calling 的 JSON Schema 必须与参数默认值和运行时校验保持一致,不能只在 description 中提示会被运行时拒绝的组合。`generate-ui-design` 固定 `gpt-image-2`,因此 `image_size` 只暴露 `1K / 2K`;其它可切换图片模型的工具通过共享条件 schema 在显式选择 `gpt-image-2` 时同样把 `image_size` 限制为 `1K / 2K`,省略模型时仍按默认 nanobanana2 允许 `0.5K``generate-video` 省略 `model` 时按默认 `seedance2.0-fast` 约束 `resolution``480p / 720p`,显式选择其它模型时仍使用其现有分辨率范围。运行时强类型校验继续作为最终防线。
- 图层操作及其他未注册的画板功能第一期不进入对话工具面,仍走现有面板。
## 当前分支落地状态
- 已落地:会话元数据、OSS 消息文档、会话 CRUD、带 `clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及八类图片 / 音视频工具对既有生成入口的复用
- 已落地:会话元数据、OSS 消息文档、会话 CRUD、带 `clientMessageId` 幂等键的普通 JSON 消息请求、后端 LLM 工具规划、右侧对话面板、会话历史、新建 / 软删会话、附件从画布资源 / 账号素材库选择,以及九类收费图片 / 音视频工具和一类免费即时拆图工具
- 已落地:工具确认 / 取消、external generation task 轮询与会话懒回填。LLM 未配置、请求失败或规划结果解析失败时,后端把 `role=system`、正文以 `ERROR ` 开头的消息写入 OSS,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏 wire 前缀并以红色错误气泡展示。工具执行失败继续保存 `status=failed`、模型和错误信息,不能只返回瞬时错误。
- 未落地:附件弹窗末尾上传格。`external_generation_job` 继续作为后台任务队列真相,对话消息只保存确认、回填状态和轻量媒体结果引用
- 未落地:附件弹窗末尾上传格。收费生成继续以 `external_generation_job` 作为后台任务队列真相;即时拆图在当前消息请求内完成并把 `status=completed` 与轻量图片引用直接写入 OSS 对话消息
## 会话与持久化
@@ -86,13 +89,14 @@
## 工具调用确认展示契约
- api-server 的 `EditorAgentToolExecutionMode` 明确区分 `ConfirmedExternalJob``Immediate`:前者才允许读取定价、构建确认展示和 worker payload;后者由 function-calling runner 当场执行,成功消息直接持久化为 `completed`,不得经过确认接口或伪造零价外部任务。
- 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**,不是 LLM 返回的原始 JSON。api-server 收到工具调用后,必须先按已注册的 ToolArgs 反序列化、补齐字段默认值、删除未进入 ToolArgs 的未知 / 退役字段、执行工具参数校验,再重新序列化并写入 `args`;校验失败的调用不得持久化为待确认消息。所有有明确默认值的工具标量参数在强类型 ToolArgs 中必须使用非 `Option` 字段:调用方省略字段或把顶层字段显式传为 `null` 时,统一在 ToolArgs 反序列化前视为未提供,由 Serde 补齐默认值,并把具体默认值写入规范 `args`;没有默认值的必填字段显式传为 `null` 时同样按缺失处理.(for compatibility) 后续计价、确认展示和 job payload 不得再次使用 `unwrap_or` 补同一默认值。LLM 原始参数只作为本次规范化的瞬时输入,不作为执行或审计真相;确认、取消、任务回填与后续上下文统一读取同一条消息中的规范 `args`。图片参数继续只保存由真实 data key 计算出的 opaque SHA-256 `imageId`;不得为了前端预览把 `args` 中的图片 ID 改写成 `objectKey`、URL 或展示对象,也不得由前端重组或回传一份新的执行参数。
- api-server 内画布 Agent 工具统一实现 object-safe `EditorAgentTool: ToolDyn``validate_args`计价、确认展示、worker job 构建、`format_execute_message` 和结果媒体投影使用统一 JSON 边界;每个具体工具实现负责把 JSON 反序列化为自己的强类型 Args / 结果,并把校验与完成消息格式化转发到 `platform-editor-agent` 中既有的 typed `validate_args` / `format_execute_message`,不得在调用方复制工具规则。`editor_agent_tool(toolName, context)` 是唯一按工具名分派的位置,规划、确认和任务回填只调用返回的 dyn tool;新增工具必须补齐同一个 trait 实现和该工厂分支。LLM builder 的 `.tool(...)` 注册列表仍是独立显式清单,不属于本次动态分派。framework runner 必须在 `ToolCallOutput` 中保留工具返回的结构化 outputrunner 写入 LLM memory 与 api-server 使用规范参数持久化 system text 时统一调用公开的 `format_tool_call_message`,不得丢弃 `TOOL_CALL_PENDING_MESSAGE` 后自行拼另一套“等待确认”输出。
- api-server 内画布 Agent 工具统一实现 object-safe `EditorAgentTool: ToolDyn``validate_args``format_execute_message` 和结果媒体投影使用统一 JSON 边界;只有 `ConfirmedExternalJob` 工具实现计价、确认展示和 worker job 构建,`Immediate` 工具不得被这些方法调用。每个具体工具负责把 JSON 反序列化为自己的强类型 Args / 结果,并把校验与完成消息格式化转发到 `platform-editor-agent` 中既有的 typed `validate_args` / `format_execute_message`,不得在调用方复制工具规则。`editor_agent_tool(toolName, context)` 是唯一按工具名分派的位置,规划、确认和任务回填只调用返回的 dyn tool;新增工具必须补齐同一个 trait 实现和该工厂分支。LLM builder 的 `.tool(...)` 注册列表仍是独立显式清单。framework runner 必须在 `ToolCallOutput` 中保留工具返回的结构化 outputrunner 写入 LLM memory 与 api-server 使用规范参数持久化 system text 时统一调用公开的 `format_tool_call_message`,不得丢弃 `TOOL_CALL_PENDING_MESSAGE` 后自行拼另一套“等待确认”输出。
- `EditorAgentToolCall.displayArgs` 是必填、只读的用户确认展示投影,与 `args` 分离:
- `stringArgs` 保存提示词、比例、清晰度、模型、时长等可展示参数的稳定名称、用户可见标题和值;前端渲染模型字段时复用图片编辑器公共展示名映射,`gemini-3.1-flash-image-preview` 显示为 `nanobanana2``audio1.0` 显示为 `Vidu``chirp-v5` 显示为 `Suno`,视频模型显示现有产品标签,不得改写后端参数真相;
- `imageArgs` 按“目标图片 / 参考图片”等参数分组,每个 `refs` 项包含与规范参数对应的 `imageId`,以及后端从已校验会话上下文解析出的 `objectKey``imageSrc`、可选 `thumbnailSrc` / `label` / `width` / `height`
@@ -104,19 +108,19 @@
## LLM 与计费
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completionsfunction-calling 注册类工具。
- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completionsfunction-calling 显式注册类工具。
- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。面向用户的规划错误使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价诊断;原始错误只记录在后端日志。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。普通 JSON POST 尚未结束只表示 provider request future 仍在等待,不能伪装成已持久化失败。
- 规划 prompt 必须自动带入上一条已完成生成结果的 `latestGeneratedImage` 引用,内容只包含上一轮 generation 的 `toolName` / `imageId` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。
- 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。
- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit-image`,并把 `latestGeneratedImage.imageId` 传入 `edit-image.object_image_id`;不得构造工具 schema 中不存在的 `source_image_id`。除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate-image`
- 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate-image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate-character`,多个图标素材 / 图集才走 `generate-icon-spritesheet`
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408``429``5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;当前八类画布工具只做同步参数校验并返回待确认,因此不会延长正式生成链。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
- 画布 Agent 规划请求使用 Chat Completions 和 1024 `max_tokens`。发送后 120 秒是前端软提示阈值,不是 provider 失败 deadline:若普通 JSON POST 仍 pending,消息流临时显示“仍在处理中,请耐心等待”并继续等待,提示不写入 OSS 消息历史;连接或请求明确失败则立即按正式错误收口。provider 单 attempt 保留 8 分钟 hard timeout;请求发起阶段的 timeout、连接失败、`408``429``5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时使用扣除会话锁和上下文准备后的剩余预算;该 deadline 必须作为 runner 内部 deadline future 参与 completion await,并在每个 tool 开始前、返回后检查,不能用外层 `tokio::timeout` 丢弃整个 prompt future,也不能中途 drop 已开始的工具。工具一旦开始就等待其返回,再按 deadline 携带结果收口;九类收费生成工具只做同步参数校验并返回待确认,`split-elements` 则在该 deadline 内完成确定性拆图。deadline 命中时仍按 `PromptRunError` 返回已经完成的工具结果、提交对应 staged memory 并追加终态错误。该 deadline 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。
- function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。
- runner 失败必须返回显式的 `PromptRunError { error, partial_outputs }`,不得只返回终态错误而丢弃本轮已产生的文本或工具事实。prompt 执行使用 `AgentMemory::begin_staged` 创建行为等价且写入隔离的 `StagedAgentMemory` 事务,限长、摘要、脱敏等 append 规则必须在本轮 completion 前生效;成功或已发生工具活动时必须显式调用 `commit()`,直接 drop staged transaction 表示回滚,不得统一复制成 `VecMemory` 或仅替换 box 冒充持久化提交。本轮无工具活动失败时回滚 staged 用户消息、助手文本和不可解析响应;已有工具活动时在末尾追加 terminal error closure 后提交。外部 drop / abort 若尚无工具活动则回滚并保持原 committed memory;若工具已完成则提交结果与取消闭环,若工具仍在执行则提交“已启动、结果未知”事实与取消闭环,后续必须先 reconcile 再决定是否重试。
- `ToolFailure` 必须以结构化工具失败输出暴露给 harness 调用方:调用方能读取 `kind``retryable``fatal` 和工具返回的原始 `output`;不得把它们压成单一错误字符串。这些字段只提供流程决策与诊断事实,是否重试、如何展示或持久化仍由业务调用方决定。
- api-server 收到带 `partial_outputs` 的终态失败时,必须先按原顺序把其中已成功工具转成 `status=not_completed` 待确认消息并写入同一会话增量,再追加 `ERROR <错误内容>` 终态 system 消息;不得因后续轮次、其它工具或 `max_turns` 失败而吞掉已经执行并返回的工具结果。
- 通用 JSON function-calling 协议、工具 schema 注入、memory / hook、`max_turns` 和“全部工具待确认即结束回合”统一由现役 `platform-agent-harness` 承载;无工具时也必须输出同一 JSON 响应格式。画布角色 prompt、规范展板 / 已有图路由、模型与超时 profile、八类工具、计费、OSS 会话和 external job 编排继续留在 `platform-editor-agent` / `api-server`,不得回流已退役的旧 `platform-agent`
- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。
- **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点;确定性 `split-elements` 免费
- 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。
## Lovart 参照做/不做清单(验收标准)
@@ -165,7 +169,7 @@
2. 存储层(spacetime-module 表 + procedure + migration + spacetime-client + schema check);
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` 与相关文档。
## 第一阶段验收补充
@@ -1,3 +1,7 @@
use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use axum::extract::{Path, State};
@@ -12,6 +16,7 @@ use platform_editor_agent::framework::memory::VecMemory;
use platform_editor_agent::framework::run::{
PromptOutput, PromptRunError, format_tool_call_message,
};
use platform_editor_agent::framework::tool::Tool;
use platform_llm::LlmMessage;
use serde::Serialize;
use serde_json::{Value, json};
@@ -32,18 +37,22 @@ use spacetime_client::{
use crate::api_response::json_success_body;
use crate::auth::AuthenticatedAccessToken;
use crate::editor_agent::tool::{
EditorAgentPrepareJobContext, EditorAgentToolError, editor_agent_tool,
EditorAgentPrepareJobContext, EditorAgentToolError, EditorAgentToolExecutionMode,
editor_agent_tool,
};
use crate::editor_agent::utils::{
IntoImageId, 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,
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, reconcile};
use crate::editor_generation_config::EditorGenerationPricingConfig;
use crate::editor_generation_queue::enqueue_editor_generation_job_with_identity;
use crate::editor_project::{current_utc_micros, map_editor_project_error};
use crate::editor_project::{
EditorIconSpritesheetSliceRequest, current_utc_micros, map_editor_project_error,
split_editor_image_elements_for_owner,
};
use crate::http_error::AppError;
use crate::request_context::RequestContext;
use crate::state::AppState;
@@ -53,11 +62,16 @@ use platform_editor_agent::agent::tools::context::EditorToolContext;
use platform_editor_agent::agent::tools::edit_image::EditImageTool;
use platform_editor_agent::agent::tools::generate_background_music::GenerateBackgroundMusicTool;
use platform_editor_agent::agent::tools::generate_character::GenerateCharacterTool;
use platform_editor_agent::agent::tools::generate_character_animation::GenerateCharacterAnimationTool;
use platform_editor_agent::agent::tools::generate_icon_spritesheet::GenerateIconSpritesheetTool;
use platform_editor_agent::agent::tools::generate_image::GenerateImageTool;
use platform_editor_agent::agent::tools::generate_sound_effect::GenerateSoundEffectTool;
use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool;
use platform_editor_agent::agent::tools::generate_video::GenerateVideoTool;
use platform_editor_agent::agent::tools::split_elements::{
SplitElementsExecutionInput, SplitElementsExecutor, SplitElementsTool, SplitElementsToolError,
SplitElementsToolOutput,
};
use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string};
use tokio::time::Instant;
@@ -67,10 +81,81 @@ const EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE: &str = "规划总时长已达到 18
const EDITOR_AGENT_LLM_UNAVAILABLE_MESSAGE: &str = "美术 Agent 服务暂不可用,请稍后重试";
const EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE: &str = "美术 Agent 生成定价暂不可用,请稍后重试";
#[derive(Clone)]
struct ApiSplitElementsExecutor {
state: AppState,
request_context: RequestContext,
owner_user_id: String,
project_id: String,
results: Arc<tokio::sync::Mutex<HashMap<String, SplitElementsToolOutput>>>,
}
impl SplitElementsExecutor for ApiSplitElementsExecutor {
fn execute(
&self,
input: SplitElementsExecutionInput,
) -> Pin<
Box<
dyn Future<Output = Result<SplitElementsToolOutput, SplitElementsToolError>>
+ Send
+ '_,
>,
> {
let state = self.state.clone();
let request_context = self.request_context.clone();
let owner_user_id = self.owner_user_id.clone();
let project_id = self.project_id.clone();
let results = self.results.clone();
Box::pin(async move {
let source_resource_id = input.source_resource_id;
if let Some(result) = results.lock().await.get(&source_resource_id).cloned() {
return Ok(result);
}
let project = state
.spacetime_client()
.get_editor_project(EditorProjectGetRecordInput {
project_id: project_id.clone(),
owner_user_id: owner_user_id.clone(),
})
.await
.map_err(|error| SplitElementsToolError::ExecutionFailed(error.to_string()))?;
let canvas_completion =
build_editor_agent_canvas_completion(&project, SplitElementsTool::NAME, "拆分元素");
let result = split_editor_image_elements_for_owner(
&state,
owner_user_id.as_str(),
EditorIconSpritesheetSliceRequest {
project_id,
source_resource_id: source_resource_id.clone(),
asset_folder_id: Some("project".to_string()),
canvas_completion,
},
)
.await
.map_err(|error| {
tracing::warn!(
request_id = %request_context.request_id(),
error = %error,
"画布 Agent 即时拆图失败"
);
SplitElementsToolError::ExecutionFailed(error.body_text())
})?;
let output = SplitElementsToolOutput {
images: result.editor_agent_images(),
};
results
.lock()
.await
.insert(source_resource_id, output.clone());
Ok(output)
})
}
}
pub async fn editor_agent_message(
State(state): State<AppState>,
Path(conversation_id): Path<String>,
Extension(_request_context): Extension<RequestContext>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Json(payload): Json<EditorAgentMessageRequest>,
) -> Result<Json<EditorAgentMessageResponse>, AppError> {
@@ -215,26 +300,15 @@ pub async fn editor_agent_message(
.await;
};
let llm_client = llm_client.clone();
let pricing = match state.editor_generation_pricing().await {
Ok(pricing) => pricing,
Err(error) => {
tracing::warn!(
conversation_id = %conversation.conversation_id,
error = %error,
"读取美术 Agent 生成定价失败"
);
return persist_editor_agent_planning_error(
&state,
&conversation,
&mut document,
conversation_summary,
EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE,
)
.await;
}
};
let memory = VecMemory::new(previous_messages);
let split_elements_executor: Arc<dyn SplitElementsExecutor> =
Arc::new(ApiSplitElementsExecutor {
state: state.clone(),
request_context: request_context.clone(),
owner_user_id: conversation.owner_user_id.clone(),
project_id: conversation.project_id.clone(),
results: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
});
let mut agent = LlmChatAgentBuilder::new()
.with_client(llm_client)
@@ -248,6 +322,9 @@ pub async fn editor_agent_message(
.tool(GenerateCharacterTool {
context: tool_context.clone(),
})
.tool(GenerateCharacterAnimationTool {
context: tool_context.clone(),
})
.tool(GenerateIconSpritesheetTool {
context: tool_context.clone(),
})
@@ -259,6 +336,10 @@ pub async fn editor_agent_message(
.tool(GenerateUiDesignTool {
context: tool_context.clone(),
})
.tool(SplitElementsTool::new(
tool_context.clone(),
split_elements_executor,
))
.max_turns(3)
.memory(memory)
.build();
@@ -276,12 +357,34 @@ pub async fn editor_agent_message(
let assistant_now = now_rfc3339();
let (outputs, terminal_error) = split_prompt_result(agent_result);
let pricing = if prompt_outputs_require_pricing(outputs.as_slice(), &tool_context) {
match state.editor_generation_pricing().await {
Ok(pricing) => Some(pricing),
Err(error) => {
tracing::warn!(
conversation_id = %conversation.conversation_id,
error = %error,
"读取美术 Agent 生成定价失败"
);
return persist_editor_agent_planning_error(
&state,
&conversation,
&mut document,
conversation_summary,
EDITOR_AGENT_PRICING_UNAVAILABLE_MESSAGE,
)
.await;
}
}
} else {
None
};
match build_delta_messages(
outputs,
&assistant_now,
document.messages.len(),
&tool_context,
&pricing,
pricing.as_ref(),
) {
Err(error) => {
persist_editor_agent_planning_error(
@@ -329,6 +432,20 @@ fn split_prompt_result(
}
}
fn prompt_outputs_require_pricing(
outputs: &[PromptOutput],
tool_context: &EditorToolContext,
) -> bool {
outputs.iter().any(|output| {
let PromptOutput::Tool(tool_output) = output else {
return false;
};
editor_agent_tool(tool_output.tool_call.name.as_str(), tool_context).is_some_and(|tool| {
tool.execution_mode() == EditorAgentToolExecutionMode::ConfirmedExternalJob
})
})
}
fn append_terminal_error(
delta_messages: &mut Vec<EditorAgentMessage>,
messages_offset: usize,
@@ -438,7 +555,10 @@ fn editor_agent_attachment_requests_match(
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use super::*;
use platform_editor_agent::agent::asset::{ImageId, ImageMetadata};
use platform_editor_agent::framework::run::ToolCallOutput;
use platform_editor_agent::framework::tool::{Tool, ToolCall};
use shared_contracts::editor_agent::{EditorAgentAttachmentRef, EditorAgentAttachmentSource};
@@ -456,6 +576,26 @@ mod tests {
}
}
fn split_tool_context() -> EditorToolContext {
EditorToolContext {
images: HashMap::from([(
ImageId {
id: "ui-image".to_string(),
},
ImageMetadata {
data_key: "generated/ui.png".to_string(),
resource_id: Some("resource-ui".to_string()),
image_src: "/generated/ui.png".to_string(),
object_key: Some("generated/ui.png".to_string()),
thumbnail_src: None,
label: Some("UI".to_string()),
width: Some(1024),
height: Some(1024),
},
)]),
}
}
#[test]
fn validates_editor_agent_message_before_normalizing_attachments() {
let empty_payload = EditorAgentMessageRequest {
@@ -585,7 +725,7 @@ mod tests {
"2026-07-23T00:00:00Z",
0,
&EditorToolContext::default(),
&pricing,
Some(&pricing),
)
.expect("pending tool message should build");
@@ -602,6 +742,42 @@ mod tests {
assert!(!messages[0].text.contains("等待用户确认"));
}
#[test]
fn immediate_tool_persists_completed_assets_without_pricing_or_confirmation() {
let context = split_tool_context();
let outputs = vec![PromptOutput::Tool(ToolCallOutput {
tool_call: ToolCall {
id: "tool-call-1".to_string(),
name: SplitElementsTool::NAME.to_string(),
args: json!({ "source_image_id": "ui-image" }),
},
output: json!({
"images": [{
"resourceId": "resource-slice-1",
"objectKey": "generated/slice-1.png",
"assetObjectId": "asset-object-slice-1",
"imageSrc": "/generated/slice-1.png",
"width": 64,
"height": 64
}]
}),
})];
assert!(!prompt_outputs_require_pricing(&outputs, &context));
let messages = build_delta_messages(outputs, "2026-07-29T00:00:00Z", 0, &context, None)
.expect("immediate split result should not need pricing");
let tool_call = messages[0]
.tool_call
.as_ref()
.expect("completed split should retain the tool call");
assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed);
assert!(tool_call.external_job_id.is_none());
assert_eq!(tool_call.display_args.extras.price_mud_points, 0);
assert_eq!(tool_call.images.len(), 1);
assert!(messages[0].text.contains("split 1 elements"));
}
#[test]
fn pending_video_with_null_defaults_persists_and_displays_concrete_values() {
let pricing =
@@ -625,7 +801,7 @@ mod tests {
"2026-07-23T00:00:00Z",
0,
&EditorToolContext::default(),
&pricing,
Some(&pricing),
)
.expect("pending video with null defaults should build");
@@ -693,9 +869,9 @@ mod tests {
"2026-07-28T00:00:00Z",
4,
&EditorToolContext::default(),
&EditorGenerationPricingConfig {
Some(&EditorGenerationPricingConfig {
models: Default::default(),
},
}),
)
.expect("successful partial tool output should still build a confirmation card");
append_terminal_error(&mut delta_messages, 4, terminal_error);
@@ -716,7 +892,7 @@ fn build_delta_messages(
created_at: &str,
messages_offset: usize,
tool_context: &EditorToolContext,
pricing: &EditorGenerationPricingConfig,
pricing: Option<&EditorGenerationPricingConfig>,
) -> Result<Vec<EditorAgentMessage>, PromptError> {
let mut messages = Vec::with_capacity(outputs.len());
@@ -745,12 +921,43 @@ fn build_delta_messages(
let normalized_args = tool
.validate_args(&tco.tool_call.args)
.map_err(|error| error.into_prompt_error(tool_name.as_str()))?;
let display_args = tool
.build_display_args(&normalized_args, pricing)
.map_err(|error| error.into_prompt_error(tool_name.as_str()))?;
let text =
format_tool_call_message(tool_name.as_str(), &normalized_args, &tco.output)?;
let (status, display_args, text, assets) = match tool.execution_mode() {
EditorAgentToolExecutionMode::ConfirmedExternalJob => {
let pricing = pricing.ok_or_else(|| {
PromptError::InternalError(format!(
"pricing is required for editor agent tool {tool_name}"
))
})?;
let display_args = tool
.build_display_args(&normalized_args, pricing)
.map_err(|error| error.into_prompt_error(tool_name.as_str()))?;
let text = format_tool_call_message(
tool_name.as_str(),
&normalized_args,
&tco.output,
)?;
(
EditorAgentToolCallStatus::NotCompleted,
display_args,
text,
Default::default(),
)
}
EditorAgentToolExecutionMode::Immediate => {
let text = tool
.format_execute_message(&normalized_args, &tco.output)
.map_err(|error| error.into_prompt_error(tool_name.as_str()))?;
let assets = tool
.result_assets(&tco.output)
.map_err(|error| error.into_prompt_error(tool_name.as_str()))?;
(
EditorAgentToolCallStatus::Completed,
Default::default(),
text,
assets,
)
}
};
messages.push(EditorAgentMessage {
id: absolute_idx,
client_message_id: None,
@@ -759,13 +966,13 @@ fn build_delta_messages(
attachments: Vec::new(),
tool_call: Some(EditorAgentToolCall {
tool_name,
status: EditorAgentToolCallStatus::NotCompleted,
status,
args: normalized_args,
display_args,
external_job_id: None,
images: Vec::new(),
videos: Vec::new(),
audios: Vec::new(),
images: assets.images.unwrap_or_default(),
videos: assets.videos.unwrap_or_default(),
audios: assets.audios.unwrap_or_default(),
error: None,
}),
created_at: created_at.to_string(),
@@ -1048,6 +1255,11 @@ pub async fn confirm_editor_agent_tool_call(
let context = context::build_tool_context(&document);
let tool = editor_agent_tool(tool_name.as_str(), &context)
.ok_or_else(|| editor_agent_bad_request(format!("unsupported tool: {tool_name}")))?;
if tool.execution_mode() != EditorAgentToolExecutionMode::ConfirmedExternalJob {
return Err(editor_agent_bad_request(format!(
"tool does not require confirmation: {tool_name}"
)));
}
let normalized_args = tool
.validate_args(&tool_args)
.map_err(map_editor_agent_tool_app_error)?;
@@ -1,7 +1,9 @@
use crate::editor_agent::utils::IntoDataKey;
use platform_editor_agent::agent::asset::{ImageId, ImageMetadata};
use platform_editor_agent::agent::tools::context::EditorToolContext;
use shared_contracts::editor_agent::EditorAgentConversationMessagesDocument;
use shared_contracts::editor_agent::{
EditorAgentAttachmentSource, EditorAgentConversationMessagesDocument,
};
use std::collections::HashMap;
pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) -> EditorToolContext {
@@ -13,6 +15,8 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) ->
let image_id = ImageId::from_data_key(&data_key);
let metadata = ImageMetadata {
data_key,
resource_id: (a.source == EditorAgentAttachmentSource::CanvasResource)
.then(|| a.reference_id.clone()),
image_src: a.image_src.clone(),
object_key: a.object_key.clone(),
thumbnail_src: a.thumbnail_src.clone(),
@@ -29,6 +33,7 @@ pub fn build_tool_context(document: &EditorAgentConversationMessagesDocument) ->
let image_id = ImageId::from_data_key(&data_key);
let metadata = ImageMetadata {
data_key,
resource_id: img.resource_id.clone(),
image_src: img.image_src.clone(),
object_key: img.object_key.clone(),
thumbnail_src: img.thumbnail_src.clone(),
@@ -91,6 +96,7 @@ mod tests {
.expect("latest image metadata should be present");
assert_eq!(metadata.data_key, "generated/reference.png");
assert_eq!(metadata.resource_id.as_deref(), Some("resource-1"));
assert_eq!(metadata.image_src, "/api/assets/read/current.png");
assert_eq!(metadata.label.as_deref(), Some("最新名称"));
assert_eq!(metadata.width, Some(640));
@@ -237,6 +237,35 @@ mod tests {
.expect("pending tool message should deserialize")
}
fn pending_character_animation_message() -> EditorAgentMessage {
serde_json::from_value(json!({
"id": 2,
"role": "system",
"text": "waiting",
"attachments": [],
"toolCall": {
"toolName": "generate-character-animation",
"status": "not_completed",
"args": {
"source_image_id": "character-image",
"prompt_text": "循环行走",
"resolution": "480p",
"ratio": "same",
"duration_seconds": 4
},
"displayArgs": {
"stringArgs": [],
"imageArgs": [],
"extras": { "priceMudPoints": 40 }
},
"externalJobId": "job-animation-1",
"images": []
},
"createdAt": "2026-07-29T00:00:00Z"
}))
.expect("pending animation message should deserialize")
}
#[test]
fn terminal_result_reconcile_retries_three_times_after_the_initial_attempt() {
assert!(should_retry_result_reconcile(0));
@@ -245,6 +274,49 @@ mod tests {
assert!(!should_retry_result_reconcile(3));
}
#[test]
fn completed_character_animation_reconciles_to_its_first_sequence_frame() {
let mut message = pending_character_animation_message();
let payload = json!({
"editor-agent-tool-call-result": {
"ok": true,
"taskId": "task-animation-1",
"model": "seedance2.0-fast",
"prompt": "循环行走",
"previewVideoPath": "/generated/animation-preview.mp4",
"frames": [{
"frameIndex": 0,
"imageSrc": "/generated/animation-frame-000.png",
"width": 480,
"height": 640
}],
"frameCount": 32,
"durationSeconds": 4,
"frameWidth": 480,
"frameHeight": 640,
"fps": 8,
"priceMudPoints": 40
}
})
.to_string();
reconcile_completed_editor_agent_tool_call(&mut message, Some(payload.as_str()))
.expect("animation result should reconcile");
let tool_call = message.tool_call.expect("tool call should remain");
assert_eq!(tool_call.status, EditorAgentToolCallStatus::Completed);
assert_eq!(tool_call.images.len(), 1);
assert_eq!(
tool_call.images[0].object_key.as_deref(),
Some("generated/animation-frame-000.png")
);
assert!(
message
.text
.contains("generated 32 character animation frames")
);
}
#[test]
fn exhausted_fatal_result_reconcile_marks_the_tool_call_failed() {
let mut message = pending_tool_message();
File diff suppressed because it is too large Load Diff
@@ -606,6 +606,15 @@ pub fn build_editor_agent_canvas_completion(
title: &str,
) -> EditorCanvasGenerationCompletionPayload {
let (width, height) = editor_agent_tool_display_size(tool_name);
build_editor_agent_canvas_completion_with_size(project, title, width, height)
}
pub fn build_editor_agent_canvas_completion_with_size(
project: &EditorProjectRecord,
title: &str,
width: f64,
height: f64,
) -> EditorCanvasGenerationCompletionPayload {
let (x, y) = next_editor_agent_canvas_position(project.layers.clone(), width, height);
EditorCanvasGenerationCompletionPayload {
dialog_id: None,
@@ -32,6 +32,7 @@ use shared_contracts::assets::{
EditorCanvasGenerationCompletionPayload as EditorCanvasGenerationCompletionRequest,
EditorCanvasGenerationPlaceholderPayload,
};
use shared_contracts::editor_agent::EditorAgentGeneratedImage;
use shared_kernel::build_prefixed_uuid_id;
use spacetime_client::{
EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput,
@@ -743,6 +744,32 @@ pub struct EditorIconSpritesheetSliceResponse {
project: EditorProjectPayload,
}
impl EditorIconSpritesheetSliceResponse {
pub(crate) fn editor_agent_images(&self) -> Vec<EditorAgentGeneratedImage> {
self.icon_image_srcs
.iter()
.map(|image| EditorAgentGeneratedImage {
resource_id: image
.resource
.as_ref()
.map(|resource| resource.resource_id.clone()),
object_key: image
.resource
.as_ref()
.and_then(|resource| resource.object_key.clone()),
asset_object_id: image
.resource
.as_ref()
.and_then(|resource| resource.asset_object_id.clone()),
image_src: image.image_src.clone(),
thumbnail_src: None,
width: Some(image.width),
height: Some(image.height),
})
.collect()
}
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct EditorProjectPayload {
@@ -4448,6 +4475,17 @@ pub async fn split_editor_icon_spritesheet(
Json(payload): Json<EditorIconSpritesheetSliceRequest>,
) -> Result<Json<Value>, AppError> {
let owner_user_id = authenticated.claims().user_id().to_string();
let response =
split_editor_image_elements_for_owner(&state, owner_user_id.as_str(), payload).await?;
Ok(json_success_body(Some(&request_context), response))
}
pub(crate) async fn split_editor_image_elements_for_owner(
state: &AppState,
owner_user_id: &str,
payload: EditorIconSpritesheetSliceRequest,
) -> Result<EditorIconSpritesheetSliceResponse, AppError> {
let owner_user_id = owner_user_id.to_string();
let project_id = payload.project_id.trim().to_string();
let source_resource_id = payload.source_resource_id.trim().to_string();
if project_id.is_empty() || source_resource_id.is_empty() {
@@ -4478,11 +4516,11 @@ pub async fn split_editor_icon_spritesheet(
"message": "未找到要拆分的图集资源。",
}))
})?;
if source_resource.asset_kind.as_deref() != Some("icon-spritesheet") {
if !is_editor_element_split_source_kind(source_resource.asset_kind.as_deref()) {
return Err(
AppError::from_status(StatusCode::UNPROCESSABLE_ENTITY).with_details(json!({
"provider": "editor-icon-spritesheet-slicing",
"message": "只有图集素材可以使用拆分功能。",
"message": "只有图集或 UI 设计素材可以使用拆分功能。",
})),
);
}
@@ -4572,13 +4610,14 @@ pub async fn split_editor_icon_spritesheet(
}))
})?;
Ok(json_success_body(
Some(&request_context),
EditorIconSpritesheetSliceResponse {
icon_image_srcs,
project,
},
))
Ok(EditorIconSpritesheetSliceResponse {
icon_image_srcs,
project,
})
}
fn is_editor_element_split_source_kind(asset_kind: Option<&str>) -> bool {
matches!(asset_kind, Some("icon-spritesheet" | "ui-design"))
}
fn validate_editor_icon_spritesheet_manual_source(
@@ -10715,6 +10754,16 @@ mod tests {
assert_eq!(request.canvas_completion.placeholder.width, 360.0);
}
#[test]
fn deterministic_element_split_accepts_ui_designs_and_spritesheets_only() {
assert!(is_editor_element_split_source_kind(Some("ui-design")));
assert!(is_editor_element_split_source_kind(Some(
"icon-spritesheet"
)));
assert!(!is_editor_element_split_source_kind(Some("character")));
assert!(!is_editor_element_split_source_kind(None));
}
#[test]
fn editor_manual_atlas_split_lookup_keeps_original_provenance_after_project_copy() {
let mut copied_resource = editor_project_resource_for_canvas_test(
@@ -39,6 +39,8 @@ impl Display for ImageId {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageMetadata {
pub data_key: String,
#[serde(default)]
pub resource_id: Option<String>,
pub image_src: String,
pub object_key: Option<String>,
pub thumbnail_src: Option<String>,
@@ -18,9 +18,11 @@ pub fn editor_agent_system_prompt() -> &'static str {
r#"
* image_id 字符串格式为 sha256:*。
* 用户引用或上传图片时,system message 会提供对应 image_id;规划相关工具调用时必须使用这些 image_id 或上下文中已有的 image_id。
* 待确认工具必须由用户在界面点击确认按钮执行。用户只在对话中回复“确认”或“可以”时,应提示其点击确认按钮,不得重复提交同一待确认工具。
* 生图、改图、角色动作和音视频生成等待确认工具必须由用户在界面点击确认按钮执行。用户只在对话中回复“确认”或“可以”时,应提示其点击确认按钮,不得重复提交同一待确认工具。
* split-elements 是无需确认、无需泥点的确定性拆图工具;用户要求拆分已有 UI 或图集中的独立元素时直接调用,不要让用户点击确认。
* split-elements 成功返回后不得在同一回合对同一 source_image_id 重复调用,直接总结已经拆出的结果。
* 用户所说的规范图、参考图和已生成图片都可以作为 image_id 图片上下文。
* 实际生成工具由后端按模型定价扣泥点,不能承诺免费生成。
* 实际生成工具由后端按模型定价扣泥点,不能承诺免费生成split-elements 不调用生成模型,不计费
你是 Genarrative 图片画布 Agent,只负责帮助用户理解、规划和触发画布生成工具。对话回复要简短。
"#
@@ -110,6 +112,14 @@ pub fn generate_ui_design_tool_description() -> String {
)
}
pub fn generate_character_animation_tool_description() -> String {
"用于让已有角色图片生成角色动作或动画序列帧。必须提供已有角色图片的 source_image_id 和清晰的动作描述;这是生成模型工具,需要用户确认并按角色动作模型计费。不要用于生成新的角色形象。".to_string()
}
pub fn split_elements_tool_description() -> String {
"用于把画布中已有的 UI 设计图或图集按透明连通区域确定性拆成独立元素。必须提供当前画布项目资源的 source_image_id;本工具不调用生成模型、不消耗泥点、无需用户确认。不要用它生成新 UI,也不要把需要重新绘制或智能补全的素材提取请求误判为本工具。".to_string()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -141,12 +151,17 @@ mod tests {
let character = generate_character_tool_description();
let icons = generate_icon_spritesheet_tool_description();
let ui = generate_ui_design_tool_description();
let animation = generate_character_animation_tool_description();
let split = split_elements_tool_description();
assert!(generate_image.contains(SPEC_BOARD_CONTENT_POLICY));
assert!(edit_image.contains("上一张"));
assert!(character.contains(SPEC_BOARD_ROUTE_POLICY));
assert!(icons.contains(SPEC_BOARD_ROUTE_POLICY));
assert!(ui.contains(SPEC_BOARD_ROUTE_POLICY));
assert!(animation.contains("用户确认"));
assert!(split.contains("不消耗泥点"));
assert!(split.contains("无需用户确认"));
for description in [&generate_image, &edit_image, &character, &ui] {
assert!(description.contains(EXISTING_IMAGE_EDIT_POLICY));
}
@@ -257,6 +257,7 @@ mod tests {
reference_image_id.clone(),
ImageMetadata {
data_key: "asset://reference-image".to_string(),
resource_id: Some("resource-reference-image".to_string()),
image_src: "asset://reference-image".to_string(),
object_key: None,
thumbnail_src: None,
@@ -2,12 +2,14 @@ pub mod context;
pub mod edit_image;
pub mod generate_background_music;
pub mod generate_character;
pub mod generate_character_animation;
pub mod generate_icon_spritesheet;
pub mod generate_image;
pub mod generate_sound_effect;
pub mod generate_ui_design;
pub mod generate_video;
mod image_generation_options;
pub mod split_elements;
#[cfg(test)]
mod tests {
@@ -17,6 +19,9 @@ mod tests {
GenerateBackgroundMusicTool, GenerateBackgroundMusicToolArgs,
};
use super::generate_character::{GenerateCharacterTool, GenerateCharacterToolArgs};
use super::generate_character_animation::{
EDITOR_AGENT_CHARACTER_ANIMATION_MODEL, GenerateCharacterAnimationToolArgs,
};
use super::generate_icon_spritesheet::{
GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs,
};
@@ -44,6 +49,12 @@ mod tests {
"prompt": "生成冒险者角色"
}))
.expect("character args should deserialize");
let character_animation: GenerateCharacterAnimationToolArgs =
serde_json::from_value(json!({
"source_image_id": "image-1",
"prompt_text": "循环行走"
}))
.expect("character animation args should deserialize");
let ui_design: GenerateUiDesignToolArgs = serde_json::from_value(json!({
"prompt": "生成游戏主界面"
}))
@@ -73,6 +84,11 @@ mod tests {
assert_eq!(character.model, NANOBANANA_2_MODEL);
assert_eq!(character.aspect_ratio, "1:1");
assert_eq!(character.image_size, "1K");
assert_eq!(character_animation.resolution, "480p");
assert_eq!(character_animation.ratio, "same");
assert_eq!(character_animation.duration_seconds, 4);
assert_eq!(character_animation.frame_count(), 32);
assert_eq!(EDITOR_AGENT_CHARACTER_ANIMATION_MODEL, "seedance2.0-fast");
assert_eq!(ui_design.model, GPT_IMAGE_2_MODEL);
assert_eq!(ui_design.aspect_ratio, "1:1");
assert_eq!(ui_design.image_size, "1K");
@@ -5,6 +5,9 @@ export function editorAgentToolLabel(toolName: string) {
if (toolName === 'generate-character') {
return '生成角色';
}
if (toolName === 'generate-character-animation') {
return '生成角色动作';
}
if (toolName === 'generate-icon-spritesheet') {
return '生成图标';
}
@@ -20,5 +23,8 @@ export function editorAgentToolLabel(toolName: string) {
if (toolName === 'generate-background-music') {
return '生成背景音乐';
}
if (toolName === 'split-elements') {
return '拆分元素';
}
return '生成图片';
}
@@ -178,6 +178,67 @@ describe('useEditorAgentConversation', () => {
);
});
it('refreshes the canvas for a completed immediate tool without an external job', async () => {
const client = createClient();
vi.mocked(client.sendMessage).mockResolvedValueOnce({
conversation: {
conversationId: 'conversation-1',
projectId: 'project-1',
title: '拆分 UI 元素',
updatedAt: '2026-07-29T00:00:01.000Z',
},
deltaMessages: [
{
id: 1,
role: 'system',
text: 'split completed',
attachments: [],
toolCall: {
toolName: 'split-elements',
status: 'completed',
externalJobId: null,
args: { source_image_id: 'ui-image' },
displayArgs: {
stringArgs: [],
imageArgs: [],
extras: { priceMudPoints: 0 },
},
images: [
{
resourceId: 'resource-slice-1',
imageSrc: '/generated/slice-1.png',
width: 64,
height: 64,
},
],
error: null,
},
createdAt: '2026-07-29T00:00:01.000Z',
},
],
errorMessage: null,
});
const onCanvasRefreshRequested = vi.fn();
const { result } = renderHook(() =>
useEditorAgentConversation({
projectId: 'project-1',
client,
onCanvasRefreshRequested,
}),
);
await waitFor(() => {
expect(result.current.activeConversationId).toBe('conversation-1');
});
await act(async () => {
await result.current.sendMessage('拆分这张 UI 图');
});
expect(onCanvasRefreshRequested).toHaveBeenCalledTimes(1);
expect(result.current.messages[1]?.toolCall?.externalJobId).toBeNull();
expect(result.current.messages[1]?.toolCall?.status).toBe('completed');
});
it('keeps the latest conversation when detail responses arrive out of order', async () => {
const client = createClient();
let resolveConversation2!: (detail: EditorAgentConversationDetail) => void;
@@ -417,7 +417,7 @@ export function useEditorAgentConversation({
nextMessages.some((message) => {
const toolCall = message.toolCall;
return Boolean(
toolCall?.externalJobId &&
toolCall?.status === 'completed' &&
(toolCall.images.length > 0 ||
(toolCall.videos?.length ?? 0) > 0 ||
(toolCall.audios?.length ?? 0) > 0),