diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 667c04b70..8abb5fd13 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -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` 的透明图。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index a02142e26..6620ce841 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -64,6 +64,7 @@ - 登录态上传和生成结果必须先落 OSS / asset object,再向 `editor_project_resource` / `editor_asset` 写入轻量 `imageSrc: "/"`、`objectKey` 和 `assetObjectId`;未登录演示态可以在内存里使用 Data URL 预览,但项目、素材库、项目资源和 `editor_canvas.layers_json` 不得写入 `data:image/*`、`data:video/*`、`data:audio/*` 或 `blob:`。旧数据读取时如果已有 `objectKey`,`imageSrc` 归一成 `/`;没有 `objectKey` 的旧 Data URL 需要走修复上传并回写轻量引用。裁扩在项目上下文中虽然由前端 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` 或资源 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。 +- 画布 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 当作会话长期事实。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 7937809cb..d3c91ed03 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -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 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index c96c427c2..748638f90 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -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` 中保留工具返回的结构化 output;runner 写入 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` 中保留工具返回的结构化 output;runner 写入 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 Completions;function-calling 注册八类工具。 +- 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-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` 与相关文档。 ## 第一阶段验收补充 diff --git a/server-rs/crates/api-server/src/editor_agent/api.rs b/server-rs/crates/api-server/src/editor_agent/api.rs index c09323825..b26793c2d 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -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>>, +} + +impl SplitElementsExecutor for ApiSplitElementsExecutor { + fn execute( + &self, + input: SplitElementsExecutionInput, + ) -> Pin< + Box< + dyn Future> + + 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, Path(conversation_id): Path, - Extension(_request_context): Extension, + Extension(request_context): Extension, Extension(authenticated): Extension, Json(payload): Json, ) -> Result, 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 = + 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, 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, 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)?; diff --git a/server-rs/crates/api-server/src/editor_agent/context.rs b/server-rs/crates/api-server/src/editor_agent/context.rs index 8b86d0cb9..f5a06f0f0 100644 --- a/server-rs/crates/api-server/src/editor_agent/context.rs +++ b/server-rs/crates/api-server/src/editor_agent/context.rs @@ -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)); diff --git a/server-rs/crates/api-server/src/editor_agent/reconcile.rs b/server-rs/crates/api-server/src/editor_agent/reconcile.rs index 27a3bcec0..985d255e1 100644 --- a/server-rs/crates/api-server/src/editor_agent/reconcile.rs +++ b/server-rs/crates/api-server/src/editor_agent/reconcile.rs @@ -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(); diff --git a/server-rs/crates/api-server/src/editor_agent/tool.rs b/server-rs/crates/api-server/src/editor_agent/tool.rs index b1c21b879..d394196f0 100644 --- a/server-rs/crates/api-server/src/editor_agent/tool.rs +++ b/server-rs/crates/api-server/src/editor_agent/tool.rs @@ -11,6 +11,10 @@ use platform_editor_agent::agent::tools::generate_background_music::{ use platform_editor_agent::agent::tools::generate_character::{ GenerateCharacterTool, GenerateCharacterToolArgs, }; +use platform_editor_agent::agent::tools::generate_character_animation::{ + EDITOR_AGENT_CHARACTER_ANIMATION_MODEL, GenerateCharacterAnimationTool, + GenerateCharacterAnimationToolArgs, +}; use platform_editor_agent::agent::tools::generate_icon_spritesheet::{ EditorIconSpritesheetResult, GenerateIconSpritesheetTool, GenerateIconSpritesheetToolArgs, }; @@ -26,6 +30,9 @@ use platform_editor_agent::agent::tools::generate_ui_design::{ use platform_editor_agent::agent::tools::generate_video::{ GenerateVideoTool, GenerateVideoToolArgs, }; +use platform_editor_agent::agent::tools::split_elements::{ + SplitElementsTool, SplitElementsToolArgs, SplitElementsToolOutput, +}; use platform_editor_agent::framework::error::PromptError; use platform_editor_agent::framework::tool::{Tool, ToolDyn, null_tool_args_as_missing}; use platform_image::GPT_IMAGE_2_MODEL; @@ -34,6 +41,7 @@ use serde::de::DeserializeOwned; use serde_json::{Value, json}; use shared_contracts::assets::{ EditorAudioGenerateResponse, EditorBackgroundMusicGenerateRequest, + EditorCharacterAnimationGenerateRequest, EditorCharacterAnimationGenerateResponse, EditorSoundEffectGenerateRequest, EditorVideoGenerateRequest, EditorVideoGenerateResponse, }; use shared_contracts::editor_agent::{ @@ -46,12 +54,15 @@ use crate::editor_agent::display_args::{ push_image_display_arg, push_image_generation_display_args, push_string_display_arg, }; use crate::editor_agent::resp_to_asset; -use crate::editor_agent::utils::build_editor_agent_canvas_completion; +use crate::editor_agent::utils::{ + build_editor_agent_canvas_completion, build_editor_agent_canvas_completion_with_size, +}; 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, + EDITOR_BACKGROUND_MUSIC_GENERATION_JOB_KIND, EDITOR_CHARACTER_ANIMATION_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, }; use crate::editor_project::{ EditorIconSpritesheetGenerationRequest, EditorImageEditRequest, EditorImageGenerationRequest, @@ -72,6 +83,12 @@ impl EditorAgentToolError { Self::Internal(message.into()) } + fn unsupported_operation(tool_name: &str, operation: &str) -> Self { + Self::internal(format!( + "tool {tool_name} does not support editor agent operation {operation}" + )) + } + pub(crate) fn into_prompt_error(self, tool_name: &str) -> PromptError { match self { Self::InvalidArgs(message) => { @@ -128,30 +145,57 @@ pub(crate) struct EditorAgentResultAssets { pub(crate) audios: Option>, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum EditorAgentToolExecutionMode { + ConfirmedExternalJob, + Immediate, +} /// api-server 侧统一的画布 Agent 工具能力。 /// /// 所有方法都使用统一 JSON 边界以保持 trait object-safe;每个具体实现负责把 JSON /// 反序列化为真实 Tool Args / 执行结果,并转发到具体工具已有的强类型行为。 pub(crate) trait EditorAgentTool: ToolDyn { + fn execution_mode(&self) -> EditorAgentToolExecutionMode { + if ToolDyn::requires_user_confirmation(self) { + EditorAgentToolExecutionMode::ConfirmedExternalJob + } else { + EditorAgentToolExecutionMode::Immediate + } + } fn validate_args(&self, args: &Value) -> Result; fn pricing( &self, - pricing: &EditorGenerationPricingConfig, - args: &Value, - ) -> Result; + _pricing: &EditorGenerationPricingConfig, + _args: &Value, + ) -> Result { + Err(EditorAgentToolError::unsupported_operation( + self.tool_name(), + "pricing", + )) + } fn build_display_args( &self, - args: &Value, - pricing: &EditorGenerationPricingConfig, - ) -> Result; + _args: &Value, + _pricing: &EditorGenerationPricingConfig, + ) -> Result { + Err(EditorAgentToolError::unsupported_operation( + self.tool_name(), + "confirmation display", + )) + } fn prepare_job( &self, - args: &Value, - context: &EditorAgentPrepareJobContext<'_>, - ) -> Result; + _args: &Value, + _context: &EditorAgentPrepareJobContext<'_>, + ) -> Result { + Err(EditorAgentToolError::unsupported_operation( + self.tool_name(), + "external job preparation", + )) + } fn format_execute_message( &self, @@ -176,6 +220,9 @@ pub(crate) fn editor_agent_tool( GenerateCharacterTool::NAME => Some(Box::new(GenerateCharacterTool { context: context.clone(), })), + GenerateCharacterAnimationTool::NAME => Some(Box::new(GenerateCharacterAnimationTool { + context: context.clone(), + })), GenerateUiDesignTool::NAME => Some(Box::new(GenerateUiDesignTool { context: context.clone(), })), @@ -190,6 +237,7 @@ pub(crate) fn editor_agent_tool( })), GenerateSoundEffectTool::NAME => Some(Box::new(GenerateSoundEffectTool)), GenerateBackgroundMusicTool::NAME => Some(Box::new(GenerateBackgroundMusicTool)), + SplitElementsTool::NAME => Some(Box::new(SplitElementsTool::for_contract(context.clone()))), _ => None, } } @@ -274,6 +322,23 @@ fn editor_agent_image_mud_points( pricing.image_generation_mud_points(kind, Some(model), image_size) } +fn character_animation_canvas_display_size( + source_width: u32, + source_height: u32, + ratio: &str, +) -> (f64, f64) { + let (ratio_width, ratio_height) = match ratio { + "1:1" => (1.0, 1.0), + "4:3" => (4.0, 3.0), + "16:9" => (16.0, 9.0), + "9:16" => (9.0, 16.0), + "3:4" => (3.0, 4.0), + _ => (f64::from(source_width), f64::from(source_height)), + }; + let scale = 512.0 / ratio_width.max(ratio_height).max(1.0); + (ratio_width * scale, ratio_height * scale) +} + impl EditorAgentTool for GenerateImageTool { fn validate_args(&self, args: &Value) -> Result { let args: GenerateImageToolArgs = parse_invalid_args(Self::NAME, args)?; @@ -489,6 +554,176 @@ impl EditorAgentTool for GenerateCharacterTool { } } +impl EditorAgentTool for GenerateCharacterAnimationTool { + fn validate_args(&self, args: &Value) -> Result { + let args: GenerateCharacterAnimationToolArgs = parse_invalid_args(Self::NAME, args)?; + GenerateCharacterAnimationTool::validate_args(self, &args) + .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; + serialize_normalized_args(Self::NAME, &args) + } + + fn pricing( + &self, + pricing: &EditorGenerationPricingConfig, + args: &Value, + ) -> Result { + let args: GenerateCharacterAnimationToolArgs = + parse_internal("generate character animation args", args)?; + Ok(pricing.character_animation_model_mud_points( + Some(EDITOR_AGENT_CHARACTER_ANIMATION_MODEL), + args.resolution.as_str(), + args.duration_seconds, + )) + } + + fn build_display_args( + &self, + args: &Value, + pricing: &EditorGenerationPricingConfig, + ) -> Result { + let price_mud_points = self.pricing(pricing, args)?; + let args: GenerateCharacterAnimationToolArgs = + parse_internal("generate character animation display args", args)?; + let frame_count = args.frame_count(); + let mut display_args = EditorAgentToolCallDisplayArgs::default(); + push_string_display_arg( + &mut display_args, + "prompt_text", + "动作描述", + args.prompt_text, + ); + push_string_display_arg( + &mut display_args, + "model", + "模型", + EDITOR_AGENT_CHARACTER_ANIMATION_MODEL, + ); + push_string_display_arg(&mut display_args, "resolution", "分辨率", args.resolution); + push_string_display_arg(&mut display_args, "ratio", "画面比例", args.ratio); + push_string_display_arg( + &mut display_args, + "duration_seconds", + "时长(秒)", + args.duration_seconds.to_string(), + ); + push_string_display_arg( + &mut display_args, + "frame_count", + "帧数", + frame_count.to_string(), + ); + push_image_display_arg( + &mut display_args, + &self.context, + "source_image_id", + "角色图片", + std::slice::from_ref(&args.source_image_id), + ) + .map_err(map_display_error)?; + Ok(finish_display_args(display_args, price_mud_points)) + } + + fn prepare_job( + &self, + args: &Value, + context: &EditorAgentPrepareJobContext<'_>, + ) -> Result { + let price_mud_points = self.pricing(context.pricing, args)?; + let args: GenerateCharacterAnimationToolArgs = parse_invalid_args(Self::NAME, args)?; + let metadata = self + .context + .image_metadata(&args.source_image_id) + .ok_or_else(|| EditorAgentToolError::invalid_args("source image not found"))?; + let source_width = metadata + .width + .filter(|value| *value > 0) + .ok_or_else(|| EditorAgentToolError::invalid_args("source image width missing"))?; + let source_height = metadata + .height + .filter(|value| *value > 0) + .ok_or_else(|| EditorAgentToolError::invalid_args("source image height missing"))?; + let source_resource_id = metadata.resource_id.clone(); + let source_layer_id = source_resource_id + .clone() + .unwrap_or_else(|| format!("editor-agent-{}", args.source_image_id.id)); + let title = args.prompt_text.clone(); + let frame_count = args.frame_count(); + let (display_width, display_height) = character_animation_canvas_display_size( + source_width, + source_height, + args.ratio.as_str(), + ); + let payload = EditorCharacterAnimationGenerateRequest { + source_layer_id, + source_image_src: metadata.data_key.clone(), + source_width, + source_height, + prompt_text: args.prompt_text, + screen_color: Some("auto".to_string()), + resolution: args.resolution, + ratio: args.ratio, + frame_count, + duration_seconds: args.duration_seconds, + model: EDITOR_AGENT_CHARACTER_ANIMATION_MODEL.to_string(), + project_id: Some(context.conversation.project_id.clone()), + canvas_completion: Some(build_editor_agent_canvas_completion_with_size( + context.project, + &title, + display_width, + display_height, + )), + generation_inputs: context.generation_inputs("用户指令", &title), + source_resource_id, + asset_folder_id: Some("project".to_string()), + asset_label: Some(title), + }; + Ok(PreparedEditorAgentJob { + job_kind: EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND, + request_label: "画布 Agent 生成角色动作", + price_mud_points, + payload: serialize_job_payload(Self::NAME, payload)?, + }) + } + + fn format_execute_message( + &self, + args: &Value, + result: &Value, + ) -> Result { + let args: GenerateCharacterAnimationToolArgs = + parse_internal("generate character animation args", args)?; + let result: EditorCharacterAnimationGenerateResponse = + parse_internal("generate character animation result", result)?; + Ok(GenerateCharacterAnimationTool::format_execute_message( + self, &args, &result, + )) + } + + fn result_assets( + &self, + result: &Value, + ) -> Result { + let result: EditorCharacterAnimationGenerateResponse = + parse_internal("generate character animation result", result)?; + let images = result.frames.first().map(|frame| { + let object_key = frame.image_src.trim_start_matches('/').to_string(); + EditorAgentGeneratedImage { + resource_id: None, + object_key: (!object_key.is_empty()).then_some(object_key), + asset_object_id: None, + image_src: frame.image_src.clone(), + thumbnail_src: None, + width: Some(frame.width), + height: Some(frame.height), + } + }); + Ok(EditorAgentResultAssets { + images: images.map(|image| vec![image]), + ..Default::default() + }) + } +} + impl EditorAgentTool for GenerateUiDesignTool { fn validate_args(&self, args: &Value) -> Result { let args: GenerateUiDesignToolArgs = parse_invalid_args(Self::NAME, args)?; @@ -1157,6 +1392,38 @@ impl EditorAgentTool for GenerateBackgroundMusicTool { } } +impl EditorAgentTool for SplitElementsTool { + fn validate_args(&self, args: &Value) -> Result { + let args: SplitElementsToolArgs = parse_invalid_args(Self::NAME, args)?; + SplitElementsTool::validate_args(self, &args) + .map_err(|error| EditorAgentToolError::invalid_args(error.to_string()))?; + serialize_normalized_args(Self::NAME, &args) + } + + fn format_execute_message( + &self, + args: &Value, + result: &Value, + ) -> Result { + let args: SplitElementsToolArgs = parse_internal("split elements args", args)?; + let result: SplitElementsToolOutput = parse_internal("split elements result", result)?; + Ok(SplitElementsTool::format_execute_message( + self, &args, &result, + )) + } + + fn result_assets( + &self, + result: &Value, + ) -> Result { + let result: SplitElementsToolOutput = parse_internal("split elements result", result)?; + Ok(EditorAgentResultAssets { + images: Some(result.images), + ..Default::default() + }) + } +} + #[cfg(test)] mod tests { use std::collections::HashMap; @@ -1180,6 +1447,7 @@ mod tests { }, ImageMetadata { data_key: format!("generated/{image_id}.png"), + resource_id: Some(format!("resource-{image_id}")), image_src: format!("/api/assets/read/generated/{image_id}.png"), object_key: Some(format!("generated/{image_id}.png")), thumbnail_src: Some(format!("/api/assets/read/generated/{image_id}-thumb.png")), @@ -1241,12 +1509,14 @@ mod tests { for name in [ GenerateImageTool::NAME, GenerateCharacterTool::NAME, + GenerateCharacterAnimationTool::NAME, GenerateUiDesignTool::NAME, EditImageTool::NAME, GenerateIconSpritesheetTool::NAME, GenerateVideoTool::NAME, GenerateSoundEffectTool::NAME, GenerateBackgroundMusicTool::NAME, + SplitElementsTool::NAME, ] { let tool = editor_agent_tool(name, &context).expect("registered tool should resolve"); assert_eq!(tool.tool_name(), name); @@ -1254,6 +1524,38 @@ mod tests { assert!(editor_agent_tool("unknown-tool", &context).is_none()); } + #[test] + fn execution_mode_separates_confirmed_generation_from_immediate_tools() { + let context = context_with_image("image-1"); + let animation = editor_agent_tool(GenerateCharacterAnimationTool::NAME, &context) + .expect("character animation tool should resolve"); + let split = editor_agent_tool(SplitElementsTool::NAME, &context) + .expect("split elements tool should resolve"); + + assert_eq!( + animation.execution_mode(), + EditorAgentToolExecutionMode::ConfirmedExternalJob + ); + assert_eq!( + split.execution_mode(), + EditorAgentToolExecutionMode::Immediate + ); + assert!(split.pricing(&pricing(), &json!({})).is_err()); + assert!( + split + .prepare_job( + &json!({}), + &EditorAgentPrepareJobContext { + conversation: &conversation(), + project: &project(), + message_id: 1, + pricing: &pricing(), + } + ) + .is_err() + ); + } + #[test] fn dyn_validation_deserializes_forwards_and_reserializes_typed_args() { let ui = editor_agent_tool(GenerateUiDesignTool::NAME, &EditorToolContext::default()) @@ -1382,6 +1684,11 @@ mod tests { json!({ "prompt": "角色", "model": platform_image::NANOBANANA_2_MODEL }), 12, ), + ( + GenerateCharacterAnimationTool::NAME, + json!({ "source_image_id": "image-1", "prompt_text": "循环行走" }), + 40, + ), ( GenerateUiDesignTool::NAME, json!({ "prompt": "界面", "model": platform_image::GPT_IMAGE_2_MODEL, "image_size": "2K" }), @@ -1554,4 +1861,54 @@ mod tests { assert_eq!(job.payload["projectId"], "project-1"); assert_eq!(job.payload["generationInputs"]["toolCallMessageId"], 7); } + + #[test] + fn character_animation_job_reuses_the_existing_billed_worker_contract() { + let pricing = pricing(); + let conversation = conversation(); + let project = project(); + let tool = editor_agent_tool( + GenerateCharacterAnimationTool::NAME, + &context_with_image("image-1"), + ) + .expect("character animation tool should resolve"); + let args = tool + .validate_args(&json!({ + "source_image_id": "image-1", + "prompt_text": "循环行走", + "duration_seconds": 5, + "resolution": "720p" + })) + .expect("character animation args should validate"); + let job = tool + .prepare_job( + &args, + &EditorAgentPrepareJobContext { + conversation: &conversation, + project: &project, + message_id: 8, + pricing: &pricing, + }, + ) + .expect("character animation job should prepare"); + + assert_eq!(job.job_kind, EDITOR_CHARACTER_ANIMATION_GENERATION_JOB_KIND); + assert_eq!(job.request_label, "画布 Agent 生成角色动作"); + assert_eq!(job.price_mud_points, 100); + assert_eq!(job.payload["sourceImageSrc"], "generated/image-1.png"); + assert_eq!(job.payload["sourceResourceId"], "resource-image-1"); + assert_eq!(job.payload["model"], EDITOR_AGENT_CHARACTER_ANIMATION_MODEL); + assert_eq!(job.payload["frameCount"], 40); + assert_eq!(job.payload["durationSeconds"], 5); + assert_eq!(job.payload["projectId"], "project-1"); + assert_eq!( + job.payload["canvasCompletion"]["placeholder"]["width"], + 512.0 + ); + assert_eq!( + job.payload["canvasCompletion"]["placeholder"]["height"], + 384.0 + ); + assert_eq!(job.payload["generationInputs"]["toolCallMessageId"], 8); + } } diff --git a/server-rs/crates/api-server/src/editor_agent/utils.rs b/server-rs/crates/api-server/src/editor_agent/utils.rs index bc367a2ae..f9fe25579 100644 --- a/server-rs/crates/api-server/src/editor_agent/utils.rs +++ b/server-rs/crates/api-server/src/editor_agent/utils.rs @@ -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, diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 4cf462c55..bc06be740 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -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 { + 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, ) -> Result, 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 { + 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( diff --git a/server-rs/crates/platform-editor-agent/src/agent/asset.rs b/server-rs/crates/platform-editor-agent/src/agent/asset.rs index f35c6c69f..7050f1bbc 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/asset.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/asset.rs @@ -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, pub image_src: String, pub object_key: Option, pub thumbnail_src: Option, diff --git a/server-rs/crates/platform-editor-agent/src/agent/prompt.rs b/server-rs/crates/platform-editor-agent/src/agent/prompt.rs index 5f04018a5..801fe44e1 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/prompt.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/prompt.rs @@ -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)); } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs index 96229eee0..4a0a84235 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_icon_spritesheet.rs @@ -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, diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs index 1c26f27a7..49e353a62 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/mod.rs @@ -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"); diff --git a/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts b/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts index 8d6304851..46f279f04 100644 --- a/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts +++ b/src/components/image-editor/EditorAgentConversation/toolCallPresentation.ts @@ -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 '生成图片'; } diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index 03ff1b3c8..d89647fa2 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -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; diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index e8f610dd5..0dc1388ea 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -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),