From d7f0c451641a64d974c8cea5501a2a13f51a2e07 Mon Sep 17 00:00:00 2001 From: kdletters Date: Tue, 28 Jul 2026 21:34:34 +0800 Subject: [PATCH 1/3] =?UTF-8?q?=E6=8A=BD=E5=8F=96=E7=94=BB=E5=B8=83=20Agen?= =?UTF-8?q?t=20=E5=85=AC=E5=85=B1=E6=89=A7=E8=A1=8C=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增业务中立的 function-calling harness 与公共提示词协议 统一画布 Agent 工具路由约束和待确认控制语义 保留失败前输出并事务化收口 memory、deadline 与取消状态 迁移画布 Agent 和 API 编排并同步架构文档与项目记忆 --- .../shared-memory/decision-log.md | 12 +- docs/project-memory/shared-memory/pitfalls.md | 15 +- ...构下线】旧创作模板业务退役方案-2026-07-17.md | 2 +- ...】server-rs与SpacetimeDB数据契约-2026-05-15.md | 7 +- .../【编辑器】画布Agent对话面板-2026-07-03.md | 14 +- server-rs/Cargo.lock | 10 + server-rs/Cargo.toml | 2 + .../crates/api-server/src/editor_agent/api.rs | 170 +- .../crates/platform-agent-harness/Cargo.toml | 12 + .../src}/agent.rs | 14 +- .../src}/agent_builder.rs | 8 +- .../platform-agent-harness/src/error.rs | 100 ++ .../src}/hook.rs | 4 +- .../crates/platform-agent-harness/src/lib.rs | 10 + .../platform-agent-harness/src/memory.rs | 57 + .../platform-agent-harness/src/prompt.rs | 105 ++ .../crates/platform-agent-harness/src/run.rs | 1581 +++++++++++++++++ .../src}/tool.rs | 2 + .../crates/platform-editor-agent/Cargo.toml | 1 + .../platform-editor-agent/src/agent/agent.rs | 62 +- .../platform-editor-agent/src/agent/mod.rs | 1 + .../platform-editor-agent/src/agent/prompt.rs | 238 +++ .../src/agent/tools/common.rs | 1 - .../src/agent/tools/edit_image.rs | 7 +- .../agent/tools/generate_background_music.rs | 4 +- .../src/agent/tools/generate_character.rs | 9 +- .../agent/tools/generate_icon_spritesheet.rs | 8 +- .../src/agent/tools/generate_image.rs | 7 +- .../src/agent/tools/generate_sound_effect.rs | 4 +- .../src/agent/tools/generate_ui_design.rs | 8 +- .../src/agent/tools/generate_video.rs | 4 +- .../src/agent/tools/mod.rs | 1 - .../src/framework/error.rs | 53 - .../src/framework/memory.rs | 31 - .../src/framework/mod.rs | 8 +- .../src/framework/run.rs | 458 ----- 36 files changed, 2310 insertions(+), 720 deletions(-) create mode 100644 server-rs/crates/platform-agent-harness/Cargo.toml rename server-rs/crates/{platform-editor-agent/src/framework => platform-agent-harness/src}/agent.rs (86%) rename server-rs/crates/{platform-editor-agent/src/framework => platform-agent-harness/src}/agent_builder.rs (75%) create mode 100644 server-rs/crates/platform-agent-harness/src/error.rs rename server-rs/crates/{platform-editor-agent/src/framework => platform-agent-harness/src}/hook.rs (80%) create mode 100644 server-rs/crates/platform-agent-harness/src/lib.rs create mode 100644 server-rs/crates/platform-agent-harness/src/memory.rs create mode 100644 server-rs/crates/platform-agent-harness/src/prompt.rs create mode 100644 server-rs/crates/platform-agent-harness/src/run.rs rename server-rs/crates/{platform-editor-agent/src/framework => platform-agent-harness/src}/tool.rs (99%) create mode 100644 server-rs/crates/platform-editor-agent/src/agent/prompt.rs delete mode 100644 server-rs/crates/platform-editor-agent/src/agent/tools/common.rs delete mode 100644 server-rs/crates/platform-editor-agent/src/framework/error.rs delete mode 100644 server-rs/crates/platform-editor-agent/src/framework/memory.rs delete mode 100644 server-rs/crates/platform-editor-agent/src/framework/run.rs diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index deafcaa38..932eebae8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -4250,8 +4250,8 @@ - 背景:VectorEngine Apifox `api-349239079` 暴露 OpenAI-compatible `POST /v1/chat/completions`;创意 Agent 和通用 LLM 代理需要统一到 VectorEngine 文本服务,并将默认文本模型切换为 `gpt-5.4-mini`。 - 决策:创意 Agent 的 `CREATIVE_AGENT_GPT5_MODEL` 固定为 `gpt-5.4-mini`,协议切到 Chat Completions,不再携带旧 APIMart `official_fallback` 字段;画布 Agent 侧边栏聊天规划请求也复用该模型和 Chat Completions 协议,不再显式使用 `gpt-4o` / Responses。通用 `/api/llm/chat/completions` 代理使用 `GENARRATIVE_LLM_PROVIDER=openai-compatible`、`GENARRATIVE_LLM_BASE_URL=https://api.vectorengine.cn/v1`、`GENARRATIVE_LLM_MODEL=gpt-5.4-mini`。未单独配置 `GENARRATIVE_LLM_API_KEY` 时,api-server 可复用 `VECTOR_ENGINE_API_KEY`;前端 LLM 客户端必须兼容 OpenAI `choices`、api-server raw `{content}` 和项目 envelope `{ok,data:{content}}` 三种非流式响应,以及 OpenAI SSE delta 和 api-server `event: delta` 两种流式响应。 -- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `toolName` / `resourceId` / `objectKey` 等轻量元数据。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit_image` 并引用该结果;不能因为本轮没有手动附件而退回 `generate_image`。 -- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate_image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate_image`,只有实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。 +- 决策补充:画布 Agent 的 planning prompt 必须自动注入上一条已完成生成结果的 `latestGeneratedImage`,来源为上一轮 generation 的 `toolName` / `imageId` / `resourceId` / `objectKey` 等轻量元数据。用户用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代上一张图或继续编辑时,规划默认调用 `edit-image` 并把 `latestGeneratedImage.imageId` 传入 `edit-image.object_image_id`;不得生成工具 schema 中不存在的 `source_image_id`,也不能因为本轮没有手动附件而退回 `generate-image`。 +- 决策补充:画布 Agent 侧边栏的“规范图 / 视觉规范图 / 风格规范图 / 素材规范展板”是 Agent 规划 prompt 和 function-calling 工具选择约束,不是侧边栏 UI 说明文案。此类请求默认走 `generate-image`,prompt 必须要求规范展板包含统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等视觉规范元素;角色规范图若是规范展板也走 `generate-image`,只有实际角色立绘才走 `generate-character`,多个图标素材 / 图集才走 `generate-icon-spritesheet`。 - 影响范围:`server-rs/crates/platform-agent`、`server-rs/crates/api-server/src/config.rs`、`src/services/llmClient.ts`、`.env.example`、`deploy/env/api-server.env.example`、`scripts/test-ve-llm.mjs`。 - 验证方式:`npm run test -- src/services/llmClient.test.ts`、`cargo test -p api-server --manifest-path server-rs/Cargo.toml from_env_reads_non_public_models_and_urls app_state_builds_creative_agent_gpt5_client_from_vector_engine_settings llm_chat_completions editor_agent_llm_request_uses_vector_engine_chat_model`、`cargo test -p platform-agent --manifest-path server-rs/Cargo.toml`、`npm run check:encoding`、`git diff --check`。 @@ -4510,3 +4510,11 @@ - 对账边界:保留管理员显式手动对账。owner 始终可用;member 必须单独持有 `profile-wallet-consumption-reconcile` 独立操作权限,任意 Tab 都不隐式授予。`POST /admin/api/profile/users/reconcile-consumption` 经二次确认后调用 runtime service identity 受限 procedure,扫描该用户全部权威流水、比较并校准投影,记录管理员与对账时间。 - 展示边界:现有共享“用户详情”弹窗的钱包区增加“历史花费”,前端只展示 BFF 顶层字段,不自行汇总账单;只有 BFF 返回 `canReconcileConsumption=true` 时展示手动对账按钮。 - 验证方式:SpacetimeDB 钱包聚合测试、api-server / admin-web 定向测试、`npm run spacetime:generate`、`npm run check:spacetime-schema`、`npm run check:spacetime-runtime-access`、`npm run admin-web:typecheck`、`npm run check:encoding`、`git diff --check`。 + +## 2026-07-28 画布 Agent 的通用 function-calling harness 与画布 prompt 分层 + +- 背景:画布 Agent 的 JSON 输出协议、tool schema 注入、memory / hook、轮次保护和“全部工具待确认即结束回合”原先位于 `platform-editor-agent/src/framework`,与规范展板、已有图编辑路由、模型超时和画布工具混在同一 crate;八类工具还重复携带待确认控制话术。旧 `platform-agent` 已随 Creative Agent 退役,不能作为新公共层复活。 +- 决策:新增无旧玩法依赖的现役 `platform-agent-harness`,只承载业务中立的 function-calling 执行协议;`platform-editor-agent` 通过兼容 re-export 复用该 crate,并继续承载画布 LLM profile、角色 prompt、公共美术工具路由策略、图片上下文和工具实现。无工具场景同样注入 JSON 响应格式;prompt 不再宣称工具并发执行;request 级 system prompt 必须真实进入本轮请求。画布对话额外注入最近一条已完成图片的有界 `latestGeneratedImage` 元数据,后续编辑仍只用 SHA-256 `imageId` 选图。 +- 执行与失败决策:prompt 每轮通过 `AgentMemory::begin_staged` 使用与调用方 memory 行为等价、写入隔离的 `StagedAgentMemory` 事务;成功或已有工具活动时显式 `commit()`,直接 drop 表示回滚。无工具活动失败时回滚本轮 staged 增量,已发生工具活动后失败时提交已发生工具事实并追加 terminal error closure。外部 future drop / abort 若发生在工具完成后,提交工具结果与取消闭环;若发生在工具执行中,提交“已启动、结果未知”与取消闭环,后续先 reconcile,不能假装副作用未发生。harness 通过 `PromptRunError { error, partial_outputs }` 显式返回终态错误和失败前输出;结构化工具失败还必须向调用方保留 `ToolFailure.kind/retryable/fatal` 与原始 `output`,不在 harness 内压成单一字符串。api-server 的 18 分钟总 deadline 以 runtime future 下沉到 runner:completion 可被 deadline 终止,工具在开始前检查、开始后等待返回、返回后携带结果收口;禁止外层 timeout drop prompt 或中途取消 effectful tool 后伪造空 partial。 +- 保留边界:会话幂等、OSS 消息、120 秒前端软提示、20 分钟 transport、18 分钟 handler 总 deadline、1024 tokens、8 分钟 provider attempt、泥点计费、确认入队和 external job 懒回填均不进入公共 harness。SpacetimeDB schema、前端 wire DTO 和侧边栏 UI 不变。 +- 验证方式:`cargo test -p platform-agent-harness`、`cargo test -p platform-editor-agent`、`cargo test -p api-server editor_agent`、`cargo check -p api-server --locked`、DDD 边界检查、Rustfmt、编码检查和 `git diff --check`。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 419c0aace..8c63ce94b 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3272,8 +3272,17 @@ - 现象:画布 Agent 已生成有效工具规划,却最终只保存 `ERROR max turns reached: 3`,助手文本和待确认工具卡都消失。 - 原因:八类画布工具的 `call()` 只返回待用户确认的规划结果,但 function-calling runner 在成功工具后仍继续请求 LLM,只靠 prompt 要求模型不再重试;模型连续返回工具调用直到上限后,错误结果又丢弃此前累积的输出。 - 处理:工具通过框架契约显式声明 `requires_user_confirmation`;当本批全部工具都成功且等待确认时,runner 在处理完整批次后立即返回已有助手文本和工具结果。未知工具、参数错误、hook skip、普通连续工具和不可解析响应仍继续受 `max_turns` 门禁保护。不要用单纯提高轮次上限掩盖终止条件缺失。 -- 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”和“普通连续工具仍会触发 max-turn 门禁”。 -- 关联:`server-rs/crates/platform-editor-agent/src/framework/run.rs`、`server-rs/crates/platform-editor-agent/src/framework/tool.rs`、`server-rs/crates/platform-editor-agent/src/agent/tools/`。 +- 验证:runner 回归测试必须同时覆盖“待确认工具只调用一次 LLM 并成功结束”“普通连续工具仍会触发 max-turn 门禁”“多工具按数组顺序执行”“request 级 system prompt 真实进入请求”;公共 prompt 在无工具时仍必须包含 runner 所需的 JSON 响应格式,且不得宣称并发执行。 +- 关联:`server-rs/crates/platform-agent-harness/src/run.rs`、`server-rs/crates/platform-agent-harness/src/tool.rs`、`server-rs/crates/platform-editor-agent/src/agent/tools/`。 + +## Agent 终态失败不能吞掉已发生的工具事实 + +- 现象:同一轮 prompt 中前面工具已经成功生成待确认结果,但后续工具、hook、completion 或 `max_turns` 失败后,API 只保存最后一条 `ERROR `,已执行工具和用户本轮语义从会话历史中消失。 +- 原因:runner 只返回单一 `PromptError`,或者直接向 committed memory 逐步写入,无法区分“尚未发生外部工具事实,整轮可回滚”与“已发生工具事实,只能提交并闭合错误”。工具失败若被压成字符串,调用方还会丢失 `kind`、`retryable`、`fatal` 和原始 `output`。 +- 处理:用 `PromptRunError { error, partial_outputs }` 保留失败前输出,并将本轮 memory 先写入 staged buffer。无工具活动失败时整体回滚 staged 增量;有成功或失败工具活动时提交已发生事实,并追加 terminal error closure。api-server 按 `partial_outputs` 顺序先持久化成功工具的 `not_completed` 待确认消息,再追加 `ERROR ` 终态消息;`ToolFailed` 保留给调用方做诊断和流程决策,不伪装成成功确认卡。 +- 取消边界:不能在 prompt future 内对 `agent.memory.take()` 后跨 await 持有,也不能用统一 `VecMemory` staging 绕过自定义 memory 的限长、摘要或脱敏规则。`AgentMemory::begin_staged` 必须产生行为等价、写入隔离的 `StagedAgentMemory`,成功或已有工具活动时显式 `commit()`,直接 drop 才表示回滚。外部 drop 若发生在工具完成后,guard 必须提交结果与取消闭环;若工具仍在执行,至少提交“已启动、结果未知”事实,供后续 reconcile。正式总 deadline 应作为 runner 内部 future 终止 completion;工具开始前检查 deadline,一旦开始则不能中途 drop,必须等待结果后再携带 partial outputs 收口。外层 timeout 只适合作为进程级最后保险,不能承担业务收口。 +- 验证:至少覆盖“无工具 completion 失败回滚 staged 用户消息”“非 fatal 工具失败对调用方暴露 `kind/retryable/fatal/output`”“成功工具后终态失败保留 partial tool output”“有工具活动时 committed memory 末尾存在 error closure”以及“API 增量中待确认工具位于 terminal `ERROR ` 之前”。上一张图继续编辑的 prompt 测试必须断言 `latestGeneratedImage.imageId -> edit-image.object_image_id`,且最终 prompt 不含 `source_image_id`。 +- 关联:`server-rs/crates/platform-agent-harness/src/run.rs`、`server-rs/crates/platform-agent-harness/src/tool.rs`、`server-rs/crates/platform-editor-agent/src/agent/prompt.rs`、`server-rs/crates/api-server/src/editor_agent/api.rs`。 ## 画布 Agent 的规划请求不能关闭瞬时失败重试 @@ -3281,7 +3290,7 @@ - 原因:规划请求虽然有 Agent 专用单次 timeout,但 `editor_agent_llm_client` 把 `max_retries` 硬编码为 0;VectorEngine `gpt-5.4-mini` 的偶发长尾、连接超时或可重试上游状态会在第一次失败后直接持久化成 system error。framework 的英文 `completion error` 前缀也被原样暴露给用户。 - 处理:120 秒改为前端软提示阈值:POST 仍 pending 时显示不入库的“仍在处理中,请耐心等待”;provider 明确断开/失败才写正式错误。专用 provider 单 attempt 使用 8 分钟 hard timeout,请求发起阶段读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次且重试退避最多 60 秒。不要只计算单次 complete 的最坏时间:runner 还可因非法 JSON/工具校验失败进入后续轮次,必须从 handler 入口开始计算 18 分钟总 deadline,进入 `agent.prompt(...)` 时扣除会话锁/上下文准备已用时间,为持久化和前端 20 分钟 timeout 留出余量。响应头后的体读取/解析错误按明确失败收口,必须使用真实 attempt 计数;规划、配置和定价错误对用户统一为中文,原始诊断只记后端日志。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。 - 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`platform-llm` 回归用例锁定第二次 attempt 成功响应头后的 body timeout 仍报累计 2 次;`api-server` 测试锁定专用 client retry、18 分钟整体 deadline 与中文直达错误。运行态排障按同一 request id 对齐 `platform_llm` failure stage 与 `/messages` 总耗时,并确认仍 pending 的请求不再在 120 秒形成错误气泡。 -- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs`、`server-rs/crates/platform-editor-agent/src/framework/error.rs`、`server-rs/crates/api-server/src/state.rs`、`src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts`、`src/components/image-editor/EditorAgentConversation/MessageBubble.tsx`、`src/services/image-editor/editorAgentClient.ts`。 +- 关联:`server-rs/crates/platform-editor-agent/src/agent/agent.rs`、`server-rs/crates/platform-agent-harness/src/error.rs`、`server-rs/crates/api-server/src/state.rs`、`src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts`、`src/components/image-editor/EditorAgentConversation/MessageBubble.tsx`、`src/services/image-editor/editorAgentClient.ts`。 ## 前端退役目录不能只靠扫描和 ignore 隔离 diff --git a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md index fa81d9c6a..804bcd7a9 100644 --- a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md +++ b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md @@ -59,7 +59,7 @@ - SpacetimeDB schema guard 比较当前工作树与基线提交时,两侧都必须分别读取各自 `Cargo.toml` 的 `lib.path`,再沿 `mod` / `#[path]` 只扫描该快照 crate root 可达的 schema;不得递归扫描整个 `src/`,否则原位保留的旧源码会与现役历史数据壳产生假 accessor 重复。 - `module-runtime` 仍是账号、钱包、公共设置、追踪和 feature gate 的现役领域 crate;其混合源码中的 `CreationEntry*`、旧公开作品、旧存档 / 浏览历史 / 游玩统计 DTO、command、mapper 和规则必须以编译条件退出,且不再依赖只为旧创作契约存在的 `shared-contracts`。历史 schema 只继续编译 `RuntimeBrowseHistoryThemeMode` 六个变体和完整保序的 `RuntimeProfileWalletLedgerSourceType` 等持久化 ABI,不保留围绕这些类型的旧业务实现。 - 纯模板 crate 和专属运行态 crate 不属于 workspace members、default members 或任何在运 crate 的依赖图;源码目录保持原样。 -- `platform-agent` 及其专属 `langchainrust` 依赖同样退出 workspace 与 `api-server` 依赖图;现役编辑器 Agent 仅需的模型常量收口到 `platform-llm`,不再通过旧拼图 Phase 1 / Creative Agent 执行器 crate 复用。 +- `platform-agent` 及其专属 `langchainrust` 依赖同样退出 workspace 与 `api-server` 依赖图;现役编辑器 Agent 仅需的模型常量收口到 `platform-llm`,不再通过旧拼图 Phase 1 / Creative Agent 执行器 crate 复用。后续抽出的 `platform-agent-harness` 是无旧玩法依赖的通用 JSON function-calling 底座,不得依赖、复用或重新挂回本条退役 crate。 - `platform-auth` 不再编译 runtime guest token;`platform-wechat` 不再编译旧生成结果订阅服务,只保留现役认证和支付协议。 ## 验收 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index d56a8a254..38b717aed 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -24,7 +24,7 @@ SpacetimeDB 版本口径:当前 Rust crate `spacetimedb`、`spacetimedb-sdk` - HTTP 与运维入口:`api-server`、`pingora-gateway`、`server-manager-panel`。 - 现役领域模块:`module-ai`、`module-assets`、`module-auth`、`module-editor-agent`、`module-runtime`。`module-runtime` 继续承载账号、钱包、公共设置、追踪、功能门禁等现役平台领域能力;该 crate 名称不代表旧玩法 runtime 路由仍在运行。 -- 平台副作用:`platform-agent`、`platform-auth`、`platform-audio`、`platform-hyper3d`、`platform-image`、`platform-llm`、`platform-matting`、`platform-oss`、`platform-speech`、`platform-wechat`。 +- 平台副作用:`platform-agent-harness`、`platform-editor-agent`、`platform-auth`、`platform-audio`、`platform-hyper3d`、`platform-image`、`platform-llm`、`platform-matting`、`platform-oss`、`platform-speech`、`platform-wechat`。已退役 Creative Agent 的旧 `platform-agent` 只保留历史源码,不属于现役 workspace 或依赖图。 - 共享层:`shared-contracts`、`shared-kernel`、`shared-logging`。 - SpacetimeDB:`spacetime-client`、`spacetime-module`。 - 测试支撑:`tests-support`。 @@ -76,11 +76,16 @@ npm run check:server-rs-ddd - `/api/editor/projects/{projectId}/agent-conversations` 负责当前工程会话列表和新建;`/api/editor/agent-conversations/{conversationId}` 负责详情读取、终态工具消息懒回填和软删;`POST /api/editor/agent-conversations/{conversationId}/messages` 负责发送消息并返回普通 JSON `EditorAgentMessageResponse`,画布 Agent 不提供 `/messages/stream` SSE 路由。消息请求必须携带最长 128 字符的 `clientMessageId`;前端对该 POST 显式启用 1 次瞬时 transport 重试,并复用同一个序列化 body、`clientMessageId` 和 `x-request-id`。同一会话在锁内按该键幂等,重复键同内容返回已有回合或从已保存用户消息继续,异内容返回 `409`。数字 `EditorAgentMessage.id` 仍只作为工具确认 / 取消的后端消息定位符,不能复用为客户端幂等键。 - `module-editor-agent` 只承载纯领域校验:标题派生、附件上限、消息输入规则和会话软删访问规则;不直接依赖 Axum、SpacetimeDB、OSS、LLM 或 Tokio。 +- `platform-agent-harness` 只承载与具体业务无关的 JSON function-calling 协议、工具 schema 注入、memory、hook、typed tool、轮次保护和待用户确认终止语义;`platform-editor-agent` 在其上叠加画布专属 LLM profile、system prompt、跨工具路由规则、图片上下文与八类生成工具。公共 harness 不依赖画布 DTO、计费、OSS、Axum 或 external job,也不得复用已退役的旧 `platform-agent`。harness 失败契约固定为 `PromptRunError { error, partial_outputs }`;`partial_outputs` 显式携带失败前已产生的助手文本、成功工具和结构化失败工具输出。`ToolFailure` 的 `kind`、`retryable`、`fatal` 及工具原始 `output` 必须对 harness 调用方可见,不得在公共层压成字符串或擅自丢弃。 +- prompt runner 对每个调用通过 `AgentMemory::begin_staged` 创建行为等价、写入隔离的 `StagedAgentMemory` 事务:限长、摘要、脱敏或持久化 memory 的 append 语义必须在本轮模型请求前生效,不得统一降级成 `VecMemory`。成功结束或已发生工具活动时必须显式调用 staged `commit()`,直接 drop 表示回滚。无工具活动的 completion、hook、解析或 `max_turns` 失败丢弃 staged transaction;已有成功或失败工具活动时在末尾追加 terminal error closure 后提交。外部 future drop / abort 若发生在工具完成后,必须提交工具结果与取消闭环;若工具仍在执行,则提交“已启动、结果未知”事实与取消闭环,供后续 reconcile,不能假装工具没有发生。 +- 画布 handler 的 18 分钟总 deadline 通过 runtime 提供的 deadline future 下沉到公共 runner:completion await 可被 deadline 终止;effectful tool 在开始前检查 deadline,开始后不被中途 drop,返回后再携带结果收口为 `PromptRunError`。禁止用外层 timeout 直接 drop 整个 prompt future 并伪造空 `partial_outputs`。 - `spacetime-module` 的 `editor_agent_conversation` 只保存元数据;创建、列表、读取、更新时间和软删通过 `create_editor_agent_conversation_and_return`、`list_editor_agent_conversations_and_return`、`get_editor_agent_conversation_and_return`、`touch_editor_agent_conversation_and_return`、`delete_editor_agent_conversation_and_return` procedure 完成,`api-server` 只能经 `spacetime-client` facade 访问。 - 完整消息文档存 OSS `editor-agent/{conversationId}.json`,由 `api-server` 负责 2 MiB 上限、会话内串行锁、读改写、消息与工具结果持久化和 `touch` 元数据更新时间;该 JSON 不进入 `editor_canvas.layers_json`,也不作为画布布局真相。LLM 未配置、连接已经断开、请求明确失败、达到最终安全上限或规划不可解析时,必须写入 `role=system`、正文以 `ERROR ` 开头的消息,并通过 `deltaMessages` 返回,`errorMessage` 保持为空;前端隐藏前缀并显示红色错误气泡,面向用户的错误正文使用中文语义,不暴露 `completion error` 等 framework 内部前缀或原始配置/定价错误;原始诊断只写后端结构化日志。后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。普通 JSON POST 尚未结束不形成持久化消息;工具失败同样必须形成可回读记录,不能只返回瞬时错误。 - 画布 Agent 的 `gpt-5.4-mini` Chat Completions 规划使用 1024 `max_tokens`。前端在 POST pending 120 秒后显示不入库的耐心等待提示;provider request future 明确返回 connect/timeout/HTTP/transport 错误时立即进入正式失败,尚未返回则继续等待。专用 provider 单 attempt hard timeout 为 8 分钟;请求发起阶段的 timeout、连接失败、`408`、`429` 与 `5xx` 读取 `GENARRATIVE_LLM_MAX_RETRIES`,但画布 Agent 最多重试 1 次,显式配置 0 仍可关闭,专用重试退避最多 60 秒。消息规划生命周期从 handler 入口开始计入 18 分钟总 deadline,进入 `agent.prompt(...)` 时只使用剩余预算;该 deadline 覆盖会话锁/上下文准备与最多 3 轮规划,并为错误持久化/HTTP 返回预留约 2 分钟,不允许多轮规划绕过前端 20 分钟 timeout。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,并使用该成功响应所属的真实 attempt 记录错误。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。 - 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。 +- planning prompt 注入的 `latestGeneratedImage.imageId` 只能映射到 `edit-image.object_image_id`;`source_image_id` 不是现役 `edit-image` schema 字段,prompt、tool args、确认执行和测试中都不得生成或兼容该字段。 - 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。 +- api-server 对 `PromptRunError` 的持久化顺序固定为:先按 `partial_outputs` 原顺序映射已成功工具,将其保存为 `status=not_completed` 且无 `externalJobId` 的待确认消息;再在同一会话增量末尾追加 `ERROR ` terminal system 消息并整体写入 OSS。后续规划失败不得吞掉失败前已执行的成功工具结果;结构化 `ToolFailed` 可用于调用方诊断与流程决策,但画布确认面不得把它伪装成成功待确认卡。 - `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。 - 会话详情的终态懒回填必须在单次 GET 和同一 conversation lock 内完成有界重试:任务结果读取、completed payload 解析或工具 formatter 首次失败后最多重试 3 次,每次等待 100ms 并重新读取主任务。任务读取失败或 completed 任务暂缺 `result_payload_json` 时,本次重试耗尽后仍保留 OSS 工具消息的 `not_completed + externalJobId`,由下次会话读取继续 reconcile;JSON 损坏、结果结构不兼容或 formatter 失败等确定性致命错误在重试耗尽后原子写为 `failed`,保存“重试 3 次后仍失败”的最后错误,避免永久循环。 - 画布 Agent 是“正式任务 payload 不进入通用用户 read model”规则的窄例外消费者:`GET /api/editor/agent-conversations/{conversationId}` 只按会话中已有的 `externalJobId` 定向读取主任务,完成后由对应工具 formatter 从 `result_payload_json` 提取并归一有界的图片 / 视频 / 音频引用,写入 OSS 工具消息后返回。前端仍不得通过通用任务列表 / 状态接口读取或解析 `request_payload_json` / `result_payload_json`;OSS 轻量媒体引用只是会话展示与后续 Agent 上下文,不替代 `editor_project_resource`、`editor_asset`、结构化画布表或 `external_generation_job` 的业务真相。未激活结构化存储的 canvas 才继续以 `editor_canvas.layers_json` 作为 legacy 布局真相。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index 145305da9..c96c427c2 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -106,12 +106,16 @@ - 编排复用 `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` / `resourceId` / `objectKey` / `assetObjectId` 等轻量元数据,不把私有签名 URL 或大图内容塞进 prompt。 +- 规划 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` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `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 覆盖非法 JSON/工具校验失败触发的后续规划轮,并为错误持久化和 HTTP 返回保留约 2 分钟,不再让前端 20 分钟 transport timeout 先触发。已收到成功响应头后的响应体读取或解析失败直接按明确失败收口,错误计数/日志使用该响应所属的真实 attempt。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。 +- 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,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。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。 - 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 实际触发生成工具时按对应模型定价扣泥点。 - 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。 @@ -152,7 +156,7 @@ - `GET/POST /api/editor/projects/{projectId}/agent-conversations`(列表/新建); - `GET/DELETE /api/editor/agent-conversations/{conversationId}`(详情/软删); - `POST /api/editor/agent-conversations/{conversationId}/messages`(JSON); - - Agent 编排(function-calling 循环、工具内部调既有生成执行链路)放 api-server 编排层,独立文件,不复用 `creative_agent.rs` 内存会话。 + - 通用 function-calling harness 放 `platform-agent-harness`;画布 Agent profile 与工具放 `platform-editor-agent`;会话、计费、OSS 和工具内部生成执行链路由 api-server 编排层承接,不复用 `creative_agent.rs` 内存会话。 - `shared-contracts` + `packages/shared`:`editorAgent` 会话、消息、工具确认展示与轻量媒体结果 DTO;消息响应返回 `conversation`、`deltaMessages` 和可选 `errorMessage`。 ## 实施顺序 diff --git a/server-rs/Cargo.lock b/server-rs/Cargo.lock index c4381610e..d7227e064 100644 --- a/server-rs/Cargo.lock +++ b/server-rs/Cargo.lock @@ -4018,6 +4018,15 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "platform-agent-harness" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tokio", +] + [[package]] name = "platform-audio" version = "0.1.0" @@ -4056,6 +4065,7 @@ name = "platform-editor-agent" version = "0.1.0" dependencies = [ "hmac", + "platform-agent-harness", "platform-audio", "platform-image", "platform-llm", diff --git a/server-rs/Cargo.toml b/server-rs/Cargo.toml index 3c68a1d50..628d31afa 100644 --- a/server-rs/Cargo.toml +++ b/server-rs/Cargo.toml @@ -38,6 +38,7 @@ members = [ "crates/platform-oss", "crates/platform-auth", "crates/platform-audio", + "crates/platform-agent-harness", "crates/platform-hyper3d", "crates/platform-image", "crates/platform-llm", @@ -67,6 +68,7 @@ module-assets = { path = "crates/module-assets", default-features = false } module-auth = { path = "crates/module-auth", default-features = false } module-editor-agent = { path = "crates/module-editor-agent", default-features = false } module-runtime = { path = "crates/module-runtime", default-features = false } +platform-agent-harness = { path = "crates/platform-agent-harness", default-features = false } platform-editor-agent = { path = "crates/platform-editor-agent", default-features = false } platform-auth = { path = "crates/platform-auth", default-features = false } platform-audio = { path = "crates/platform-audio", default-features = false } 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 a1f0f089c..c09323825 100644 --- a/server-rs/crates/api-server/src/editor_agent/api.rs +++ b/server-rs/crates/api-server/src/editor_agent/api.rs @@ -1,4 +1,3 @@ -use std::future::IntoFuture; use std::time::Duration; use axum::extract::{Path, State}; @@ -10,7 +9,9 @@ use module_editor_agent::{ use platform_editor_agent::framework::agent_builder::AgentBuilder; use platform_editor_agent::framework::error::PromptError; use platform_editor_agent::framework::memory::VecMemory; -use platform_editor_agent::framework::run::{PromptOutput, format_tool_call_message}; +use platform_editor_agent::framework::run::{ + PromptOutput, PromptRunError, format_tool_call_message, +}; use platform_llm::LlmMessage; use serde::Serialize; use serde_json::{Value, json}; @@ -47,6 +48,7 @@ use crate::http_error::AppError; use crate::request_context::RequestContext; use crate::state::AppState; use platform_editor_agent::agent::agent::LlmChatAgentBuilder; +use platform_editor_agent::agent::prompt::{build_prompt_memory, editor_agent_system_prompt}; 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; @@ -57,7 +59,7 @@ use platform_editor_agent::agent::tools::generate_sound_effect::GenerateSoundEff use platform_editor_agent::agent::tools::generate_ui_design::GenerateUiDesignTool; use platform_editor_agent::agent::tools::generate_video::GenerateVideoTool; use shared_kernel::{build_prefixed_uuid_id, normalize_optional_string, normalize_required_string}; -use tokio::time::{Instant, timeout}; +use tokio::time::Instant; const EDITOR_AGENT_CLIENT_MESSAGE_ID_MAX_CHARS: usize = 128; const EDITOR_AGENT_PROMPT_TIMEOUT_MS: u64 = 18 * 60_000; @@ -190,18 +192,9 @@ pub async fn editor_agent_message( }; // The current user message is passed separately to prompt(), so memory stops before it. - let previous_messages: Vec = document.messages[..history_end] - .iter() - .map(|message| match message.role { - EditorAgentMessageRole::User => LlmMessage::user(&message.text), - EditorAgentMessageRole::Assistant => LlmMessage::assistant(&message.text), - EditorAgentMessageRole::System => LlmMessage::system(&message.text), - }) - // Tool calls and attachment bookkeeping are separate system messages. - .rev() - .take(18) - .rev() - .collect(); + // Tool calls and attachment bookkeeping are separate system messages. The prompt memory also + // appends one bounded latestGeneratedImage context entry for natural-language image references. + let previous_messages = build_prompt_memory(&document, history_end); // Build tool context from document let tool_context = context::build_tool_context(&document); @@ -272,16 +265,19 @@ pub async fn editor_agent_message( let remaining_prompt_duration = remaining_editor_agent_prompt_duration(message_started_at.elapsed()); - let agent_result = run_editor_agent_prompt_with_timeout( - agent.prompt(LlmMessage::user(user_message.text.clone())), - remaining_prompt_duration, - ) - .await; + let agent_result = agent + .prompt(LlmMessage::user(user_message.text.clone())) + .deadline( + tokio::time::sleep(remaining_prompt_duration), + editor_agent_prompt_deadline_error(), + ) + .await; let assistant_now = now_rfc3339(); + let (outputs, terminal_error) = split_prompt_result(agent_result); match build_delta_messages( - agent_result, + outputs, &assistant_now, document.messages.len(), &tool_context, @@ -293,11 +289,12 @@ pub async fn editor_agent_message( &conversation, &mut document, conversation_summary, - error.to_string(), + error.display_with_agent_label("美术 Agent").to_string(), ) .await } - Ok(delta_messages) => { + Ok(mut delta_messages) => { + append_terminal_error(&mut delta_messages, document.messages.len(), terminal_error); for msg in &delta_messages { document.messages.push(msg.clone()); } @@ -316,20 +313,33 @@ fn remaining_editor_agent_prompt_duration(elapsed: Duration) -> Duration { Duration::from_millis(EDITOR_AGENT_PROMPT_TIMEOUT_MS).saturating_sub(elapsed) } -async fn run_editor_agent_prompt_with_timeout( - future: F, - duration: Duration, -) -> Result, PromptError> -where - F: IntoFuture, PromptError>>, -{ - timeout(duration, future.into_future()) - .await - .unwrap_or_else(|_| { - Err(PromptError::CompletionError( - EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE.to_string(), - )) - }) +fn editor_agent_prompt_deadline_error() -> PromptError { + PromptError::CompletionError(EDITOR_AGENT_PROMPT_TIMEOUT_MESSAGE.to_string()) +} + +fn split_prompt_result( + result: Result, PromptRunError>, +) -> (Vec, Option) { + match result { + Ok(outputs) => (outputs, None), + Err(error) => { + let (terminal_error, partial_outputs) = error.into_parts(); + (partial_outputs, Some(terminal_error)) + } + } +} + +fn append_terminal_error( + delta_messages: &mut Vec, + messages_offset: usize, + terminal_error: Option, +) { + if let Some(error) = terminal_error { + delta_messages.push(build_editor_agent_error_message( + messages_offset + delta_messages.len(), + error.display_with_agent_label("美术 Agent"), + )); + } } fn build_editor_agent_error_message( @@ -564,14 +574,14 @@ mod tests { crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) .expect("default editor pricing should load"); let messages = build_delta_messages( - Ok(vec![PromptOutput::Tool(ToolCallOutput { + vec![PromptOutput::Tool(ToolCallOutput { tool_call: ToolCall { id: "tool-call-1".to_string(), name: tool_name.to_string(), args: json!({ "prompt": "轻快冒险音乐" }), }, output: output.clone(), - })]), + })], "2026-07-23T00:00:00Z", 0, &EditorToolContext::default(), @@ -598,7 +608,7 @@ mod tests { crate::editor_generation_config::load_editor_generation_pricing_from_paths(None) .expect("default editor pricing should load"); let messages = build_delta_messages( - Ok(vec![PromptOutput::Tool(ToolCallOutput { + vec![PromptOutput::Tool(ToolCallOutput { tool_call: ToolCall { id: "tool-call-1".to_string(), name: GenerateVideoTool::NAME.to_string(), @@ -611,7 +621,7 @@ mod tests { }), }, output: json!({ "message": "runner pending output" }), - })]), + })], "2026-07-23T00:00:00Z", 0, &EditorToolContext::default(), @@ -642,15 +652,8 @@ mod tests { assert_eq!(display_value("sound"), Some("on")); } - #[tokio::test] - async fn prompt_timeout_applies_to_the_whole_agent_run() { - let error = run_editor_agent_prompt_with_timeout( - std::future::pending::, PromptError>>(), - Duration::from_millis(1), - ) - .await - .expect_err("pending agent run should hit the prompt deadline"); - + #[test] + fn prompt_deadline_applies_to_the_whole_agent_run() { assert_eq!(EDITOR_AGENT_PROMPT_TIMEOUT_MS, 1_080_000); assert_eq!( remaining_editor_agent_prompt_duration(Duration::from_secs(17 * 60)), @@ -661,37 +664,64 @@ mod tests { Duration::ZERO ); assert_eq!( - error.to_string(), + editor_agent_prompt_deadline_error() + .display_with_agent_label("美术 Agent") + .to_string(), "美术 Agent 规划失败:规划总时长已达到 18 分钟安全上限" ); } -} -fn editor_agent_system_prompt() -> &'static str { - r#" -* image_id str format is like: sha256:* -* when user referenced/uploaded image, a system message will notify you the image id(s). - YOU MUST USE THESE IMAGE IDs(or more from former context) IN YOUR TOOL CALLS.(or why user upload them?) -* to confirm a pending tool call, user should click a confirm button in their UI, instead of tell you "ok"/"confirm". - If in that case, you should tip the user to use the confirm button, instead of repeat that pending tool call. -* 用户所说的 规范图/参考图/生成的图/... 没有本质区别,all can be some image_id -* 实际生成工具由后端按模型定价扣泥点, 不能承诺免费生成 + #[test] + fn terminal_failure_keeps_partial_outputs_for_delta_persistence() { + let result = Err(PromptRunError::new( + PromptError::MaxTurnsReached { max_turns: 3 }, + vec![PromptOutput::Tool(ToolCallOutput { + tool_call: ToolCall { + id: "0".to_string(), + name: GenerateImageTool::NAME.to_string(), + args: json!({ + "prompt": "一座漂浮在云海上的城堡", + "reference_image_ids": [] + }), + }, + output: json!({ "message": "等待用户确认" }), + })], + )); -你是 Genarrative 图片画布 Agent,负责帮助用户理解、规划和触发画布生成工具. 对话回复要简短. -"# -} + let (outputs, terminal_error) = split_prompt_result(result); + let mut delta_messages = build_delta_messages( + outputs, + "2026-07-28T00:00:00Z", + 4, + &EditorToolContext::default(), + &EditorGenerationPricingConfig { + models: Default::default(), + }, + ) + .expect("successful partial tool output should still build a confirmation card"); + append_terminal_error(&mut delta_messages, 4, terminal_error); + assert_eq!(delta_messages.len(), 2); + assert_eq!(delta_messages[0].id, 4); + assert!(delta_messages[0].tool_call.is_some()); + assert_eq!(delta_messages[1].id, 5); + assert_eq!(delta_messages[1].role, EditorAgentMessageRole::System); + assert_eq!( + delta_messages[1].text, + "ERROR 美术 Agent 规划轮数已达上限:3" + ); + } +} fn build_delta_messages( - result: Result, PromptError>, + outputs: Vec, created_at: &str, messages_offset: usize, tool_context: &EditorToolContext, pricing: &EditorGenerationPricingConfig, ) -> Result, PromptError> { - let outputs = result?; let mut messages = Vec::with_capacity(outputs.len()); - for (i, out) in outputs.into_iter().enumerate() { - let absolute_idx = messages_offset + i; + for out in outputs { + let absolute_idx = messages_offset + messages.len(); match out { PromptOutput::Text(text) => { messages.push(EditorAgentMessage { @@ -741,6 +771,10 @@ fn build_delta_messages( created_at: created_at.to_string(), }); } + // Tool failures are retained by the shared harness for callers that need structured + // retry/diagnostic policy. The editor surface must not render them as confirmation + // cards; a terminal failure is appended below as the existing ERROR system message. + PromptOutput::ToolFailed(_) => {} } } diff --git a/server-rs/crates/platform-agent-harness/Cargo.toml b/server-rs/crates/platform-agent-harness/Cargo.toml new file mode 100644 index 000000000..be81ec120 --- /dev/null +++ b/server-rs/crates/platform-agent-harness/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "platform-agent-harness" +edition.workspace = true +version.workspace = true +license.workspace = true + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "time"] } diff --git a/server-rs/crates/platform-editor-agent/src/framework/agent.rs b/server-rs/crates/platform-agent-harness/src/agent.rs similarity index 86% rename from server-rs/crates/platform-editor-agent/src/framework/agent.rs rename to server-rs/crates/platform-agent-harness/src/agent.rs index 824123030..8cbd4105e 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/agent.rs +++ b/server-rs/crates/platform-agent-harness/src/agent.rs @@ -1,8 +1,8 @@ -use crate::framework::error::PromptError; -use crate::framework::hook::Hook; -use crate::framework::memory::AgentMemory; -use crate::framework::run::PromptRequest; -use crate::framework::tool::{Tool, ToolDyn}; +use crate::error::PromptError; +use crate::hook::Hook; +use crate::memory::AgentMemory; +use crate::run::PromptRequest; +use crate::tool::{Tool, ToolDyn}; pub struct Agent, Message> { pub model: M, @@ -76,4 +76,8 @@ pub trait LlmApiAdaptor: Send + Sync { fn tool_result_message(&self, tool_name: &str, output: &str) -> Message; fn build_assistant_message(&self, text: &str) -> Message; + + fn build_error_message(&self, error: &PromptError) -> Message { + self.tool_result_message("agent-error", &error.to_string()) + } } diff --git a/server-rs/crates/platform-editor-agent/src/framework/agent_builder.rs b/server-rs/crates/platform-agent-harness/src/agent_builder.rs similarity index 75% rename from server-rs/crates/platform-editor-agent/src/framework/agent_builder.rs rename to server-rs/crates/platform-agent-harness/src/agent_builder.rs index f2f90d8cc..eba53ceac 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/agent_builder.rs +++ b/server-rs/crates/platform-agent-harness/src/agent_builder.rs @@ -1,7 +1,7 @@ -use crate::framework::agent::{Agent, LlmApiAdaptor}; -use crate::framework::hook::Hook; -use crate::framework::memory::AgentMemory; -use crate::framework::tool::Tool; +use crate::agent::{Agent, LlmApiAdaptor}; +use crate::hook::Hook; +use crate::memory::AgentMemory; +use crate::tool::Tool; pub trait AgentBuilder> { type Client; diff --git a/server-rs/crates/platform-agent-harness/src/error.rs b/server-rs/crates/platform-agent-harness/src/error.rs new file mode 100644 index 000000000..e2713b847 --- /dev/null +++ b/server-rs/crates/platform-agent-harness/src/error.rs @@ -0,0 +1,100 @@ +#[derive(Debug, Clone)] +pub enum PromptError { + CompletionError(String), + ToolError(String), + InternalError(String), + MaxTurnsReached { max_turns: usize }, +} + +impl PromptError { + /// 使用业务入口自己的 Agent 名称展示错误,同时保持 harness 默认文案中性。 + pub fn display_with_agent_label<'a>(&'a self, agent_label: &'a str) -> PromptErrorDisplay<'a> { + PromptErrorDisplay { + error: self, + agent_label, + } + } + + fn fmt_with_agent_label( + &self, + formatter: &mut std::fmt::Formatter<'_>, + agent_label: &str, + ) -> std::fmt::Result { + match self { + Self::CompletionError(message) => { + write!(formatter, "{agent_label} 规划失败:{message}") + } + Self::ToolError(message) => { + write!(formatter, "{agent_label} 工具执行失败:{message}") + } + Self::InternalError(message) => { + write!(formatter, "{agent_label} 内部错误:{message}") + } + Self::MaxTurnsReached { max_turns } => { + write!(formatter, "{agent_label} 规划轮数已达上限:{max_turns}") + } + } + } +} + +pub struct PromptErrorDisplay<'a> { + error: &'a PromptError, + agent_label: &'a str, +} + +impl std::fmt::Display for PromptErrorDisplay<'_> { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt_with_agent_label(formatter, self.agent_label) + } +} + +impl std::fmt::Display for PromptError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fmt_with_agent_label(formatter, "Agent") + } +} + +impl std::error::Error for PromptError {} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_display_uses_neutral_agent_label() { + let error = PromptError::CompletionError("LLM 请求超时,累计尝试 2 次".to_string()); + + assert_eq!( + error.to_string(), + "Agent 规划失败:LLM 请求超时,累计尝试 2 次" + ); + } + + #[test] + fn caller_can_supply_a_surface_specific_agent_label() { + assert_eq!( + PromptError::CompletionError("LLM 请求超时".to_string()) + .display_with_agent_label("美术 Agent") + .to_string(), + "美术 Agent 规划失败:LLM 请求超时" + ); + assert_eq!( + PromptError::ToolError("参数无效".to_string()) + .display_with_agent_label("美术 Agent") + .to_string(), + "美术 Agent 工具执行失败:参数无效" + ); + assert_eq!( + PromptError::InternalError("序列化失败".to_string()) + .display_with_agent_label("美术 Agent") + .to_string(), + "美术 Agent 内部错误:序列化失败" + ); + assert_eq!( + PromptError::MaxTurnsReached { max_turns: 3 } + .display_with_agent_label("美术 Agent") + .to_string(), + "美术 Agent 规划轮数已达上限:3" + ); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/framework/hook.rs b/server-rs/crates/platform-agent-harness/src/hook.rs similarity index 80% rename from server-rs/crates/platform-editor-agent/src/framework/hook.rs rename to server-rs/crates/platform-agent-harness/src/hook.rs index c5fd255a3..e996854d2 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/hook.rs +++ b/server-rs/crates/platform-agent-harness/src/hook.rs @@ -1,5 +1,5 @@ -use crate::framework::run::{TextFlow, ToolCallFlow}; -use crate::framework::tool::ToolCall; +use crate::run::{TextFlow, ToolCallFlow}; +use crate::tool::ToolCall; pub trait Hook: Send + Sync { fn on_text_reply(&self, _text: &str) -> TextFlow { diff --git a/server-rs/crates/platform-agent-harness/src/lib.rs b/server-rs/crates/platform-agent-harness/src/lib.rs new file mode 100644 index 000000000..271292025 --- /dev/null +++ b/server-rs/crates/platform-agent-harness/src/lib.rs @@ -0,0 +1,10 @@ +//! 与具体业务 Agent、模型和界面无关的 function-calling harness。 + +pub mod agent; +pub mod agent_builder; +pub mod error; +pub mod hook; +pub mod memory; +pub mod prompt; +pub mod run; +pub mod tool; diff --git a/server-rs/crates/platform-agent-harness/src/memory.rs b/server-rs/crates/platform-agent-harness/src/memory.rs new file mode 100644 index 000000000..29c97266a --- /dev/null +++ b/server-rs/crates/platform-agent-harness/src/memory.rs @@ -0,0 +1,57 @@ +//! Agent 对话记忆的最小公共抽象。 + +use serde::{Deserialize, Serialize}; + +pub trait AgentMemoryBuffer: Send + Sync { + fn get_memory(&self) -> &[Message]; + fn append_message(&mut self, message: Message); +} + +pub trait AgentMemory: AgentMemoryBuffer { + /// 创建与当前 memory 行为等价、但写入彼此隔离的本轮事务副本。 + fn begin_staged(&self) -> Box>; +} + +pub trait StagedAgentMemory: AgentMemoryBuffer { + /// 显式提交本轮事务,并返回下一轮继续使用的 committed memory。 + /// + /// 持久化实现应在这里发布 staged 写入;直接 drop staged transaction 表示回滚。 + fn commit(self: Box) -> Box>; +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VecMemory { + messages: Vec, +} + +impl VecMemory { + pub fn new(messages: Vec) -> Self { + Self { messages } + } + + pub fn into_inner(self) -> Vec { + self.messages + } +} + +impl AgentMemoryBuffer for VecMemory { + fn get_memory(&self) -> &[Message] { + &self.messages + } + + fn append_message(&mut self, message: Message) { + self.messages.push(message); + } +} + +impl AgentMemory for VecMemory { + fn begin_staged(&self) -> Box> { + Box::new(self.clone()) + } +} + +impl StagedAgentMemory for VecMemory { + fn commit(self: Box) -> Box> { + self + } +} diff --git a/server-rs/crates/platform-agent-harness/src/prompt.rs b/server-rs/crates/platform-agent-harness/src/prompt.rs new file mode 100644 index 000000000..593eeffde --- /dev/null +++ b/server-rs/crates/platform-agent-harness/src/prompt.rs @@ -0,0 +1,105 @@ +use serde_json::Value; + +pub const PENDING_USER_CONFIRMATION_MESSAGE: &str = + "tool call is pending user confirmation; end this turn when all tool calls are pending"; + +#[derive(Debug, Clone, PartialEq)] +pub struct ToolPromptSpec { + pub name: String, + pub description: String, + pub parameters: Value, +} + +/// 把业务 system prompt、统一 JSON function-calling 输出契约和工具 schema 组合起来。 +pub fn build_tools_system_prompt(base_prompt: &str, tool_specs: &[ToolPromptSpec]) -> String { + let mut prompt = String::new(); + prompt.push_str(base_prompt); + prompt.push_str("\n\nYou have access to the following tools.\n\n"); + prompt.push_str("## JSON Response Format\n"); + prompt.push_str("respond with valid JSON only (no markdown fences):\n"); + prompt.push_str("{\n"); + prompt.push_str(" \"reply_text\": \"your message to the user\",\n"); + prompt.push_str(" \"tool_calls\": [\n {\n"); + prompt.push_str(" \"tool_name\": \"tool_name_here\",\n"); + prompt.push_str(" \"args\": { \"argument_name\": \"argument_value\" }\n"); + prompt.push_str(" }\n ]\n"); + prompt.push_str("}\n\n"); + prompt.push_str("If you don't need to use a tool, respond with:\n"); + prompt.push_str("{\n"); + prompt.push_str(" \"reply_text\": \"your message\",\n"); + prompt.push_str(" \"tool_calls\": []\n"); + prompt.push_str("}\n\n"); + prompt.push_str("## Available Tools\n\n"); + + if tool_specs.is_empty() { + prompt.push_str("(No tools available.)\n"); + } else { + for tool in tool_specs { + prompt.push_str(&format!("- {}\n", tool.name)); + prompt.push_str(&format!(" Description: {}\n", tool.description)); + prompt.push_str(" Arguments JSON Schema:\n"); + let parameters = serde_json::to_string_pretty(&tool.parameters) + .unwrap_or_else(|_| tool.parameters.to_string()); + prompt.push_str(¶meters); + prompt.push('\n'); + } + + prompt.push_str("tool_calls can contain multiple calls in one turn. "); + prompt.push_str("Calls are executed sequentially in array order. "); + prompt.push_str("Valid tool calls are recorded as system messages. "); + prompt.push_str( + "Some tools require user confirmation. Do not retry the same tool call while it is pending. \ + If all tool calls are pending confirmation, end the turn and wait for the user's action.", + ); + } + + prompt +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tool_prompt_keeps_the_shared_json_and_confirmation_contract() { + let prompt = build_tools_system_prompt( + "业务提示词", + &[ToolPromptSpec { + name: "generate-image".to_string(), + description: "生成图片".to_string(), + parameters: json!({ + "type": "object", + "properties": { "prompt": { "type": "string" } }, + "required": ["prompt"] + }), + }], + ); + + assert!(prompt.starts_with("业务提示词")); + assert!(prompt.contains("\"reply_text\"")); + assert!(prompt.contains("\"tool_calls\"")); + assert!(prompt.contains("- generate-image")); + assert!(prompt.contains("\"required\": [")); + assert!(prompt.contains("Do not retry the same tool call")); + assert!(prompt.contains("all tool calls are pending confirmation")); + assert!(prompt.contains("executed sequentially in array order")); + assert!(!prompt.contains("concurrently")); + } + + #[test] + fn tool_prompt_preserves_the_no_tools_shape() { + let prompt = build_tools_system_prompt("基础提示词", &[]); + + assert!(prompt.starts_with("基础提示词")); + assert!(prompt.contains("## JSON Response Format")); + assert!(prompt.contains("\"tool_calls\": []")); + assert!(prompt.ends_with("## Available Tools\n\n(No tools available.)\n")); + } + + #[test] + fn pending_confirmation_message_is_shared_control_flow_copy() { + assert!(PENDING_USER_CONFIRMATION_MESSAGE.contains("pending user confirmation")); + assert!(PENDING_USER_CONFIRMATION_MESSAGE.contains("end this turn")); + } +} diff --git a/server-rs/crates/platform-agent-harness/src/run.rs b/server-rs/crates/platform-agent-harness/src/run.rs new file mode 100644 index 000000000..3c1dc5744 --- /dev/null +++ b/server-rs/crates/platform-agent-harness/src/run.rs @@ -0,0 +1,1581 @@ +use crate::agent::Agent; +use crate::agent::LlmApiAdaptor; +use crate::error::PromptError; +use crate::hook::Hook; +use crate::memory::{AgentMemory, StagedAgentMemory, VecMemory}; +use crate::run::PromptOutput::{Text, Tool}; +use crate::tool::{ToolCall, ToolExecutionResult, ToolFailure, ToolOutcome}; +use serde::Deserialize; +use serde_json::Value; +use std::future::{Future, poll_fn}; +use std::pin::Pin; +use std::task::Poll; + +pub type TextOutput = String; + +#[derive(Debug, Clone)] +pub struct ToolCallOutput { + pub tool_call: ToolCall, + pub output: Value, +} + +#[derive(Debug, Clone)] +pub struct ToolFailureOutput { + pub tool_call: ToolCall, + pub message: String, + pub output: Value, + pub failure: ToolFailure, +} + +#[derive(Debug, Clone)] +pub enum PromptOutput { + Text(TextOutput), + Tool(ToolCallOutput), + ToolFailed(ToolFailureOutput), +} + +#[derive(Debug, Clone)] +pub struct PromptRunError { + pub error: PromptError, + pub partial_outputs: Vec, +} + +impl PromptRunError { + pub fn new(error: PromptError, partial_outputs: Vec) -> Self { + Self { + error, + partial_outputs, + } + } + + pub fn has_tool_activity(&self) -> bool { + self.partial_outputs + .iter() + .any(|output| matches!(output, PromptOutput::Tool(_) | PromptOutput::ToolFailed(_))) + } + + pub fn into_parts(self) -> (PromptError, Vec) { + (self.error, self.partial_outputs) + } +} + +impl std::fmt::Display for PromptRunError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.error.fmt(formatter) + } +} + +impl std::error::Error for PromptRunError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.error) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TextFlow { + Continue, + Stop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ToolCallFlow { + Continue, + Skip, + Stop, +} + +pub fn format_tool_call_message( + tool_call_id: impl std::fmt::Display, + args: &Value, + output: &Value, +) -> Result { + let arg_json = serde_json::to_string(args) + .map_err(|error| PromptError::InternalError(error.to_string()))?; + let output_json = serde_json::to_string(output) + .map_err(|error| PromptError::InternalError(error.to_string()))?; + Ok(format!( + "[tool_call:{tool_call_id}] args: {arg_json} output: {output_json}" + )) +} + +pub struct PromptRequest<'a, M: LlmApiAdaptor + 'a, Message: 'a> { + agent: &'a mut Agent, + message: Message, + system_prompt: Option, + hooks: Vec>, + max_turns: usize, + deadline: Option>, +} + +struct PromptDeadline<'a> { + future: Pin + Send + 'a>>, + error: PromptError, +} + +struct PromptMemoryTransaction<'a, Message: Send + Sync + 'static> { + committed: &'a mut Option>>, + staged: Option>>, + cancellation_message: Option, + completed_tool_activity: bool, + in_flight_tool_message: Option, + finalized: bool, +} + +impl<'a, Message: Send + Sync + 'static> PromptMemoryTransaction<'a, Message> { + fn new( + committed: &'a mut Option>>, + staged: Box>, + cancellation_message: Message, + ) -> Self { + Self { + committed, + staged: Some(staged), + cancellation_message: Some(cancellation_message), + completed_tool_activity: false, + in_flight_tool_message: None, + finalized: false, + } + } + + fn get_memory(&self) -> &[Message] { + self.staged + .as_ref() + .expect("staged memory exists") + .get_memory() + } + + fn append_message(&mut self, message: Message) { + self.staged + .as_mut() + .expect("staged memory exists") + .append_message(message); + } + + fn begin_tool(&mut self, started_message: Message) { + self.in_flight_tool_message = Some(started_message); + } + + fn finish_tool(&mut self) { + self.in_flight_tool_message = None; + self.completed_tool_activity = true; + } + + fn mark_tool_activity(&mut self) { + self.completed_tool_activity = true; + } + + fn has_tool_activity(&self) -> bool { + self.completed_tool_activity || self.in_flight_tool_message.is_some() + } + + fn commit_staged(&mut self) { + let staged = self.staged.take().expect("staged memory exists"); + *self.committed = Some(staged.commit()); + } + + fn finalize(mut self, commit: bool, terminal_message: Option) { + self.finalized = true; + if commit { + if let Some(message) = terminal_message { + self.append_message(message); + } + self.commit_staged(); + } + } +} + +impl Drop for PromptMemoryTransaction<'_, Message> { + fn drop(&mut self) { + if self.finalized || !self.has_tool_activity() { + return; + } + if let Some(message) = self.in_flight_tool_message.take() { + self.append_message(message); + } + if let Some(message) = self.cancellation_message.take() { + self.append_message(message); + } + self.commit_staged(); + } +} + +impl<'a, M, Message> PromptRequest<'a, M, Message> +where + M: LlmApiAdaptor + 'a, + Message: 'a, +{ + pub fn new(agent: &'a mut Agent, message: Message) -> Self { + let max_turns = agent.default_max_turns; + Self { + agent, + message, + system_prompt: None, + hooks: Vec::new(), + max_turns, + deadline: None, + } + } + + pub fn system_prompt(mut self, msg: Message) -> Self { + self.system_prompt = Some(msg); + self + } + + pub fn add_hook(mut self, hook: impl Hook + 'static) -> Self { + self.hooks.push(Box::new(hook)); + self + } + + pub fn max_turns(mut self, n: usize) -> Self { + self.max_turns = n; + self + } + + /// 在调用方提供的 deadline future 完成时,从 runner 内部正常收口当前执行进度。 + /// + /// 这与从外部 drop `PromptRequest` 不同:已完成工具会进入 `partial_outputs`,memory + /// 也会按正常失败事务边界提交或回滚。deadline future 由具体 runtime 提供,因此 + /// 公共 harness 不绑定 Tokio 或其它异步运行时。 + pub fn deadline( + mut self, + future: impl Future + Send + 'a, + error: PromptError, + ) -> Self { + self.deadline = Some(PromptDeadline { + future: Box::pin(future), + error, + }); + self + } +} + +async fn await_with_deadline( + future: F, + deadline: &mut Option>, +) -> Result +where + F: Future + Send, +{ + let Some(deadline) = deadline else { + return Ok(future.await); + }; + let mut future = Box::pin(future); + poll_fn(|context| { + if let Poll::Ready(()) = deadline.future.as_mut().poll(context) { + return Poll::Ready(Err(deadline.error.clone())); + } + if let Poll::Ready(output) = future.as_mut().poll(context) { + return Poll::Ready(Ok(output)); + } + Poll::Pending + }) + .await +} + +async fn ensure_deadline_not_elapsed( + deadline: &mut Option>, +) -> Result<(), PromptError> { + await_with_deadline(std::future::ready(()), deadline).await +} + +impl<'a, M, Message> IntoFuture for PromptRequest<'a, M, Message> +where + M: LlmApiAdaptor + Send + Sync + 'a, + Message: Send + Sync + Clone + 'a + 'static, +{ + type Output = Result, PromptRunError>; + type IntoFuture = Pin + Send + 'a>>; + + fn into_future(self) -> Self::IntoFuture { + let agent = self.agent; + let message = self.message; + let request_system_prompt = self.system_prompt; + let extra_hooks = self.hooks; + let max_turns = self.max_turns; + let mut deadline = self.deadline; + + Box::pin(async move { + let Agent { + model, + tools, + hooks: agent_hooks, + system_prompt: agent_system_prompt, + memory: committed_memory, + .. + } = agent; + let staged_memory = committed_memory + .as_ref() + .map(|memory| memory.begin_staged()) + .unwrap_or_else(|| { + Box::new(VecMemory::new(Vec::new())) as Box> + }); + let cancellation_message = model.build_error_message(&PromptError::CompletionError( + "prompt future cancelled after tool activity; reconcile before retry".to_string(), + )); + let mut memory = + PromptMemoryTransaction::new(committed_memory, staged_memory, cancellation_message); + memory.append_message(message); + + let outcome: Result, PromptRunError> = async { + let mut prompt_result: Vec = Vec::new(); + + for _ in 0..max_turns { + let text = { + let messages = agent_system_prompt + .iter() + .chain(request_system_prompt.iter()) + .chain(memory.get_memory().iter()); + await_with_deadline(model.complete(messages), &mut deadline) + .await + .map_err(|error| { + PromptRunError::new(error, prompt_result.clone()) + })? + .map_err(|error| { + PromptRunError::new(error, prompt_result.clone()) + })? + }; + + // Try to parse the LLM reply as JSON (handle Markdown fences) + let cleaned = clean_json_response(&text); + match serde_json::from_str::(&cleaned) { + Ok(json_resp) => { + let clean_text = json_resp.reply_text.trim().to_string(); + + // Run on_text_reply hooks + for hook in agent_hooks.iter().chain(extra_hooks.iter()) { + match hook.on_text_reply(&clean_text) { + TextFlow::Stop => { + return Err(PromptRunError::new( + PromptError::ToolError( + "text reply rejected by hook".to_string(), + ), + prompt_result, + )); + } + TextFlow::Continue => {} + } + } + memory.append_message(model.build_assistant_message(&clean_text)); + prompt_result.push(Text(clean_text.clone())); + + let tool_calls: Vec = json_resp + .tool_calls + .into_iter() + .enumerate() + .map(|(idx, tc)| ToolCall { + id: format!("{idx}"), + name: tc.tool_name, + args: tc.args, + }) + .collect(); + + // no tool call, turn terminate. + if tool_calls.is_empty() { + return Ok(prompt_result); + } + + let mut all_tool_calls_await_user_confirmation = true; + for (tc_id, tc) in tool_calls.iter().enumerate() { + // inline run_hooks: before_tool_call hook + let mut should_skip = false; + for hook in agent_hooks.iter().chain(extra_hooks.iter()) { + match hook.before_tool_call(tc) { + ToolCallFlow::Stop => { + return Err(PromptRunError::new( + PromptError::ToolError( + "tool call rejected by hook".to_string(), + ), + prompt_result, + )); + } + ToolCallFlow::Skip => { + let msg = model.tool_result_message( + &tc.name, + "(skipped by hook)", + ); + memory.append_message(msg); + should_skip = true; + break; + } + ToolCallFlow::Continue => {} + } + } + if should_skip { + all_tool_calls_await_user_confirmation = false; + continue; + } + + ensure_deadline_not_elapsed(&mut deadline) + .await + .map_err(|error| { + PromptRunError::new(error, prompt_result.clone()) + })?; + + let matching_tool = + tools.iter().find(|tool| tool.tool_name() == tc.name); + let requires_user_confirmation = matching_tool + .is_some_and(|tool| tool.requires_user_confirmation()); + let result = match matching_tool { + Some(tool) => { + let arg_json = serde_json::to_string(&tc.args).map_err( + |error| { + PromptRunError::new( + PromptError::InternalError(error.to_string()), + prompt_result.clone(), + ) + }, + )?; + memory.begin_tool(model.tool_result_message( + &tc.name, + &format!( + "[tool_call:{tc_id}] started with args: {arg_json}; result unknown because prompt execution was cancelled" + ), + )); + let result = tool.call(tc.args.clone()).await; + memory.finish_tool(); + result + } + None => { + memory.mark_tool_activity(); + ToolExecutionResult::failed( + Value::Null, + ToolFailure::invalid_args(format!( + "unknown tool: {}", + tc.name + )), + ) + } + }; + if !requires_user_confirmation + || !matches!(&result.outcome, ToolOutcome::InternalOk) + { + all_tool_calls_await_user_confirmation = false; + } + + match result.outcome { + ToolOutcome::InternalOk => { + let mut json_output = result.output; + let mut hook_stop_error = None; + // Run after_tool_call hooks to allow output modification + for hook in agent_hooks.iter().chain(extra_hooks.iter()) { + match hook.after_tool_call(&tc.name, &mut json_output) { + ToolCallFlow::Stop => { + hook_stop_error = Some(PromptError::ToolError( + "tool call output caused this turn to stop by hook" + .to_string(), + )); + break; + } + ToolCallFlow::Skip => { + all_tool_calls_await_user_confirmation = false; + json_output = serde_json::json!({"message":"tool call is ignored by hook"}); + break; + } + ToolCallFlow::Continue => {} + } + } + let overall_message = + format_tool_call_message(tc_id, &tc.args, &json_output) + .map_err(|error| { + PromptRunError::new( + error, + prompt_result.clone(), + ) + })?; + let msg = model.tool_result_message(&tc.name, &overall_message); + memory.append_message(msg); + prompt_result.push(Tool(ToolCallOutput { + tool_call: tc.clone(), + output: json_output, + })); + if let Some(error) = hook_stop_error { + return Err(PromptRunError::new(error, prompt_result)); + } + } + ToolOutcome::InternalError(failure) => { + let failure_payload = serde_json::json!({ + "status": "failed", + "failure": &failure, + "output": &result.output, + }); + let failure_json = serde_json::to_string(&failure_payload) + .map_err(|error| { + PromptRunError::new( + PromptError::InternalError(error.to_string()), + prompt_result.clone(), + ) + })?; + let overall_message = format!( + "[tool_call:{tc_id}] failure: {failure_json}" + ); + let msg = model.tool_result_message(&tc.name, &overall_message); + memory.append_message(msg); + let fatal = failure.fatal; + let error_message = failure.message.clone(); + prompt_result.push(PromptOutput::ToolFailed( + ToolFailureOutput { + tool_call: tc.clone(), + message: overall_message, + output: result.output, + failure, + }, + )); + if fatal { + return Err(PromptRunError::new( + PromptError::ToolError(error_message), + prompt_result, + )); + } + } + } + + ensure_deadline_not_elapsed(&mut deadline) + .await + .map_err(|error| { + PromptRunError::new(error, prompt_result.clone()) + })?; + } + + if all_tool_calls_await_user_confirmation { + return Ok(prompt_result); + } + } + Err(_) => { + // Not valid JSON — retain only inside this staged turn until success. + memory.append_message(model.build_assistant_message(&text)); + continue; + } + } + } + + Err(PromptRunError::new( + PromptError::MaxTurnsReached { max_turns }, + prompt_result, + )) + } + .await; + + let commit_staged_memory = outcome.is_ok() || memory.has_tool_activity(); + let terminal_message = outcome + .as_ref() + .err() + .map(|error| model.build_error_message(&error.error)); + memory.finalize(commit_staged_memory, terminal_message); + outcome + }) + } +} + +#[derive(Deserialize)] +struct LlmJsonResponse { + reply_text: String, + #[serde(default)] + tool_calls: Vec, +} + +#[derive(Deserialize)] +struct LlmToolCallRequest { + tool_name: String, + #[serde(default)] + args: Value, +} + +pub fn clean_json_response(text: &str) -> String { + let text = text.trim(); + if text.starts_with("```") { + let lines: Vec<&str> = text.lines().collect(); + let mut cleaned = Vec::new(); + let mut in_code = false; + for line in lines { + if line.trim().starts_with("```") { + in_code = !in_code; + continue; + } + if in_code { + cleaned.push(line); + } + } + if !cleaned.is_empty() { + return cleaned.join("\n").trim().to_string(); + } + } + text.to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agent::LlmApiAdaptor; + use crate::hook::Hook; + use crate::tool::{Tool, ToolDyn, ToolFailureKind}; + use serde_json::json; + use std::convert::Infallible; + use std::sync::Arc; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct RepeatingToolCallModel { + completion_count: Arc, + } + + impl LlmApiAdaptor for RepeatingToolCallModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + self.completion_count.fetch_add(1, Ordering::SeqCst); + Ok(json!({ + "reply_text": "请确认这次生成", + "tool_calls": [{ + "tool_name": "test-tool", + "args": { "prompt": "生成一张图" } + }] + }) + .to_string()) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + struct TestTool { + requires_user_confirmation: bool, + } + + struct CapturingModel { + messages: Arc>>, + } + + struct OrderedBatchModel; + + struct FailingCompletionModel; + + struct PendingCompletionModel; + + struct ToolThenPendingModel { + completion_count: Arc, + } + + struct SlowToolCallModel; + + struct FailingToolCallModel { + include_successful_tool: bool, + } + + impl LlmApiAdaptor for OrderedBatchModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + Ok(json!({ + "reply_text": "请确认这批操作", + "tool_calls": [ + { "tool_name": "ordered-tool", "args": { "order": 2 } }, + { "tool_name": "ordered-tool", "args": { "order": 1 } } + ] + }) + .to_string()) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + impl LlmApiAdaptor for FailingCompletionModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + Err(PromptError::CompletionError( + "provider unavailable".to_string(), + )) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + impl LlmApiAdaptor for PendingCompletionModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + std::future::pending().await + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + impl LlmApiAdaptor for ToolThenPendingModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + if self.completion_count.fetch_add(1, Ordering::SeqCst) == 0 { + return Ok(json!({ + "reply_text": "先执行一个工具", + "tool_calls": [{ "tool_name": "test-tool", "args": {} }] + }) + .to_string()); + } + std::future::pending().await + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + impl LlmApiAdaptor for SlowToolCallModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + Ok(json!({ + "reply_text": "执行慢工具", + "tool_calls": [{ "tool_name": "slow-effect-tool", "args": {} }] + }) + .to_string()) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + impl LlmApiAdaptor for FailingToolCallModel { + async fn complete<'a>( + &self, + _messages: impl Iterator + Send, + ) -> Result { + let mut tool_calls = Vec::new(); + if self.include_successful_tool { + tool_calls.push(json!({ "tool_name": "test-tool", "args": {} })); + } + tool_calls.push(json!({ "tool_name": "failing-tool", "args": {} })); + Ok(json!({ + "reply_text": "执行工具", + "tool_calls": tool_calls, + }) + .to_string()) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + struct OrderedTool { + execution_order: Arc>>, + } + + struct FailingToolDyn { + fatal: bool, + } + + struct SlowEffectTool { + started: Arc, + duration: std::time::Duration, + } + + #[derive(Clone)] + struct TailMemory { + messages: Vec, + max_messages: usize, + } + + impl crate::memory::AgentMemoryBuffer for TailMemory { + fn get_memory(&self) -> &[String] { + &self.messages + } + + fn append_message(&mut self, message: String) { + self.messages.push(message); + let overflow = self.messages.len().saturating_sub(self.max_messages); + if overflow > 0 { + self.messages.drain(..overflow); + } + } + } + + impl AgentMemory for TailMemory { + fn begin_staged(&self) -> Box> { + Box::new(self.clone()) + } + } + + impl StagedAgentMemory for TailMemory { + fn commit(self: Box) -> Box> { + self + } + } + + #[derive(Clone)] + struct CommitTrackingMemory { + messages: Vec, + commits: Arc, + } + + struct CommitTrackingStagedMemory { + messages: Vec, + commits: Arc, + } + + impl crate::memory::AgentMemoryBuffer for CommitTrackingMemory { + fn get_memory(&self) -> &[String] { + &self.messages + } + + fn append_message(&mut self, message: String) { + self.messages.push(message); + } + } + + impl AgentMemory for CommitTrackingMemory { + fn begin_staged(&self) -> Box> { + Box::new(CommitTrackingStagedMemory { + messages: self.messages.clone(), + commits: self.commits.clone(), + }) + } + } + + impl crate::memory::AgentMemoryBuffer for CommitTrackingStagedMemory { + fn get_memory(&self) -> &[String] { + &self.messages + } + + fn append_message(&mut self, message: String) { + self.messages.push(message); + } + } + + impl StagedAgentMemory for CommitTrackingStagedMemory { + fn commit(self: Box) -> Box> { + self.commits.fetch_add(1, Ordering::SeqCst); + Box::new(CommitTrackingMemory { + messages: self.messages, + commits: self.commits, + }) + } + } + + impl LlmApiAdaptor for CapturingModel { + async fn complete<'a>( + &self, + messages: impl Iterator + Send, + ) -> Result { + *self.messages.lock().expect("messages lock should succeed") = + messages.cloned().collect(); + Ok(json!({ + "reply_text": "完成", + "tool_calls": [] + }) + .to_string()) + } + + fn tool_result_message(&self, tool_name: &str, output: &str) -> String { + format!("{tool_name}: {output}") + } + + fn build_assistant_message(&self, text: &str) -> String { + text.to_string() + } + } + + struct SkipAfterToolCallHook; + + struct StopAfterToolCallHook; + + impl Hook for SkipAfterToolCallHook { + fn after_tool_call(&self, _tool_name: &str, _output: &mut Value) -> ToolCallFlow { + ToolCallFlow::Skip + } + } + + impl Hook for StopAfterToolCallHook { + fn after_tool_call(&self, _tool_name: &str, _output: &mut Value) -> ToolCallFlow { + ToolCallFlow::Stop + } + } + + impl Tool for TestTool { + const NAME: &'static str = "test-tool"; + type Error = Infallible; + type Args = Value; + type Output = Value; + + fn description(&self) -> String { + "test tool".to_string() + } + + fn parameters(&self) -> Value { + json!({ "type": "object" }) + } + + fn call( + &self, + _args: Self::Args, + ) -> impl Future> + Send { + async { Ok(json!({ "message": "pending user confirmation" })) } + } + + fn requires_user_confirmation(&self) -> bool { + self.requires_user_confirmation + } + } + + impl Tool for SlowEffectTool { + const NAME: &'static str = "slow-effect-tool"; + type Error = Infallible; + type Args = Value; + type Output = Value; + + fn description(&self) -> String { + "slow effect tool".to_string() + } + + fn parameters(&self) -> Value { + json!({ "type": "object" }) + } + + fn call( + &self, + _args: Self::Args, + ) -> impl Future> + Send { + async move { + self.started.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(self.duration).await; + Ok(json!({ "message": "effect completed" })) + } + } + } + + impl Tool for OrderedTool { + const NAME: &'static str = "ordered-tool"; + type Error = Infallible; + type Args = Value; + type Output = Value; + + fn description(&self) -> String { + "ordered test tool".to_string() + } + + fn parameters(&self) -> Value { + json!({ "type": "object" }) + } + + fn call( + &self, + args: Self::Args, + ) -> impl Future> + Send { + async move { + let order = args["order"] + .as_u64() + .expect("ordered tool should receive an order"); + self.execution_order + .lock() + .expect("execution order lock should succeed") + .push(order); + Ok(json!({ "message": "pending user confirmation" })) + } + } + + fn requires_user_confirmation(&self) -> bool { + true + } + } + + impl ToolDyn for FailingToolDyn { + fn tool_name(&self) -> &'static str { + "failing-tool" + } + + fn description(&self) -> String { + "failing test tool".to_string() + } + + fn parameters(&self) -> Value { + json!({ "type": "object" }) + } + + fn requires_user_confirmation(&self) -> bool { + false + } + + fn call( + &self, + _args: Value, + ) -> Pin + Send + '_>> { + Box::pin(async move { + ToolExecutionResult::failed( + json!({ "attempt": 1 }), + ToolFailure::new(ToolFailureKind::Network, "network failed") + .with_fatal(self.fatal), + ) + }) + } + } + + #[tokio::test] + async fn request_system_prompt_is_added_after_the_agent_system_prompt() { + let captured_messages = Arc::new(Mutex::new(Vec::new())); + let model = CapturingModel { + messages: captured_messages.clone(), + }; + let mut agent = Agent::new(model).system_prompt("Agent system".to_string()); + + let outputs = agent + .prompt("User message".to_string()) + .system_prompt("Request system".to_string()) + .await + .expect("request prompt should succeed"); + + assert_eq!(outputs.len(), 1); + assert_eq!( + *captured_messages + .lock() + .expect("messages lock should succeed"), + vec![ + "Agent system".to_string(), + "Request system".to_string(), + "User message".to_string(), + ] + ); + } + + #[tokio::test] + async fn completion_failure_rolls_back_the_staged_user_message() { + let mut agent = Agent::new(FailingCompletionModel) + .memory(VecMemory::new(vec!["prior message".to_string()])); + + let error = agent + .prompt("new user message".to_string()) + .await + .expect_err("completion should fail"); + + assert!(matches!(error.error, PromptError::CompletionError(_))); + assert!(error.partial_outputs.is_empty()); + assert_eq!( + agent + .memory + .as_ref() + .expect("existing memory should be restored") + .get_memory(), + &["prior message".to_string()] + ); + } + + #[tokio::test] + async fn dropping_a_pending_prompt_keeps_the_original_memory() { + let mut agent = Agent::new(PendingCompletionModel) + .memory(VecMemory::new(vec!["prior message".to_string()])); + + let timeout = tokio::time::timeout( + std::time::Duration::from_millis(1), + agent.prompt("new user message".to_string()), + ) + .await; + + assert!(timeout.is_err()); + assert_eq!( + agent + .memory + .as_ref() + .expect("external cancellation must keep committed memory") + .get_memory(), + &["prior message".to_string()] + ); + } + + #[tokio::test] + async fn dropping_after_a_completed_tool_commits_the_fact_and_cancellation_closure() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let commits = Arc::new(AtomicUsize::new(0)); + let mut agent = Agent::new(ToolThenPendingModel { + completion_count: completion_count.clone(), + }) + .tool(TestTool { + requires_user_confirmation: false, + }) + .memory(CommitTrackingMemory { + messages: vec!["prior message".to_string()], + commits: commits.clone(), + }); + + let timeout = tokio::time::timeout( + std::time::Duration::from_millis(10), + agent.prompt("execute then wait".to_string()), + ) + .await; + + assert!(timeout.is_err()); + assert_eq!(completion_count.load(Ordering::SeqCst), 2); + assert_eq!(commits.load(Ordering::SeqCst), 1); + let memory = agent + .memory + .as_ref() + .expect("completed tool cancellation should commit memory") + .get_memory(); + assert!( + memory + .iter() + .any(|message| message.starts_with("test-tool:")) + ); + assert!(memory.last().is_some_and(|message| { + message.contains("prompt future cancelled after tool activity") + })); + } + + #[tokio::test] + async fn dropping_an_in_flight_tool_commits_an_unknown_result_fact() { + let started = Arc::new(AtomicUsize::new(0)); + let mut agent = Agent::new(SlowToolCallModel) + .tool(SlowEffectTool { + started: started.clone(), + duration: std::time::Duration::from_secs(60), + }) + .memory(VecMemory::new(vec!["prior message".to_string()])); + + let timeout = tokio::time::timeout( + std::time::Duration::from_millis(10), + agent.prompt("start slow effect".to_string()), + ) + .await; + + assert!(timeout.is_err()); + assert_eq!(started.load(Ordering::SeqCst), 1); + let memory = agent + .memory + .as_ref() + .expect("in-flight cancellation should commit memory") + .get_memory(); + assert!(memory.iter().any(|message| { + message.contains("slow-effect-tool") && message.contains("result unknown") + })); + assert!(memory.last().is_some_and(|message| { + message.contains("prompt future cancelled after tool activity") + })); + } + + #[tokio::test] + async fn internal_deadline_returns_completed_tools_and_closes_memory() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let mut agent = Agent::new(ToolThenPendingModel { + completion_count: completion_count.clone(), + }) + .tool(TestTool { + requires_user_confirmation: false, + }); + + let error = agent + .prompt("执行后等待".to_string()) + .deadline( + tokio::time::sleep(std::time::Duration::from_millis(1)), + PromptError::CompletionError("total deadline reached".to_string()), + ) + .await + .expect_err("runner deadline should terminate the pending completion"); + + assert_eq!(completion_count.load(Ordering::SeqCst), 2); + assert!(matches!(error.error, PromptError::CompletionError(_))); + assert_eq!(error.partial_outputs.len(), 2); + assert!(matches!(error.partial_outputs[1], PromptOutput::Tool(_))); + assert_eq!( + agent + .memory + .as_ref() + .expect("completed tool activity should commit staged memory") + .get_memory() + .last() + .map(String::as_str), + Some("agent-error: Agent 规划失败:total deadline reached") + ); + } + + #[tokio::test] + async fn internal_deadline_does_not_cancel_an_in_flight_tool() { + let started = Arc::new(AtomicUsize::new(0)); + let mut agent = Agent::new(SlowToolCallModel).tool(SlowEffectTool { + started: started.clone(), + duration: std::time::Duration::from_millis(30), + }); + + let error = agent + .prompt("run effect safely".to_string()) + .deadline( + tokio::time::sleep(std::time::Duration::from_millis(10)), + PromptError::CompletionError("total deadline reached".to_string()), + ) + .await + .expect_err("deadline should close after the started tool returns"); + + assert_eq!(started.load(Ordering::SeqCst), 1); + assert!(matches!(error.error, PromptError::CompletionError(_))); + assert!(matches!(error.partial_outputs[1], PromptOutput::Tool(_))); + assert_eq!( + agent + .memory + .as_ref() + .expect("completed tool should commit before deadline closure") + .get_memory() + .last() + .map(String::as_str), + Some("agent-error: Agent 规划失败:total deadline reached") + ); + } + + #[tokio::test] + async fn elapsed_deadline_wins_before_polling_the_next_operation() { + let captured_messages = Arc::new(Mutex::new(Vec::new())); + let model = CapturingModel { + messages: captured_messages.clone(), + }; + let mut agent = Agent::new(model).memory(VecMemory::new(vec!["prior message".to_string()])); + + let error = agent + .prompt("new user message".to_string()) + .deadline( + std::future::ready(()), + PromptError::CompletionError("deadline already elapsed".to_string()), + ) + .await + .expect_err("elapsed deadline should win before completion is polled"); + + assert!(matches!(error.error, PromptError::CompletionError(_))); + assert!(error.partial_outputs.is_empty()); + assert!( + captured_messages + .lock() + .expect("messages lock should succeed") + .is_empty() + ); + assert_eq!( + agent + .memory + .as_ref() + .expect("no-tool deadline should retain committed memory") + .get_memory(), + &["prior message".to_string()] + ); + } + + #[tokio::test] + async fn staged_prompt_preserves_custom_memory_append_semantics() { + let captured_messages = Arc::new(Mutex::new(Vec::new())); + let model = CapturingModel { + messages: captured_messages.clone(), + }; + let mut agent = Agent::new(model).memory(TailMemory { + messages: vec!["older".to_string(), "latest".to_string()], + max_messages: 2, + }); + + agent + .prompt("current user".to_string()) + .await + .expect("bounded staged memory should complete"); + + assert_eq!( + *captured_messages + .lock() + .expect("messages lock should succeed"), + vec!["latest".to_string(), "current user".to_string()] + ); + assert_eq!( + agent + .memory + .as_ref() + .expect("successful staged memory should commit") + .get_memory(), + &["current user".to_string(), "完成".to_string()] + ); + } + + #[tokio::test] + async fn staged_memory_uses_explicit_commit_and_drop_as_rollback() { + let commits = Arc::new(AtomicUsize::new(0)); + let mut failing_agent = Agent::new(FailingCompletionModel).memory(CommitTrackingMemory { + messages: vec!["prior".to_string()], + commits: commits.clone(), + }); + + failing_agent + .prompt("failed turn".to_string()) + .await + .expect_err("completion failure without tools should roll back"); + assert_eq!(commits.load(Ordering::SeqCst), 0); + + let captured_messages = Arc::new(Mutex::new(Vec::new())); + let mut successful_agent = Agent::new(CapturingModel { + messages: captured_messages, + }) + .memory(CommitTrackingMemory { + messages: vec!["prior".to_string()], + commits: commits.clone(), + }); + + successful_agent + .prompt("successful turn".to_string()) + .await + .expect("successful turn should commit staged memory"); + assert_eq!(commits.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn non_fatal_tool_failure_preserves_structured_failure_and_output() { + let mut agent = Agent::new(FailingToolCallModel { + include_successful_tool: false, + }) + .max_turns(1); + agent.tools.push(Box::new(FailingToolDyn { fatal: false })); + + let error = agent + .prompt("执行失败工具".to_string()) + .await + .expect_err("non-fatal failure should still respect max turns"); + + assert!(matches!( + error.error, + PromptError::MaxTurnsReached { max_turns: 1 } + )); + let PromptOutput::ToolFailed(failure_output) = &error.partial_outputs[1] else { + panic!("structured tool failure should reach the caller"); + }; + assert_eq!(failure_output.failure.kind, ToolFailureKind::Network); + assert!(failure_output.failure.retryable); + assert!(!failure_output.failure.fatal); + assert_eq!(failure_output.output, json!({ "attempt": 1 })); + assert!(failure_output.message.contains("\"retryable\":true")); + } + + #[tokio::test] + async fn fatal_tool_failure_keeps_earlier_success_and_closes_memory() { + let mut agent = Agent::new(FailingToolCallModel { + include_successful_tool: true, + }) + .tool(TestTool { + requires_user_confirmation: false, + }); + agent.tools.push(Box::new(FailingToolDyn { fatal: true })); + + let error = agent + .prompt("先成功再失败".to_string()) + .await + .expect_err("fatal tool should terminate the run"); + + assert!(matches!(error.error, PromptError::ToolError(_))); + assert!(matches!(error.partial_outputs[1], PromptOutput::Tool(_))); + assert!(matches!( + error.partial_outputs[2], + PromptOutput::ToolFailed(_) + )); + assert_eq!( + agent + .memory + .as_ref() + .expect("tool activity should commit memory") + .get_memory() + .last() + .map(String::as_str), + Some("agent-error: Agent 工具执行失败:network failed") + ); + } + + #[tokio::test] + async fn post_tool_hook_failure_keeps_executed_tool_fact_and_closes_memory() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let model = RepeatingToolCallModel { + completion_count: completion_count.clone(), + }; + let mut agent = Agent::new(model) + .tool(TestTool { + requires_user_confirmation: false, + }) + .hook(StopAfterToolCallHook); + + let error = agent + .prompt("执行后由 hook 终止".to_string()) + .await + .expect_err("post-tool hook should terminate the run"); + + assert!(matches!(error.error, PromptError::ToolError(_))); + assert!(matches!(error.partial_outputs[1], PromptOutput::Tool(_))); + assert_eq!( + agent + .memory + .as_ref() + .expect("executed tool should commit memory") + .get_memory() + .last() + .map(String::as_str), + Some( + "agent-error: Agent 工具执行失败:tool call output caused this turn to stop by hook" + ) + ); + } + + #[tokio::test] + async fn multiple_tool_calls_execute_sequentially_in_array_order() { + let execution_order = Arc::new(Mutex::new(Vec::new())); + let mut agent = Agent::new(OrderedBatchModel).tool(OrderedTool { + execution_order: execution_order.clone(), + }); + + let outputs = agent + .prompt("执行两项操作".to_string()) + .await + .expect("ordered pending tools should finish the turn"); + + assert_eq!(outputs.len(), 3); + assert_eq!( + *execution_order + .lock() + .expect("execution order lock should succeed"), + vec![2, 1] + ); + } + + #[tokio::test] + async fn pending_confirmation_tool_batch_finishes_without_another_completion() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let model = RepeatingToolCallModel { + completion_count: completion_count.clone(), + }; + let mut agent = Agent::new(model) + .tool(TestTool { + requires_user_confirmation: true, + }) + .max_turns(3); + + let outputs = agent + .prompt("生成一张图".to_string()) + .await + .expect("pending confirmation should finish the planning turn"); + + assert_eq!(completion_count.load(Ordering::SeqCst), 1); + assert_eq!(outputs.len(), 2); + assert!(matches!(outputs[0], PromptOutput::Text(_))); + assert!(matches!(outputs[1], PromptOutput::Tool(_))); + } + + #[tokio::test] + async fn non_confirmation_tool_keeps_the_existing_max_turn_guard() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let model = RepeatingToolCallModel { + completion_count: completion_count.clone(), + }; + let mut agent = Agent::new(model) + .tool(TestTool { + requires_user_confirmation: false, + }) + .max_turns(3); + + let error = agent + .prompt("生成一张图".to_string()) + .await + .expect_err("a continuing tool should still hit the max-turn guard"); + + assert_eq!(completion_count.load(Ordering::SeqCst), 3); + assert!(matches!( + error.error, + PromptError::MaxTurnsReached { max_turns: 3 } + )); + assert_eq!(error.partial_outputs.len(), 6); + assert_eq!( + agent + .memory + .as_ref() + .expect("tool activity should commit memory") + .get_memory() + .last() + .map(String::as_str), + Some("agent-error: Agent 规划轮数已达上限:3") + ); + } + + #[tokio::test] + async fn skipped_confirmation_result_keeps_the_existing_max_turn_guard() { + let completion_count = Arc::new(AtomicUsize::new(0)); + let model = RepeatingToolCallModel { + completion_count: completion_count.clone(), + }; + let mut agent = Agent::new(model) + .tool(TestTool { + requires_user_confirmation: true, + }) + .hook(SkipAfterToolCallHook) + .max_turns(3); + + let error = agent + .prompt("生成一张图".to_string()) + .await + .expect_err("a skipped result must not finish as pending confirmation"); + + assert_eq!(completion_count.load(Ordering::SeqCst), 3); + assert!(matches!( + error.error, + PromptError::MaxTurnsReached { max_turns: 3 } + )); + assert_eq!(error.partial_outputs.len(), 6); + assert_eq!( + error + .partial_outputs + .iter() + .filter(|output| matches!(output, PromptOutput::Text(_))) + .count(), + 3 + ); + assert_eq!( + error + .partial_outputs + .iter() + .filter(|output| matches!(output, PromptOutput::Tool(_))) + .count(), + 3 + ); + assert_eq!( + agent + .memory + .as_ref() + .expect("tool activity should commit memory") + .get_memory() + .last() + .map(String::as_str), + Some("agent-error: Agent 规划轮数已达上限:3") + ); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/framework/tool.rs b/server-rs/crates/platform-agent-harness/src/tool.rs similarity index 99% rename from server-rs/crates/platform-editor-agent/src/framework/tool.rs rename to server-rs/crates/platform-agent-harness/src/tool.rs index c36e76186..22da48bfc 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/tool.rs +++ b/server-rs/crates/platform-agent-harness/src/tool.rs @@ -1,3 +1,5 @@ +//! Function-calling 工具及其结构化执行结果的公共契约。 + use serde::{Deserialize, Serialize}; use std::pin::Pin; diff --git a/server-rs/crates/platform-editor-agent/Cargo.toml b/server-rs/crates/platform-editor-agent/Cargo.toml index b60f96fec..8db3d7480 100644 --- a/server-rs/crates/platform-editor-agent/Cargo.toml +++ b/server-rs/crates/platform-editor-agent/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] hmac = { workspace = true } +platform-agent-harness = { workspace = true } platform-llm = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/server-rs/crates/platform-editor-agent/src/agent/agent.rs b/server-rs/crates/platform-editor-agent/src/agent/agent.rs index 226f8ef61..e0881e237 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/agent.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/agent.rs @@ -4,8 +4,8 @@ use crate::framework::error::PromptError; use crate::framework::hook::Hook; use crate::framework::memory::AgentMemory; use crate::framework::tool::{Tool, ToolDyn}; +use platform_agent_harness::prompt::{ToolPromptSpec, build_tools_system_prompt}; use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmClient, LlmMessage, LlmTextRequest}; -use serde_json::Value; const EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS: u32 = 1024; const EDITOR_AGENT_LLM_HARD_REQUEST_TIMEOUT_MS: u64 = 480_000; @@ -35,6 +35,13 @@ impl LlmApiAdaptor for LlmCompletionModel { fn build_assistant_message(&self, text: &str) -> LlmMessage { LlmMessage::assistant(text) } + + fn build_error_message(&self, error: &PromptError) -> LlmMessage { + LlmMessage::system(format!( + "ERROR {}", + error.display_with_agent_label("美术 Agent") + )) + } } fn build_editor_agent_llm_request(messages: Vec) -> LlmTextRequest { @@ -122,59 +129,6 @@ impl AgentBuilder for LlmChatAgentBuilder { } } -struct ToolPromptSpec { - name: String, - description: String, - parameters: Value, -} - -fn build_tools_system_prompt(base_prompt: &str, tool_specs: &[ToolPromptSpec]) -> String { - let mut prompt = String::new(); - prompt.push_str(base_prompt); - prompt.push_str("\n\nYou have access to the following tools.\n\n"); - - if tool_specs.is_empty() { - prompt.push_str("(No tools available.)\n"); - } else { - prompt.push_str("## JSON Response Format\n"); - prompt.push_str("respond with valid JSON only (no markdown fences):\n"); - prompt.push_str("{\n"); - prompt.push_str(" \"reply_text\": \"your message to the user\",\n"); - prompt.push_str(" \"tool_calls\": [\n {\n"); - prompt.push_str(" \"tool_name\": \"tool_name_here\",\n"); - prompt.push_str(" \"args\": { \"argument_name\": \"argument_value\" }\n"); - prompt.push_str(" }\n ]\n"); - prompt.push_str("}\n\n"); - prompt.push_str("If you don't need to use a tool, respond with:\n"); - prompt.push_str("{\n"); - prompt.push_str(" \"reply_text\": \"your message\",\n"); - prompt.push_str(" \"tool_calls\": []\n"); - prompt.push_str("}\n\n"); - prompt.push_str("## Available Tools\n\n"); - - for tool in tool_specs { - prompt.push_str(&format!("- {}\n", tool.name)); - prompt.push_str(&format!(" Description: {}\n", tool.description)); - prompt.push_str(" Arguments JSON Schema:\n"); - let parameters = serde_json::to_string_pretty(&tool.parameters) - .unwrap_or_else(|_| tool.parameters.to_string()); - prompt.push_str(¶meters); - prompt.push_str("\n"); - } - - prompt.push_str( - "as you see, tool_calls is an array, several tools calls can be executed in one turn concurrently. ", - ); - prompt.push_str("your valid tool call will be recorded as system message"); - prompt.push_str( - "some tools calls needs user's confirmation, you should not retry the same tool call in this case.\ - And if all tool calls are pending confirmation, you should just end the turn, as you cant do more before user's action" - ); - } - - prompt -} - #[cfg(test)] mod tests { use super::*; diff --git a/server-rs/crates/platform-editor-agent/src/agent/mod.rs b/server-rs/crates/platform-editor-agent/src/agent/mod.rs index ab3846cdf..7711dff12 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/mod.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/mod.rs @@ -1,3 +1,4 @@ pub mod agent; pub mod asset; +pub mod prompt; pub mod tools; diff --git a/server-rs/crates/platform-editor-agent/src/agent/prompt.rs b/server-rs/crates/platform-editor-agent/src/agent/prompt.rs new file mode 100644 index 000000000..5f04018a5 --- /dev/null +++ b/server-rs/crates/platform-editor-agent/src/agent/prompt.rs @@ -0,0 +1,238 @@ +use crate::agent::asset::ImageId; +pub use platform_agent_harness::prompt::PENDING_USER_CONFIRMATION_MESSAGE; +use platform_llm::LlmMessage; +use serde_json::json; +use shared_contracts::editor_agent::{ + EditorAgentConversationMessagesDocument, EditorAgentGeneratedImage, EditorAgentMessage, + EditorAgentMessageRole, EditorAgentToolCallStatus, +}; + +const EDITOR_AGENT_MAX_RECENT_PROMPT_MESSAGES: usize = 18; + +const EXISTING_IMAGE_EDIT_POLICY: &str = "已有图片的修改必须选择 edit-image,不得退回全新生成"; +const SPEC_BOARD_ROUTE_POLICY: &str = + "规范图、视觉规范图、风格规范图或素材规范展板必须选择 generate-image"; +const SPEC_BOARD_CONTENT_POLICY: &str = "规范展板 prompt 必须写明统一视角、线条粗细、描边、填充风格、材质、阴影、圆角、状态层级、色卡或色号、尺寸标注和排版层级"; + +pub fn editor_agent_system_prompt() -> &'static str { + r#" +* image_id 字符串格式为 sha256:*。 +* 用户引用或上传图片时,system message 会提供对应 image_id;规划相关工具调用时必须使用这些 image_id 或上下文中已有的 image_id。 +* 待确认工具必须由用户在界面点击确认按钮执行。用户只在对话中回复“确认”或“可以”时,应提示其点击确认按钮,不得重复提交同一待确认工具。 +* 用户所说的规范图、参考图和已生成图片都可以作为 image_id 图片上下文。 +* 实际生成工具由后端按模型定价扣泥点,不能承诺免费生成。 + +你是 Genarrative 图片画布 Agent,只负责帮助用户理解、规划和触发画布生成工具。对话回复要简短。 +"# +} + +pub fn build_prompt_memory( + document: &EditorAgentConversationMessagesDocument, + history_end: usize, +) -> Vec { + let history = &document.messages[..history_end.min(document.messages.len())]; + let mut messages = history + .iter() + .map(|message| match message.role { + EditorAgentMessageRole::User => LlmMessage::user(&message.text), + EditorAgentMessageRole::Assistant => LlmMessage::assistant(&message.text), + EditorAgentMessageRole::System => LlmMessage::system(&message.text), + }) + .rev() + .take(EDITOR_AGENT_MAX_RECENT_PROMPT_MESSAGES) + .rev() + .collect::>(); + + if let Some(latest_generated_image) = build_latest_generated_image_prompt_context(history) { + messages.push(LlmMessage::system(latest_generated_image)); + } + messages +} + +fn build_latest_generated_image_prompt_context(messages: &[EditorAgentMessage]) -> Option { + let (tool_name, image) = messages.iter().rev().find_map(|message| { + let tool_call = message.tool_call.as_ref()?; + if tool_call.status != EditorAgentToolCallStatus::Completed { + return None; + } + Some((tool_call.tool_name.as_str(), tool_call.images.first()?)) + })?; + let image_id = ImageId::from_data_key(generated_image_data_key(image)); + let context = json!({ + "toolName": tool_name, + "imageId": image_id, + "resourceId": image.resource_id.as_deref(), + "objectKey": image.object_key.as_deref(), + "assetObjectId": image.asset_object_id.as_deref(), + }); + Some(format!( + "latestGeneratedImage: {context}\n用户指代“这张”“刚才那个”或“上一张”时,使用 imageId 作为 edit-image 的 object_image_id。" + )) +} + +fn generated_image_data_key(image: &EditorAgentGeneratedImage) -> String { + image + .object_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.trim_start_matches('/').to_string()) + .unwrap_or_else(|| image.image_src.clone()) +} + +pub fn edit_image_tool_description() -> String { + format!( + "仅用于修改已有图片:换衣服、改颜色、替换背景、局部重绘,或保持主体、构图、姿势不变的编辑。用户指代“这张”“刚才那个”“上一张”“改成”“换成”时优先使用本工具。{EXISTING_IMAGE_EDIT_POLICY};必须使用已有图片上下文,没有参考图时不要调用,应提示用户先选择参考图。" + ) +} + +pub fn generate_image_tool_description() -> String { + format!( + "用于从文字生成全新图片:新场景、新物体、新插画或新背景;也专用于规范图、视觉规范图、风格规范图、素材规范展板。不要用来修改已有图,{EXISTING_IMAGE_EDIT_POLICY}。{SPEC_BOARD_ROUTE_POLICY};{SPEC_BOARD_CONTENT_POLICY};角色规范展板还要含头身比例、标准立绘、动作帧样例、服饰配饰分层和专属色卡。完整 prompt 必须包含画面、主体、风格、构图和背景。" + ) +} + +pub fn generate_character_tool_description() -> String { + format!( + "仅用于生成新的角色形象、人物立绘或普通角色设定图。{EXISTING_IMAGE_EDIT_POLICY};角色规范图、角色美术视觉规范设定图或规范展板属于规范展板,{SPEC_BOARD_ROUTE_POLICY}。" + ) +} + +pub fn generate_icon_spritesheet_tool_description() -> String { + format!( + "仅用于生成多个图标成品、图标素材图集或 spritesheet。必须提供图标规范或风格参考图,并填写多个 icon_descriptions;没有参考图时不要调用,应提示用户先选择参考图。图标规范图或图标视觉规范展板属于规范展板,{SPEC_BOARD_ROUTE_POLICY}。" + ) +} + +pub fn generate_ui_design_tool_description() -> String { + format!( + "仅用于生成完整可用的 UI 设计图或界面稿,包括 HUD、弹窗、面板、按钮组合和整页界面。不要用于提取图标、拆素材;{EXISTING_IMAGE_EDIT_POLICY}。UI 规范图、组件规范展板或视觉规范展板属于规范展板,{SPEC_BOARD_ROUTE_POLICY}。" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use shared_contracts::editor_agent::{ + EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, EditorAgentToolCall, EditorAgentToolCallDisplayArgs, + }; + + fn message( + id: usize, + role: EditorAgentMessageRole, + text: &str, + tool_call: Option, + ) -> EditorAgentMessage { + EditorAgentMessage { + id, + client_message_id: None, + role, + text: text.to_string(), + attachments: Vec::new(), + tool_call, + created_at: "2026-07-28T00:00:00Z".to_string(), + } + } + + #[test] + fn shared_tool_policy_keeps_spec_and_edit_routes_consistent() { + let generate_image = generate_image_tool_description(); + let edit_image = edit_image_tool_description(); + let character = generate_character_tool_description(); + let icons = generate_icon_spritesheet_tool_description(); + let ui = generate_ui_design_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)); + for description in [&generate_image, &edit_image, &character, &ui] { + assert!(description.contains(EXISTING_IMAGE_EDIT_POLICY)); + } + } + + #[test] + fn shared_pending_message_is_control_flow_not_tool_specific_copy() { + assert!(PENDING_USER_CONFIRMATION_MESSAGE.contains("pending user confirmation")); + assert!(PENDING_USER_CONFIRMATION_MESSAGE.contains("end this turn")); + } + + #[test] + fn prompt_memory_injects_the_latest_completed_generated_image() { + let generated = EditorAgentToolCall { + tool_name: "generate-image".to_string(), + status: EditorAgentToolCallStatus::Completed, + args: json!({ "prompt": "一只橙色小猫" }), + display_args: EditorAgentToolCallDisplayArgs::default(), + external_job_id: Some("job-1".to_string()), + images: vec![EditorAgentGeneratedImage { + resource_id: Some("resource-1".to_string()), + object_key: Some("generated/editor/cat.png".to_string()), + asset_object_id: Some("asset-object-1".to_string()), + image_src: "/generated/editor/cat.png".to_string(), + thumbnail_src: None, + width: Some(1024), + height: Some(1024), + }], + videos: Vec::new(), + audios: Vec::new(), + error: None, + }; + let document = EditorAgentConversationMessagesDocument { + version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + conversation_id: "conversation-1".to_string(), + messages: vec![ + message( + 0, + EditorAgentMessageRole::System, + "tool result", + Some(generated), + ), + message(1, EditorAgentMessageRole::User, "把上一张改成蓝色", None), + ], + }; + + let memory = build_prompt_memory(&document, 1); + let latest_context = &memory.last().expect("latest image context").content; + + assert!(latest_context.starts_with("latestGeneratedImage:")); + assert!(latest_context.contains("\"toolName\":\"generate-image\"")); + assert!(latest_context.contains("\"resourceId\":\"resource-1\"")); + assert!(latest_context.contains("\"objectKey\":\"generated/editor/cat.png\"")); + assert!(latest_context.contains("\"imageId\":\"sha256:")); + assert!(latest_context.contains("edit-image 的 object_image_id")); + assert!(!latest_context.contains("source_image_id")); + assert!(!latest_context.contains("https://")); + } + + #[test] + fn prompt_memory_ignores_unfinished_tool_results() { + let pending = EditorAgentToolCall { + tool_name: "generate-image".to_string(), + status: EditorAgentToolCallStatus::NotCompleted, + args: json!({}), + display_args: EditorAgentToolCallDisplayArgs::default(), + external_job_id: None, + images: Vec::new(), + videos: Vec::new(), + audios: Vec::new(), + error: None, + }; + let document = EditorAgentConversationMessagesDocument { + version: EDITOR_AGENT_MESSAGES_DOCUMENT_VERSION, + conversation_id: "conversation-1".to_string(), + messages: vec![message( + 0, + EditorAgentMessageRole::System, + "pending", + Some(pending), + )], + }; + + let memory = build_prompt_memory(&document, document.messages.len()); + + assert_eq!(memory.len(), 1); + assert!(!memory[0].content.contains("latestGeneratedImage")); + } +} diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/common.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/common.rs deleted file mode 100644 index 4d2920087..000000000 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/common.rs +++ /dev/null @@ -1 +0,0 @@ -pub const TOOL_CALL_PENDING_MESSAGE: &'static str = "this tool call is pending user confirmation. if all tool calls are pending, just end this turn"; diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs index e72210d94..52a5b22e7 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/edit_image.rs @@ -1,5 +1,5 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::{PENDING_USER_CONFIRMATION_MESSAGE, edit_image_tool_description}; use crate::agent::tools::context::EditorToolContext; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; use platform_image::GPT_IMAGE_2_MODEL; @@ -67,8 +67,7 @@ impl Tool for EditImageTool { type Output = EditImageToolOutput; fn description(&self) -> String { - "仅用于修改已有图片:换衣服、改颜色、替换背景、局部重绘,或保持主体、构图、姿势不变的编辑。用户指代“这张”“刚才那个”“上一张”“改成”“换成”时优先使用本工具。必须使用已有图片上下文;没有参考图时不要调用,应提示用户先选择参考图。" - .to_string() + edit_image_tool_description() } fn parameters(&self) -> Value { @@ -110,7 +109,7 @@ impl Tool for EditImageTool { return Err(error); } Ok(EditImageToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs index 8a1c12f47..71bf38128 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_background_music.rs @@ -1,4 +1,4 @@ -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::PENDING_USER_CONFIRMATION_MESSAGE; use crate::framework::tool::{Tool, ToolFailure}; use platform_audio::SUNO_DEFAULT_MODEL; use serde::{Deserialize, Serialize}; @@ -65,7 +65,7 @@ impl Tool for GenerateBackgroundMusicTool { async move { self.validate_args(&args)?; Ok(GenerateBackgroundMusicToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs index fd331d759..4d102c0bd 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_character.rs @@ -1,5 +1,7 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::{ + PENDING_USER_CONFIRMATION_MESSAGE, generate_character_tool_description, +}; use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::generate_image::{ EditorImageGenerationResult, GenerateImageError, GenerateImageToolOutput, @@ -39,8 +41,7 @@ impl Tool for GenerateCharacterTool { type Output = GenerateImageToolOutput; fn description(&self) -> String { - "仅用于生成新的角色形象、人物立绘或普通角色设定图。已有角色图的服装、颜色、表情、姿势或背景修改必须选择 edit-image。角色规范图、角色美术视觉规范设定图或规范展板必须选择 generate-image。" - .to_string() + generate_character_tool_description() } fn parameters(&self) -> Value { @@ -66,7 +67,7 @@ impl Tool for GenerateCharacterTool { async move { self.validate_args(&args)?; Ok(GenerateImageToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } 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 684f39b35..96229eee0 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 @@ -1,5 +1,7 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::{ + PENDING_USER_CONFIRMATION_MESSAGE, generate_icon_spritesheet_tool_description, +}; use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::image_generation_options::{ ImageGenerationOptionsError, default_image_aspect_ratio, default_image_model, @@ -119,7 +121,7 @@ impl Tool for GenerateIconSpritesheetTool { type Output = GenerateIconSpritesheetToolOutput; fn description(&self) -> String { - "仅用于生成多个图标成品、图标素材图集或 spritesheet。必须提供图标规范或风格参考图,并填写多个 icon_descriptions;没有参考图时不要调用,应提示用户先选择参考图。图标规范图或图标视觉规范展板不是本工具,应使用 generate-image。".to_string() + generate_icon_spritesheet_tool_description() } fn parameters(&self) -> Value { @@ -146,7 +148,7 @@ impl Tool for GenerateIconSpritesheetTool { async move { self.validate_args(&args)?; Ok(GenerateIconSpritesheetToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs index 984cd20ad..03f774b5c 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_image.rs @@ -1,5 +1,5 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::{PENDING_USER_CONFIRMATION_MESSAGE, generate_image_tool_description}; use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::image_generation_options::{ ImageGenerationOptionsError, default_image_aspect_ratio, default_image_model, @@ -92,8 +92,7 @@ impl Tool for GenerateImageTool { type Output = GenerateImageToolOutput; fn description(&self) -> String { - "用于从文字生成全新图片:新场景、新物体、新插画或新背景;也专用于规范图、视觉规范图、风格规范图、素材规范展板。不要用来修改已有图,已有图修改应选 edit-image。规范展板 prompt 必须写明统一视角、线条粗细、描边、填充风格、材质、阴影、圆角、状态层级、色卡或色号、尺寸标注和排版层级;角色规范展板还要含头身比例、标准立绘、动作帧样例、服饰配饰分层和专属色卡。完整 prompt 必须包含画面、主体、风格、构图和背景。" - .to_string() + generate_image_tool_description() } fn parameters(&self) -> Value { @@ -126,7 +125,7 @@ impl Tool for GenerateImageTool { async move { self.validate_args(&args)?; Ok(GenerateImageToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs index 6c9c22a53..8831789e5 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_sound_effect.rs @@ -1,4 +1,4 @@ -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::PENDING_USER_CONFIRMATION_MESSAGE; use crate::framework::tool::{Tool, ToolFailure}; use platform_audio::VIDU_AUDIO_MODEL; use serde::{Deserialize, Serialize}; @@ -91,7 +91,7 @@ impl Tool for GenerateSoundEffectTool { async move { self.validate_args(&args)?; Ok(GenerateSoundEffectToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs index 73bdaea56..295f29ef0 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_ui_design.rs @@ -1,5 +1,7 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::{ + PENDING_USER_CONFIRMATION_MESSAGE, generate_ui_design_tool_description, +}; use crate::agent::tools::context::EditorToolContext; use crate::agent::tools::generate_image::{ EditorImageGenerationResult, GenerateImageError, GenerateImageToolOutput, @@ -42,7 +44,7 @@ impl Tool for GenerateUiDesignTool { type Output = GenerateImageToolOutput; fn description(&self) -> String { - "仅用于生成完整可用的 UI 设计图或界面稿,包括 HUD、弹窗、面板、按钮组合和整页界面。不要用于提取图标、拆素材或修改已有图;UI 规范图、组件规范展板或视觉规范展板应使用 generate-image。".to_string() + generate_ui_design_tool_description() } fn parameters(&self) -> Value { @@ -72,7 +74,7 @@ impl Tool for GenerateUiDesignTool { async move { self.validate_args(&args)?; Ok(GenerateImageToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } diff --git a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs index 6d4d5b14d..461597417 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/tools/generate_video.rs @@ -1,5 +1,5 @@ use crate::agent::asset::ImageId; -use crate::agent::tools::common::TOOL_CALL_PENDING_MESSAGE; +use crate::agent::prompt::PENDING_USER_CONFIRMATION_MESSAGE; use crate::agent::tools::context::EditorToolContext; use crate::framework::tool::{Tool, ToolFailure, ToolFailureKind}; use serde::{Deserialize, Serialize}; @@ -153,7 +153,7 @@ impl Tool for GenerateVideoTool { async move { self.validate_args(&args)?; Ok(GenerateVideoToolOutput { - message: TOOL_CALL_PENDING_MESSAGE.to_string(), + message: PENDING_USER_CONFIRMATION_MESSAGE.to_string(), }) } } 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 ba4a6e188..1c26f27a7 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 @@ -1,4 +1,3 @@ -mod common; pub mod context; pub mod edit_image; pub mod generate_background_music; diff --git a/server-rs/crates/platform-editor-agent/src/framework/error.rs b/server-rs/crates/platform-editor-agent/src/framework/error.rs deleted file mode 100644 index 5d2e92581..000000000 --- a/server-rs/crates/platform-editor-agent/src/framework/error.rs +++ /dev/null @@ -1,53 +0,0 @@ -#[derive(Debug, Clone)] -pub enum PromptError { - CompletionError(String), - ToolError(String), - InternalError(String), - MaxTurnsReached { max_turns: usize }, -} - -impl std::fmt::Display for PromptError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::CompletionError(msg) => write!(f, "美术 Agent 规划失败:{msg}"), - Self::ToolError(msg) => write!(f, "美术 Agent 工具执行失败:{msg}"), - Self::InternalError(msg) => write!(f, "美术 Agent 内部错误:{msg}"), - Self::MaxTurnsReached { max_turns } => { - write!(f, "美术 Agent 规划轮数已达上限:{max_turns}") - } - } - } -} - -impl std::error::Error for PromptError {} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn completion_error_uses_user_facing_chinese_copy() { - let error = PromptError::CompletionError("LLM 请求超时,累计尝试 2 次".to_string()); - - assert_eq!( - error.to_string(), - "美术 Agent 规划失败:LLM 请求超时,累计尝试 2 次" - ); - } - - #[test] - fn other_errors_do_not_expose_framework_prefixes() { - assert_eq!( - PromptError::ToolError("参数无效".to_string()).to_string(), - "美术 Agent 工具执行失败:参数无效" - ); - assert_eq!( - PromptError::InternalError("序列化失败".to_string()).to_string(), - "美术 Agent 内部错误:序列化失败" - ); - assert_eq!( - PromptError::MaxTurnsReached { max_turns: 3 }.to_string(), - "美术 Agent 规划轮数已达上限:3" - ); - } -} diff --git a/server-rs/crates/platform-editor-agent/src/framework/memory.rs b/server-rs/crates/platform-editor-agent/src/framework/memory.rs deleted file mode 100644 index 3157d7387..000000000 --- a/server-rs/crates/platform-editor-agent/src/framework/memory.rs +++ /dev/null @@ -1,31 +0,0 @@ -use serde::{Deserialize, Serialize}; - -pub trait AgentMemory: Send + Sync { - fn get_memory(&self) -> &[Message]; - fn append_message(&mut self, message: Message); -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VecMemory { - messages: Vec, -} - -impl VecMemory { - pub fn new(messages: Vec) -> Self { - Self { messages } - } - - pub fn into_inner(self) -> Vec { - self.messages - } -} - -impl AgentMemory for VecMemory { - fn get_memory(&self) -> &[Message] { - &self.messages - } - - fn append_message(&mut self, message: Message) { - self.messages.push(message); - } -} diff --git a/server-rs/crates/platform-editor-agent/src/framework/mod.rs b/server-rs/crates/platform-editor-agent/src/framework/mod.rs index 0f84bfb15..4491d4376 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/mod.rs +++ b/server-rs/crates/platform-editor-agent/src/framework/mod.rs @@ -1,7 +1 @@ -pub mod agent; -pub mod agent_builder; -pub mod error; -pub mod hook; -pub mod memory; -pub mod run; -pub mod tool; +pub use platform_agent_harness::{agent, agent_builder, error, hook, memory, prompt, run, tool}; diff --git a/server-rs/crates/platform-editor-agent/src/framework/run.rs b/server-rs/crates/platform-editor-agent/src/framework/run.rs deleted file mode 100644 index 2acd2dc6e..000000000 --- a/server-rs/crates/platform-editor-agent/src/framework/run.rs +++ /dev/null @@ -1,458 +0,0 @@ -use crate::framework::agent::Agent; -use crate::framework::agent::LlmApiAdaptor; -use crate::framework::error::PromptError; -use crate::framework::hook::Hook; -use crate::framework::memory::VecMemory; -use crate::framework::run::PromptOutput::{Text, Tool}; -use crate::framework::tool::{ToolCall, ToolExecutionResult, ToolFailure, ToolOutcome}; -use serde::Deserialize; -use serde_json::Value; -use std::pin::Pin; - -pub type TextOutput = String; - -#[derive(Debug, Clone)] -pub struct ToolCallOutput { - pub tool_call: ToolCall, - pub output: Value, -} -#[derive(Debug, Clone)] -pub enum PromptOutput { - Text(TextOutput), - Tool(ToolCallOutput), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum TextFlow { - Continue, - Stop, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ToolCallFlow { - Continue, - Skip, - Stop, -} - -pub fn format_tool_call_message( - tool_call_id: impl std::fmt::Display, - args: &Value, - output: &Value, -) -> Result { - let arg_json = serde_json::to_string(args) - .map_err(|error| PromptError::InternalError(error.to_string()))?; - let output_json = serde_json::to_string(output) - .map_err(|error| PromptError::InternalError(error.to_string()))?; - Ok(format!( - "[tool_call:{tool_call_id}] args: {arg_json} output: {output_json}" - )) -} - -pub struct PromptRequest<'a, M: LlmApiAdaptor + 'a, Message: 'a> { - agent: &'a mut Agent, - message: Message, - system_prompt: Option, - hooks: Vec>, - max_turns: usize, -} - -impl<'a, M, Message> PromptRequest<'a, M, Message> -where - M: LlmApiAdaptor + 'a, - Message: 'a, -{ - pub fn new(agent: &'a mut Agent, message: Message) -> Self { - let max_turns = agent.default_max_turns; - Self { - agent, - message, - system_prompt: None, - hooks: Vec::new(), - max_turns, - } - } - - pub fn system_prompt(mut self, msg: Message) -> Self { - self.system_prompt = Some(msg); - self - } - - pub fn add_hook(mut self, hook: impl Hook + 'static) -> Self { - self.hooks.push(Box::new(hook)); - self - } - - pub fn max_turns(mut self, n: usize) -> Self { - self.max_turns = n; - self - } -} - -impl<'a, M, Message> IntoFuture for PromptRequest<'a, M, Message> -where - M: LlmApiAdaptor + Send + Sync + 'a, - Message: Send + Sync + Clone + 'a + 'static, -{ - type Output = Result, PromptError>; - type IntoFuture = Pin + Send + 'a>>; - - fn into_future(self) -> Self::IntoFuture { - let agent = self.agent; - let message = self.message; - let extra_hooks = self.hooks; - let max_turns = self.max_turns; - - Box::pin(async move { - let memory = agent - .memory - .get_or_insert_with(|| Box::new(VecMemory::new(Vec::new()))); - memory.append_message(message); - let mut prompt_result: Vec = Vec::new(); - - for _ in 0..max_turns { - let text = { - let messages = agent.system_prompt.iter().chain(memory.get_memory().iter()); - agent.model.complete(messages).await? - }; - - // Try to parse the LLM reply as JSON (handle Markdown fences) - let cleaned = clean_json_response(&text); - match serde_json::from_str::(&cleaned) { - Ok(json_resp) => { - let clean_text = json_resp.reply_text.trim().to_string(); - - // Run on_text_reply hooks - for hook in agent.hooks.iter().chain(extra_hooks.iter()) { - match hook.on_text_reply(&clean_text) { - TextFlow::Stop => { - return Err(PromptError::ToolError( - "text reply rejected by hook".to_string(), - )); - } - TextFlow::Continue => {} - } - } - memory.append_message(agent.model.build_assistant_message(&clean_text)); - prompt_result.push(Text(clean_text.clone())); - - let tool_calls: Vec = json_resp - .tool_calls - .into_iter() - .enumerate() - .map(|(idx, tc)| ToolCall { - id: format!("{idx}"), - name: tc.tool_name, - args: tc.args, - }) - .collect(); - - // no tool call, turn terminate. - if tool_calls.is_empty() { - return Ok(prompt_result); - } - - let mut all_tool_calls_await_user_confirmation = true; - for (tc_id, tc) in tool_calls.iter().enumerate() { - // inline run_hooks: before_tool_call hook - let mut should_skip = false; - for hook in agent.hooks.iter().chain(extra_hooks.iter()) { - match hook.before_tool_call(tc) { - ToolCallFlow::Stop => { - return Err(PromptError::ToolError( - "tool call rejected by hook".to_string(), - )); - } - ToolCallFlow::Skip => { - let msg = agent - .model - .tool_result_message(&tc.name, "(skipped by hook)"); - memory.append_message(msg); - should_skip = true; - break; - } - ToolCallFlow::Continue => {} - } - } - if should_skip { - all_tool_calls_await_user_confirmation = false; - continue; - } - - let matching_tool = - agent.tools.iter().find(|tool| tool.tool_name() == tc.name); - let requires_user_confirmation = - matching_tool.is_some_and(|tool| tool.requires_user_confirmation()); - let result = match matching_tool { - Some(tool) => tool.call(tc.args.clone()).await, - None => ToolExecutionResult::failed( - Value::Null, - ToolFailure::invalid_args(format!("unknown tool: {}", tc.name)), - ), - }; - if !requires_user_confirmation - || !matches!(&result.outcome, ToolOutcome::InternalOk) - { - all_tool_calls_await_user_confirmation = false; - } - - match result.outcome { - ToolOutcome::InternalOk => { - let mut json_output = result.output; - // Run after_tool_call hooks to allow output modification - for hook in agent.hooks.iter().chain(extra_hooks.iter()) { - match hook.after_tool_call(&tc.name, &mut json_output) { - ToolCallFlow::Stop => { - return Err(PromptError::ToolError( - "tool call output caused this turn stopped by hook".to_string(), - )); - } - ToolCallFlow::Skip => { - all_tool_calls_await_user_confirmation = false; - json_output = serde_json::json!({"message":"tool call is ignored by hook"}); - break; - } - ToolCallFlow::Continue => {} - } - } - let overall_message = - format_tool_call_message(tc_id, &tc.args, &json_output)?; - let msg = - agent.model.tool_result_message(&tc.name, &overall_message); - memory.append_message(msg); - prompt_result.push(Tool(ToolCallOutput { - tool_call: tc.clone(), - output: json_output, - })) - } - ToolOutcome::InternalError(failure) if failure.fatal => { - return Err(PromptError::ToolError(failure.message)); - } - ToolOutcome::InternalError(failure) => { - let msg = agent.model.tool_result_message( - &tc.name, - &format!("error: {}", failure.message), - ); - memory.append_message(msg); - } - } - } - - if all_tool_calls_await_user_confirmation { - return Ok(prompt_result); - } - } - Err(_) => { - // Not valid JSON — push as assistant message and continue to next _turn - memory.append_message(agent.model.build_assistant_message(&text)); - continue; - } - } - } - - Err(PromptError::MaxTurnsReached { max_turns }) - }) - } -} - -#[derive(Deserialize)] -struct LlmJsonResponse { - reply_text: String, - #[serde(default)] - tool_calls: Vec, -} - -#[derive(Deserialize)] -struct LlmToolCallRequest { - tool_name: String, - #[serde(default)] - args: Value, -} - -pub fn clean_json_response(text: &str) -> String { - let text = text.trim(); - if text.starts_with("```") { - let lines: Vec<&str> = text.lines().collect(); - let mut cleaned = Vec::new(); - let mut in_code = false; - for line in lines { - if line.trim().starts_with("```") { - in_code = !in_code; - continue; - } - if in_code { - cleaned.push(line); - } - } - if !cleaned.is_empty() { - return cleaned.join("\n").trim().to_string(); - } - } - text.to_string() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::framework::agent::LlmApiAdaptor; - use crate::framework::hook::Hook; - use crate::framework::tool::Tool; - use serde_json::json; - use std::convert::Infallible; - use std::sync::Arc; - use std::sync::atomic::{AtomicUsize, Ordering}; - - struct RepeatingToolCallModel { - completion_count: Arc, - } - - impl LlmApiAdaptor for RepeatingToolCallModel { - async fn complete<'a>( - &self, - _messages: impl Iterator + Send, - ) -> Result { - self.completion_count.fetch_add(1, Ordering::SeqCst); - Ok(json!({ - "reply_text": "请确认这次生成", - "tool_calls": [{ - "tool_name": "test-tool", - "args": { "prompt": "生成一张图" } - }] - }) - .to_string()) - } - - fn tool_result_message(&self, tool_name: &str, output: &str) -> String { - format!("{tool_name}: {output}") - } - - fn build_assistant_message(&self, text: &str) -> String { - text.to_string() - } - } - - struct TestTool { - requires_user_confirmation: bool, - } - - struct SkipAfterToolCallHook; - - impl Hook for SkipAfterToolCallHook { - fn after_tool_call(&self, _tool_name: &str, _output: &mut Value) -> ToolCallFlow { - ToolCallFlow::Skip - } - } - - impl Tool for TestTool { - const NAME: &'static str = "test-tool"; - type Error = Infallible; - type Args = Value; - type Output = Value; - - fn description(&self) -> String { - "test tool".to_string() - } - - fn parameters(&self) -> Value { - json!({ "type": "object" }) - } - - fn call( - &self, - _args: Self::Args, - ) -> impl Future> + Send { - async { Ok(json!({ "message": "pending user confirmation" })) } - } - - fn requires_user_confirmation(&self) -> bool { - self.requires_user_confirmation - } - } - - #[tokio::test] - async fn pending_confirmation_tool_batch_finishes_without_another_completion() { - let completion_count = Arc::new(AtomicUsize::new(0)); - let model = RepeatingToolCallModel { - completion_count: completion_count.clone(), - }; - let mut agent = Agent::new(model) - .tool(TestTool { - requires_user_confirmation: true, - }) - .max_turns(3); - - let outputs = agent - .prompt("生成一张图".to_string()) - .await - .expect("pending confirmation should finish the planning turn"); - - assert_eq!(completion_count.load(Ordering::SeqCst), 1); - assert_eq!(outputs.len(), 2); - assert!(matches!(outputs[0], PromptOutput::Text(_))); - let PromptOutput::Tool(tool_output) = &outputs[1] else { - panic!("second output should be a tool call"); - }; - assert_eq!( - tool_output.output, - json!({ "message": "pending user confirmation" }) - ); - assert_eq!( - format_tool_call_message( - "test-tool", - &tool_output.tool_call.args, - &tool_output.output, - ) - .expect("tool call message should format"), - "[tool_call:test-tool] args: {\"prompt\":\"生成一张图\"} output: {\"message\":\"pending user confirmation\"}" - ); - } - - #[tokio::test] - async fn non_confirmation_tool_keeps_the_existing_max_turn_guard() { - let completion_count = Arc::new(AtomicUsize::new(0)); - let model = RepeatingToolCallModel { - completion_count: completion_count.clone(), - }; - let mut agent = Agent::new(model) - .tool(TestTool { - requires_user_confirmation: false, - }) - .max_turns(3); - - let error = agent - .prompt("生成一张图".to_string()) - .await - .expect_err("a continuing tool should still hit the max-turn guard"); - - assert_eq!(completion_count.load(Ordering::SeqCst), 3); - assert!(matches!( - error, - PromptError::MaxTurnsReached { max_turns: 3 } - )); - } - - #[tokio::test] - async fn skipped_confirmation_result_keeps_the_existing_max_turn_guard() { - let completion_count = Arc::new(AtomicUsize::new(0)); - let model = RepeatingToolCallModel { - completion_count: completion_count.clone(), - }; - let mut agent = Agent::new(model) - .tool(TestTool { - requires_user_confirmation: true, - }) - .hook(SkipAfterToolCallHook) - .max_turns(3); - - let error = agent - .prompt("生成一张图".to_string()) - .await - .expect_err("a skipped result must not finish as pending confirmation"); - - assert_eq!(completion_count.load(Ordering::SeqCst), 3); - assert!(matches!( - error, - PromptError::MaxTurnsReached { max_turns: 3 } - )); - } -} From 44f47e5db1f94778346c74ee98ced086e9e50a2a Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 29 Jul 2026 11:06:56 +0800 Subject: [PATCH 2/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=9B=BE=E9=9B=86?= =?UTF-8?q?=E7=B4=A0=E6=9D=90=E6=A0=87=E7=AD=BE=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E4=B8=8E=E6=8B=86=E5=88=86=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 项目资源复用增加素材类型匹配,避免同源旧类型资源回流 标签保存期间禁用拆分并在服务端确认后回写资源引用 补齐乱序请求、快照刷新、失败回滚和撤销历史保护 新增前后端回归测试并同步图集入口设计文档 --- ...辑器】画板图标素材生成入口设计-2026-06-15.md | 2 + .../src/editor_project_storage.rs | 77 +++- .../image-editor/ImageCanvasEditorTypes.ts | 2 + .../ImageCanvasEditorView.test.tsx | 331 +++++++++++++++++- .../image-editor/ImageCanvasEditorView.tsx | 201 +++++++++-- .../ImageCanvasHistoryModel.test.ts | 16 + .../image-editor/ImageCanvasHistoryModel.ts | 11 +- ...ageCanvasSelectedLayerToolbarView.test.tsx | 22 ++ .../ImageCanvasSelectedLayerToolbarView.tsx | 32 +- .../image-editor/ImageCanvasStageView.tsx | 5 + .../image-editor/useCanvasHistory.test.tsx | 2 + .../image-editor/useCanvasHistory.ts | 61 +++- .../useImageCanvasLayerCommands.ts | 6 +- 13 files changed, 711 insertions(+), 57 deletions(-) diff --git a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md index cf6fe7712..51bf1d987 100644 --- a/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板图标素材生成入口设计-2026-06-15.md @@ -14,6 +14,7 @@ - 图标素材面板锚定在占位图下方,和现有生成输入框同一层级展示。 - 透明背景处理正常成功后删除占位态:透明 spritesheet 作为主图(`assetKind: "icon-spritesheet"`,`generatedLayerId` 锚点)放入画布,provider 带背景原图作为第二个同类型图层放在透明主图右侧,按 alpha 连通域成功拆出的 `assetKind: "icon"` 素材从原图右侧继续铺放;透明背景处理最终失败时,后端完成快照只用 provider 原图替换占位态。 - 选中 `assetKind: "icon-spritesheet"` 图层时,图片浮动工具栏显示 `拆分图集`;手动拆分只追加独立素材,不复制原图集。 +- 用户把现有图层手动标记为“图集”时,必须先持久化一条 `assetKind: "icon-spritesheet"` 的项目资源并把返回的 `resourceId` 写回图层;项目资源只能在媒体来源和 `assetKind` 都相同时复用,不得因同源图片而返回旧类型资源。持久化完成前必须禁用“拆分图集”,持久化失败时回滚到上一个已确认的素材标签和资源引用,并失效该轮未确认的标签撤销记录。 - 图标规范图写入 `assetKind: "icon-spec"`,用于刷新后保留标签和限制点选来源。 ## 面板结构 @@ -81,4 +82,5 @@ - 图标素材生成可以上传普通参考图;提交时图标规范图仍走 `referenceImageSrc`,普通参考图走 `referenceImageSrcs`,二者都必须是稳定引用(`objectKey` / 项目资源 ID / 素材 ID),禁止 Data URL / Blob URL,并写入 `generationInputs.references`。 - 透明背景处理和自动拆分都成功后,画布同时出现透明 spritesheet 主图、其右侧的 provider 原图,以及从原图右侧铺开的按描述命名的独立图标图层;透明图集成功但拆分失败时仍出现透明主图与右侧原图,透明背景处理最终失败时只出现 provider 原图。 - 选中透明图集图层时显示 `拆分图集`;点击后源图集显示扫描蒙层与 `拆图中` 状态,工具栏按钮同步切换为旋转图标和 `拆图中` 并禁用重复提交。完成后恢复工具栏,不新增第二张图集,只在 provider 原图右侧追加自动识别的独立素材,并同步写入素材库。 +- 把同源派生图层从其它标签改为“图集”时,在项目资源返回新 `resourceId` 前“拆分图集”保持禁用;持久化成功后拆分请求必须指向 `assetKind: "icon-spritesheet"` 的新资源,失败时标签回滚且不发起拆分请求。 - 生成图标素材提交体包含按模型和尺寸计算的 `priceMudPoints`;`nanobanana2 1K` 应为 `12`,`gpt-image-2 1K` 应为 `3`,`gpt-image-2 2K` 应为 `5`。若前端传入与后端计费配置不一致的值,后端返回 `priceMudPoints` 校验错误,不继续调用上游生成。 diff --git a/server-rs/crates/spacetime-module/src/editor_project_storage.rs b/server-rs/crates/spacetime-module/src/editor_project_storage.rs index ccd6e6f0d..5bebf74c8 100644 --- a/server-rs/crates/spacetime-module/src/editor_project_storage.rs +++ b/server-rs/crates/spacetime-module/src/editor_project_storage.rs @@ -2087,6 +2087,7 @@ fn create_editor_project_resource( project_id.as_str(), owner_user_id.as_str(), source_resource_id.as_ref(), + asset_kind.as_deref(), asset_object_id.as_ref(), object_key.as_ref(), image_src.as_str(), @@ -4948,6 +4949,7 @@ fn find_reusable_project_resource_for_input( project_id: &str, owner_user_id: &str, source_resource_id: Option<&String>, + asset_kind: Option<&str>, asset_object_id: Option<&String>, object_key: Option<&String>, image_src: &str, @@ -4963,14 +4965,15 @@ fn find_reusable_project_resource_for_input( .by_editor_project_resource_project_id() .filter(&project_key) .filter(|resource| { - resource.owner_user_id == owner_user_id - && (resource.resource_id == source_resource_id - || resource - .source_resource_id - .as_deref() - .map(str::trim) - .is_some_and(|value| value == source_resource_id)) - && project_resource_matches_media(resource, asset_object_id, object_key, image_src) + project_resource_matches_reuse_input( + resource, + owner_user_id, + source_resource_id, + asset_kind, + asset_object_id, + object_key, + image_src, + ) }) .collect::>(); matches.sort_by(|left, right| { @@ -4988,6 +4991,26 @@ fn find_reusable_project_resource_for_input( matches.into_iter().next() } +fn project_resource_matches_reuse_input( + resource: &EditorProjectResource, + owner_user_id: &str, + source_resource_id: &str, + asset_kind: Option<&str>, + asset_object_id: Option<&String>, + object_key: Option<&String>, + image_src: &str, +) -> bool { + resource.owner_user_id == owner_user_id + && (resource.resource_id == source_resource_id + || resource + .source_resource_id + .as_deref() + .map(str::trim) + .is_some_and(|value| value == source_resource_id)) + && resource.asset_kind.as_deref() == asset_kind + && project_resource_matches_media(resource, asset_object_id, object_key, image_src) +} + fn matching_optional_text_ref(left: Option<&String>, right: Option<&String>) -> bool { match (normalize_optional_ref(left), normalize_optional_ref(right)) { (Some(left_value), Some(right_value)) => left_value == right_value, @@ -9283,6 +9306,44 @@ mod tests { } } + #[test] + fn editor_project_resource_reuse_rejects_different_asset_kind() { + let resource = generated_editor_project_resource( + "resource-derived", + "task-1", + Some("resource-source"), + ); + + assert!(!project_resource_matches_reuse_input( + &resource, + "user-1", + "resource-source", + Some("icon-spritesheet"), + resource.asset_object_id.as_ref(), + resource.object_key.as_ref(), + resource.image_src.as_str(), + )); + } + + #[test] + fn editor_project_resource_reuse_accepts_same_asset_kind() { + let resource = generated_editor_project_resource( + "resource-derived", + "task-1", + Some("resource-source"), + ); + + assert!(project_resource_matches_reuse_input( + &resource, + "user-1", + "resource-source", + Some("icon"), + resource.asset_object_id.as_ref(), + resource.object_key.as_ref(), + resource.image_src.as_str(), + )); + } + #[test] fn normalize_editor_generation_pricing_models_accepts_complete_matrix() { let normalized = diff --git a/src/components/image-editor/ImageCanvasEditorTypes.ts b/src/components/image-editor/ImageCanvasEditorTypes.ts index f6d0e73f2..61435b221 100644 --- a/src/components/image-editor/ImageCanvasEditorTypes.ts +++ b/src/components/image-editor/ImageCanvasEditorTypes.ts @@ -303,9 +303,11 @@ export type CanvasHistoryActionType = export type CanvasHistoryAction = { type: CanvasHistoryActionType; count?: number; + layerIds?: string[]; }; export type CanvasHistoryEntry = { + entryId: number; snapshot: CanvasHistorySnapshot; action: CanvasHistoryAction; createdAt: number; diff --git a/src/components/image-editor/ImageCanvasEditorView.test.tsx b/src/components/image-editor/ImageCanvasEditorView.test.tsx index b4380fdb2..9f0360f90 100644 --- a/src/components/image-editor/ImageCanvasEditorView.test.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.test.tsx @@ -111,6 +111,7 @@ const generateEditorCharacterAnimationMock = vi.hoisted(() => vi.fn()); const editEditorImageMock = vi.hoisted(() => vi.fn()); const createEditorAssetMock = vi.hoisted(() => vi.fn()); const createEditorProjectResourceMock = vi.hoisted(() => vi.fn()); +const splitEditorIconSpritesheetMock = vi.hoisted(() => vi.fn()); const createEditorAssetFolderMock = vi.hoisted(() => vi.fn()); const updateEditorAssetMock = vi.hoisted(() => vi.fn()); const updateEditorAssetFolderMock = vi.hoisted(() => vi.fn()); @@ -136,6 +137,7 @@ vi.mock('../../services/image-editor/editorProjectClient', async () => { createEditorAsset: createEditorAssetMock, createEditorAssetFolder: createEditorAssetFolderMock, createEditorProjectResource: createEditorProjectResourceMock, + splitEditorIconSpritesheet: splitEditorIconSpritesheetMock, deleteEditorAsset: deleteEditorAssetMock, deleteEditorAssetFolder: deleteEditorAssetFolderMock, generateEditorCharacterAnimation: generateEditorCharacterAnimationMock, @@ -322,6 +324,10 @@ describe('ImageCanvasEditorView', () => { }); confirmEditorAgentToolCallMock.mockReset(); cancelEditorAgentToolCallMock.mockReset(); + splitEditorIconSpritesheetMock.mockReset(); + splitEditorIconSpritesheetMock.mockImplementation( + () => new Promise(() => undefined), + ); getPlatformProfileDashboardMock.mockResolvedValue({ walletBalance: 1234, totalPlayTimeMs: 0, @@ -2041,6 +2047,160 @@ describe('ImageCanvasEditorView', () => { }); }); + it('keeps atlas splitting busy until the new asset kind resource is confirmed', async () => { + const persistedResource = createDeferred(); + createEditorProjectResourceMock.mockImplementationOnce( + () => persistedResource.promise, + ); + render(); + + const layerButton = await screen.findByRole('button', { + name: '选择拼图素材', + }); + fireEvent.click(layerButton); + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '图集' }, + ), + ); + + const pendingSplitButton = await screen.findByRole('button', { + name: '素材类型保存中', + }); + expect((pendingSplitButton as HTMLButtonElement).disabled).toBe(true); + expect(pendingSplitButton.getAttribute('aria-busy')).toBe('true'); + fireEvent.click(pendingSplitButton); + expect(splitEditorIconSpritesheetMock).not.toHaveBeenCalled(); + + await act(async () => { + persistedResource.resolve({ + resourceId: 'resource-confirmed-atlas', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + assetKind: 'icon-spritesheet', + }); + await persistedResource.promise; + }); + + const splitButton = await screen.findByRole('button', { + name: '拆分图集', + }); + expect((splitButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(splitButton); + await waitFor(() => { + expect(splitEditorIconSpritesheetMock).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'editor-project-default', + sourceResourceId: 'resource-confirmed-atlas', + }), + ); + }); + }); + + it('rolls back the asset kind and resource when persistence fails', async () => { + const persistedResource = createDeferred(); + createEditorProjectResourceMock.mockImplementationOnce( + () => persistedResource.promise, + ); + render(); + + const layerButton = await screen.findByRole('button', { + name: '选择拼图素材', + }); + fireEvent.click(layerButton); + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '图集' }, + ), + ); + await screen.findByRole('button', { name: '素材类型保存中' }); + + await act(async () => { + persistedResource.reject(new Error('资源类型写入失败')); + try { + await persistedResource.promise; + } catch { + // The component consumes the rejection and restores the prior layer. + } + }); + + await within(layerButton).findByRole('button', { + name: '素材标签:未知', + }); + expect( + screen.getByText('素材类型保存失败,已恢复原标签:资源类型写入失败'), + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull(); + await waitFor(() => { + const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]; + expect(latestLayout.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerId: 'layer-puzzle', + resourceId: 'resource-puzzle', + }), + ]), + ); + }); + }); + + it('rolls back when the resource response keeps the old asset kind', async () => { + createEditorProjectResourceMock.mockResolvedValueOnce({ + resourceId: 'resource-stale-icon-spec', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + assetKind: 'icon-spec', + }); + render(); + + const layerButton = await screen.findByRole('button', { + name: '选择拼图素材', + }); + fireEvent.click(layerButton); + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '图集' }, + ), + ); + + await within(layerButton).findByRole('button', { name: '素材标签:未知' }); + expect( + screen.getByText( + '素材类型保存失败,已恢复原标签:服务器未确认新的素材类型', + ), + ).toBeTruthy(); + expect(screen.queryByRole('button', { name: '拆分图集' })).toBeNull(); + await waitFor(() => { + const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]; + expect(latestLayout.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerId: 'layer-puzzle', + resourceId: 'resource-puzzle', + }), + ]), + ); + }); + }); + it('persists an undone asset kind and ignores the older pending response', async () => { const characterResource = createDeferred(); const restoredResource = createDeferred(); @@ -2119,6 +2279,167 @@ describe('ImageCanvasEditorView', () => { ).toBe(false); }); + it('rolls back an overlapping undo failure to the last confirmed resource', async () => { + const staleCharacterResource = + createDeferred(); + const failedUndoResource = createDeferred(); + createEditorProjectResourceMock + .mockImplementationOnce(() => staleCharacterResource.promise) + .mockImplementationOnce(() => failedUndoResource.promise); + render(); + + const layerButton = await screen.findByRole('button', { + name: '选择拼图素材', + }); + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '角色' }, + ), + ); + await within(layerButton).findByRole('button', { + name: '素材标签:角色', + }); + + fireEvent.click(screen.getByRole('button', { name: '撤销' })); + await within(layerButton).findByRole('button', { name: '素材标签:未知' }); + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2); + }); + + await act(async () => { + failedUndoResource.reject(new Error('撤销后资源写入失败')); + try { + await failedUndoResource.promise; + } catch { + // The latest failure must restore the server-confirmed original layer. + } + }); + + await within(layerButton).findByRole('button', { name: '素材标签:未知' }); + expect( + screen.getByText('素材类型保存失败,已恢复原标签:撤销后资源写入失败'), + ).toBeTruthy(); + await waitFor(() => { + const latestLayout = saveEditorProjectLayoutMock.mock.calls.at(-1)?.[1]; + expect(latestLayout.layers).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + layerId: 'layer-puzzle', + resourceId: 'resource-puzzle', + }), + ]), + ); + }); + + const undoButton = screen.getByRole('button', { name: '撤销' }); + expect((undoButton as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(undoButton); + expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(2); + + await act(async () => { + staleCharacterResource.resolve({ + resourceId: 'resource-stale-character-after-failed-undo', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + assetKind: 'character', + }); + await staleCharacterResource.promise; + }); + + expect( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ).toBeTruthy(); + expect( + saveEditorProjectLayoutMock.mock.calls.some((call) => + call[1].layers.some( + (layer: { resourceId?: unknown }) => + layer.resourceId === 'resource-stale-character-after-failed-undo', + ), + ), + ).toBe(false); + }); + + it('keeps the newer asset kind pending when an older request fails', async () => { + const staleCharacterResource = + createDeferred(); + const currentAtlasResource = + createDeferred(); + createEditorProjectResourceMock + .mockImplementationOnce(() => staleCharacterResource.promise) + .mockImplementationOnce(() => currentAtlasResource.promise); + render(); + + const layerButton = await screen.findByRole('button', { + name: '选择拼图素材', + }); + fireEvent.click(layerButton); + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:未知' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '角色' }, + ), + ); + await within(layerButton).findByRole('button', { + name: '素材标签:角色', + }); + + fireEvent.click( + within(layerButton).getByRole('button', { name: '素材标签:角色' }), + ); + fireEvent.click( + within(screen.getByRole('menu', { name: '选择素材标签' })).getByRole( + 'menuitem', + { name: '图集' }, + ), + ); + await screen.findByRole('button', { name: '素材类型保存中' }); + + await act(async () => { + staleCharacterResource.reject(new Error('旧请求失败')); + try { + await staleCharacterResource.promise; + } catch { + // The stale rejection must not affect the newer request state. + } + }); + + expect( + within(layerButton).getByRole('button', { name: '素材标签:图集' }), + ).toBeTruthy(); + const stillPendingButton = screen.getByRole('button', { + name: '素材类型保存中', + }); + expect((stillPendingButton as HTMLButtonElement).disabled).toBe(true); + expect(screen.queryByText(/素材类型保存失败/u)).toBeNull(); + + await act(async () => { + currentAtlasResource.resolve({ + resourceId: 'resource-current-atlas', + projectId: 'editor-project-default', + imageSrc: '/creation-type-references/puzzle.webp', + width: 640, + height: 640, + sourceType: 'uploaded', + assetKind: 'icon-spritesheet', + }); + await currentAtlasResource.promise; + }); + + expect( + await screen.findByRole('button', { name: '拆分图集' }), + ).toBeTruthy(); + }); + it('offers Lovart-style zoom menu commands', async () => { render(); @@ -2251,8 +2572,7 @@ describe('ImageCanvasEditorView', () => { object_image_id: 'source-image', prompt: '把图片换成像素风', }, - displayArgs: - createEditImageToolCallDisplayArgs('把图片换成像素风'), + displayArgs: createEditImageToolCallDisplayArgs('把图片换成像素风'), images: [], error: null, }, @@ -2273,8 +2593,7 @@ describe('ImageCanvasEditorView', () => { status: 'not_completed', externalJobId: 'task-edit-canvas-1', args: {}, - displayArgs: - createEditImageToolCallDisplayArgs('把图片换成像素风'), + displayArgs: createEditImageToolCallDisplayArgs('把图片换成像素风'), images: [], error: null, }, @@ -2335,9 +2654,7 @@ describe('ImageCanvasEditorView', () => { clientX: 400, clientY: 280, }); - expect(world.style.transform).toBe( - 'translate(-80px, -120px) scale(0.5)', - ); + expect(world.style.transform).toBe('translate(-80px, -120px) scale(0.5)'); expect( screen.getByRole('button', { name: '当前缩放比例 100%' }), ).toBeTruthy(); diff --git a/src/components/image-editor/ImageCanvasEditorView.tsx b/src/components/image-editor/ImageCanvasEditorView.tsx index 2e4db9067..c30236015 100644 --- a/src/components/image-editor/ImageCanvasEditorView.tsx +++ b/src/components/image-editor/ImageCanvasEditorView.tsx @@ -316,10 +316,15 @@ export function ImageCanvasEditorView({ const discardCanvasHistoryEntriesContainingLayerRef = useRef< (matchesLayer: (layer: CanvasLayer) => boolean) => void >(() => {}); - const persistRestoredAssetKindRef = useRef<(layer: CanvasLayer) => void>( - () => {}, - ); + const persistRestoredAssetKindRef = useRef< + (layer: CanvasLayer, historyEntryId?: number) => void + >(() => {}); const layerResourcePersistenceVersionRef = useRef(new Map()); + const assetKindRollbackLayerByIdRef = useRef(new Map()); + const assetKindHistoryEntryIdsByLayerIdRef = useRef( + new Map>(), + ); + const persistingAssetKindLayerIdsRef = useRef(new Set()); const resetCanvasInteractionStateRef = useRef<() => void>(() => {}); const closeGenerationTransientStateRef = useRef<() => void>(() => {}); const specToolWrapRef = useRef(null); @@ -785,8 +790,10 @@ export function ImageCanvasEditorView({ ); const canvasHistoryEffects = useMemo( () => ({ - persistRestoredAssetKind: (layer: CanvasLayer) => - persistRestoredAssetKindRef.current(layer), + persistRestoredAssetKind: ( + layer: CanvasLayer, + historyEntryId?: number, + ) => persistRestoredAssetKindRef.current(layer, historyEntryId), }), [], ); @@ -796,6 +803,7 @@ export function ImageCanvasEditorView({ getCanvasHistorySnapshot, captureCanvasHistory, discardCanvasHistoryEntriesContainingLayer, + discardCanvasHistoryEntriesById, undoCanvasChange, redoCanvasChange, } = useCanvasHistory({ @@ -809,6 +817,8 @@ export function ImageCanvasEditorView({ discardCanvasHistoryEntriesContainingLayer; const [historyFeedback, setHistoryFeedback] = useState(null); + const [persistingAssetKindLayerIds, setPersistingAssetKindLayerIds] = + useState>(() => new Set()); useEffect(() => { if (!historyFeedback) { return; @@ -1187,18 +1197,105 @@ export function ImageCanvasEditorView({ .then(applyGeneratedProjectSnapshot) .catch(() => undefined); }, [applyGeneratedProjectSnapshot, projectId]); + const setLayerAssetKindPersistencePending = useCallback( + (layerId: string, isPending: boolean) => { + const nextLayerIds = new Set(persistingAssetKindLayerIdsRef.current); + if (isPending) { + nextLayerIds.add(layerId); + } else { + nextLayerIds.delete(layerId); + } + persistingAssetKindLayerIdsRef.current = nextLayerIds; + setPersistingAssetKindLayerIds(nextLayerIds); + }, + [], + ); const persistUpdatedLayerResource = useCallback( - (layer: CanvasLayer) => { + ( + layer: CanvasLayer, + rollbackLayer?: CanvasLayer, + historyEntryId?: number, + ) => { + const requestVersion = + (layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1; + layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion); + const confirmedRollbackLayer = rollbackLayer + ? (assetKindRollbackLayerByIdRef.current.get(layer.id) ?? rollbackLayer) + : undefined; + if (confirmedRollbackLayer) { + assetKindRollbackLayerByIdRef.current.set( + layer.id, + confirmedRollbackLayer, + ); + if (historyEntryId !== undefined) { + const historyEntryIds = new Set( + assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id), + ); + historyEntryIds.add(historyEntryId); + assetKindHistoryEntryIdsByLayerIdRef.current.set( + layer.id, + historyEntryIds, + ); + } + setLayerAssetKindPersistencePending(layer.id, true); + } + const rollbackLatestRequest = (error: unknown) => { + if ( + layerResourcePersistenceVersionRef.current.get(layer.id) !== + requestVersion || + !confirmedRollbackLayer + ) { + return; + } + setLayers((currentLayers) => { + const nextLayers = currentLayers.map((currentLayer) => + currentLayer.id === layer.id + ? { + ...currentLayer, + assetKind: confirmedRollbackLayer.assetKind, + resourceId: confirmedRollbackLayer.resourceId, + } + : currentLayer, + ); + layersRef.current = nextLayers; + return nextLayers; + }); + setMetadataLayer((currentLayer) => + currentLayer?.id === layer.id + ? { + ...currentLayer, + assetKind: confirmedRollbackLayer.assetKind, + resourceId: confirmedRollbackLayer.resourceId, + } + : currentLayer, + ); + const historyEntryIds = + assetKindHistoryEntryIdsByLayerIdRef.current.get(layer.id); + if (historyEntryIds) { + discardCanvasHistoryEntriesById(historyEntryIds); + } + assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id); + assetKindRollbackLayerByIdRef.current.delete(layer.id); + setLayerAssetKindPersistencePending(layer.id, false); + const reason = + error instanceof Error && error.message.trim() + ? error.message.trim() + : '请稍后重试'; + setHistoryFeedback((currentFeedback) => ({ + id: (currentFeedback?.id ?? 0) + 1, + tone: 'warning', + text: `素材类型保存失败,已恢复原标签:${reason}`, + })); + }; if (!projectId) { + rollbackLatestRequest(new Error('项目尚未就绪')); return; } const imageSrc = resolveLayerProjectResourceImageSrc(layer); if (!imageSrc) { + rollbackLatestRequest(new Error('素材图片尚未就绪')); return; } - const requestVersion = - (layerResourcePersistenceVersionRef.current.get(layer.id) ?? 0) + 1; - layerResourcePersistenceVersionRef.current.set(layer.id, requestVersion); void createEditorProjectResource(projectId, { imageSrc, objectKey: layer.objectKey, @@ -1215,30 +1312,68 @@ export function ImageCanvasEditorView({ sourceResourceId: layer.sourceResourceId, assetKind: layer.assetKind, generationInputs: layer.generationInputs, - }).then((resource) => { - if ( - layerResourcePersistenceVersionRef.current.get(layer.id) !== - requestVersion - ) { - return; - } - setLayers((currentLayers) => { - const nextLayers = currentLayers.map((currentLayer) => - currentLayer.id === layer.id + }) + .then((resource) => { + if ( + layerResourcePersistenceVersionRef.current.get(layer.id) !== + requestVersion + ) { + return; + } + if ( + confirmedRollbackLayer && + (resource.assetKind !== layer.assetKind || + !resource.resourceId.trim()) + ) { + rollbackLatestRequest(new Error('服务器未确认新的素材类型')); + return; + } + setLayers((currentLayers) => { + const nextLayers = currentLayers.map((currentLayer) => + currentLayer.id === layer.id + ? { + ...currentLayer, + assetKind: layer.assetKind, + resourceId: resource.resourceId, + } + : currentLayer, + ); + layersRef.current = nextLayers; + return nextLayers; + }); + setMetadataLayer((currentLayer) => + currentLayer?.id === layer.id ? { ...currentLayer, + assetKind: layer.assetKind, resourceId: resource.resourceId, } : currentLayer, ); - layersRef.current = nextLayers; - return nextLayers; - }); - }); + if (confirmedRollbackLayer) { + assetKindHistoryEntryIdsByLayerIdRef.current.delete(layer.id); + assetKindRollbackLayerByIdRef.current.delete(layer.id); + setLayerAssetKindPersistencePending(layer.id, false); + } + }) + .catch(rollbackLatestRequest); }, - [projectId], + [ + discardCanvasHistoryEntriesById, + projectId, + setLayerAssetKindPersistencePending, + ], ); - persistRestoredAssetKindRef.current = persistUpdatedLayerResource; + persistRestoredAssetKindRef.current = (layer, historyEntryId) => { + const rollbackLayer = layersRef.current.find( + (currentLayer) => currentLayer.id === layer.id, + ); + persistUpdatedLayerResource( + layer, + rollbackLayer ?? layer, + historyEntryId, + ); + }; const { assetExportStatus, isExportingAssets, @@ -1793,7 +1928,11 @@ export function ImageCanvasEditorView({ if (!targetLayer || targetLayer.assetKind === assetKind) { return; } - captureCanvasHistory({ type: 'change-asset-kind', count: 1 }); + const historyEntryId = captureCanvasHistory({ + type: 'change-asset-kind', + count: 1, + layerIds: [layerId], + }); const nextLayer = { ...targetLayer, assetKind, @@ -1808,7 +1947,7 @@ export function ImageCanvasEditorView({ setMetadataLayer((currentLayer) => currentLayer?.id === layerId ? nextLayer : currentLayer, ); - persistUpdatedLayerResource(nextLayer); + persistUpdatedLayerResource(nextLayer, targetLayer, historyEntryId); setImageContextMenu(null); setContextMenu(null); }, @@ -2216,6 +2355,7 @@ export function ImageCanvasEditorView({ generationComposerStyle, selectedToolbarStyle, splittingIconSpritesheetLayerIds, + persistingAssetKindLayerIds, uploadDropTarget, contextMenu, canvasClipboard, @@ -2266,7 +2406,12 @@ export function ImageCanvasEditorView({ onOpenRedrawPanel: openRedrawPanel, onOpenCropExpandPanel: openCropExpandPanel, onRemoveBackground: removeSelectedLayerBackground, - onSplitIconSpritesheet: splitSelectedIconSpritesheet, + onSplitIconSpritesheet: (layer: CanvasLayer) => { + if (persistingAssetKindLayerIdsRef.current.has(layer.id)) { + return; + } + void splitSelectedIconSpritesheet(layer); + }, onExtractUiDesignAssets: extractUiDesignAssets, onUiAssetExtractionToolChange: changeUiAssetExtractionTool, onUiAssetExtractionModelChange: changeUiAssetExtractionModel, diff --git a/src/components/image-editor/ImageCanvasHistoryModel.test.ts b/src/components/image-editor/ImageCanvasHistoryModel.test.ts index df80f1392..25401a6f4 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.test.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.test.ts @@ -183,6 +183,22 @@ describe('ImageCanvasHistoryModel', () => { ]); }); + it('preserves the current asset kind for layers outside the history action target', () => { + const targetLayer = createLayer({ assetKind: 'icon-spritesheet', x: 10 }); + const currentLayer = createLayer({ assetKind: 'character', x: 90 }); + + const merged = mergeCanvasHistorySnapshotForRestore({ + current: createSnapshot([currentLayer]), + target: createSnapshot([targetLayer]), + assetKindLayerIds: new Set(), + }); + + expect(merged.layers[0]).toMatchObject({ + assetKind: 'character', + x: 10, + }); + }); + it('restores the complete target layer after that layer was deleted', () => { const targetLayer = createLayer({ resourceId: 'deleted-resource', diff --git a/src/components/image-editor/ImageCanvasHistoryModel.ts b/src/components/image-editor/ImageCanvasHistoryModel.ts index 6f2c2dd71..8ace4aed9 100644 --- a/src/components/image-editor/ImageCanvasHistoryModel.ts +++ b/src/components/image-editor/ImageCanvasHistoryModel.ts @@ -131,6 +131,7 @@ function cloneDialogPlaceholder( function mergeLayerForHistoryRestore( target: CanvasLayer, current: CanvasLayer | undefined, + restoreAssetKind: boolean, ): CanvasLayer { if (!current) { return { ...target }; @@ -141,7 +142,7 @@ function mergeLayerForHistoryRestore( y: target.y, zIndex: target.zIndex, groupId: target.groupId, - assetKind: target.assetKind, + assetKind: restoreAssetKind ? target.assetKind : current.assetKind, hidden: target.hidden, locked: target.locked, flipX: target.flipX, @@ -186,9 +187,11 @@ function mergeDialogForHistoryRestore< export function mergeCanvasHistorySnapshotForRestore({ current, target, + assetKindLayerIds, }: { current: CanvasHistorySnapshot; target: CanvasHistorySnapshot; + assetKindLayerIds?: ReadonlySet; }): CanvasHistorySnapshot { const currentLayerById = new Map( current.layers.map((layer) => [layer.id, layer] as const), @@ -210,7 +213,11 @@ export function mergeCanvasHistorySnapshotForRestore({ return { ...target, layers: target.layers.map((layer) => - mergeLayerForHistoryRestore(layer, currentLayerById.get(layer.id)), + mergeLayerForHistoryRestore( + layer, + currentLayerById.get(layer.id), + assetKindLayerIds?.has(layer.id) ?? true, + ), ), generateDialog: target.generateDialog ? mergeTargetDialog(target.generateDialog) diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx index ba91a51c7..b06f0013e 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.test.tsx @@ -43,6 +43,7 @@ function renderSelectedToolbar( onOpenCropExpandPanel: vi.fn(), onRemoveBackground: vi.fn(), isSplittingIconSpritesheet: false, + isPersistingAssetKind: false, onSplitIconSpritesheet: vi.fn(), onExtractUiDesignAssets: vi.fn(), onOpenCharacterAnimationPanel: vi.fn(), @@ -183,6 +184,25 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled(); }); + it('does not invoke atlas splitting while the selected asset kind is persisting', () => { + const layer = createLayer({ assetKind: 'icon-spritesheet' }); + const props = renderSelectedToolbar({ + selectedLayer: layer, + isPersistingAssetKind: true, + }); + const splitButton = screen.getByRole('button', { + name: '素材类型保存中', + }); + + expect(splitButton.getAttribute('aria-busy')).toBe('true'); + expect((splitButton as HTMLButtonElement).disabled).toBe(true); + expect(splitButton.textContent).toContain('保存中'); + + fireEvent.click(splitButton); + + expect(props.onSplitIconSpritesheet).not.toHaveBeenCalled(); + }); + it('keeps only remodel and download for audio layers', () => { const layer = createLayer({ title: '游戏音效', @@ -322,6 +342,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} isSplittingIconSpritesheet={false} + isPersistingAssetKind={false} onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} @@ -340,6 +361,7 @@ describe('ImageCanvasSelectedLayerToolbarView', () => { onOpenCropExpandPanel={vi.fn()} onRemoveBackground={vi.fn()} isSplittingIconSpritesheet={false} + isPersistingAssetKind={false} onSplitIconSpritesheet={vi.fn()} onExtractUiDesignAssets={vi.fn()} onOpenCharacterAnimationPanel={vi.fn()} diff --git a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx index 5c187ea95..b1a423324 100644 --- a/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx +++ b/src/components/image-editor/ImageCanvasSelectedLayerToolbarView.tsx @@ -23,6 +23,7 @@ type ImageCanvasSelectedLayerToolbarViewProps = { onOpenCropExpandPanel: (layer: CanvasLayer) => void; onRemoveBackground: (layer: CanvasLayer) => void; isSplittingIconSpritesheet: boolean; + isPersistingAssetKind: boolean; onSplitIconSpritesheet: (layer: CanvasLayer) => void; onExtractUiDesignAssets: (layer: CanvasLayer) => void; onOpenCharacterAnimationPanel: (layer: CanvasLayer) => void; @@ -37,6 +38,7 @@ export function ImageCanvasSelectedLayerToolbarView({ onOpenCropExpandPanel, onRemoveBackground, isSplittingIconSpritesheet, + isPersistingAssetKind, onSplitIconSpritesheet, onExtractUiDesignAssets, onOpenCharacterAnimationPanel, @@ -123,20 +125,38 @@ export function ImageCanvasSelectedLayerToolbarView({ {selectedLayer.assetKind === 'icon-spritesheet' ? ( ) : ( ) } - disabled={isSplittingIconSpritesheet} - aria-busy={isSplittingIconSpritesheet} + disabled={isPersistingAssetKind || isSplittingIconSpritesheet} + aria-busy={isPersistingAssetKind || isSplittingIconSpritesheet} onClick={() => onSplitIconSpritesheet(selectedLayer)} > - {isSplittingIconSpritesheet ? '拆图中' : '拆分图集'} + + {isPersistingAssetKind + ? '保存中' + : isSplittingIconSpritesheet + ? '拆图中' + : '拆分图集'} + ) : null} {selectedLayer.assetKind === 'ui-design' ? ( diff --git a/src/components/image-editor/ImageCanvasStageView.tsx b/src/components/image-editor/ImageCanvasStageView.tsx index 9341e22c2..fee6cf878 100644 --- a/src/components/image-editor/ImageCanvasStageView.tsx +++ b/src/components/image-editor/ImageCanvasStageView.tsx @@ -78,6 +78,7 @@ export type ImageCanvasStageViewProps = { generationComposerStyle: CSSProperties | null; selectedToolbarStyle: CSSProperties | null; splittingIconSpritesheetLayerIds?: ReadonlySet; + persistingAssetKindLayerIds?: ReadonlySet; uploadDropTarget: 'canvas' | 'assets' | null; contextMenu: CanvasContextMenuState | null; canvasClipboard: CanvasClipboard | null; @@ -232,6 +233,7 @@ export function ImageCanvasStageView({ generationComposerStyle, selectedToolbarStyle, splittingIconSpritesheetLayerIds = EMPTY_LAYER_ID_SET, + persistingAssetKindLayerIds = EMPTY_LAYER_ID_SET, uploadDropTarget, contextMenu, canvasClipboard, @@ -399,6 +401,9 @@ export function ImageCanvasStageView({ selectedLayer && splittingIconSpritesheetLayerIds.has(selectedLayer.id), )} + isPersistingAssetKind={Boolean( + selectedLayer && persistingAssetKindLayerIds.has(selectedLayer.id), + )} onOpenQuickEditPanel={onOpenQuickEditPanel} onOpenRedrawPanel={onOpenRedrawPanel} onOpenCropExpandPanel={onOpenCropExpandPanel} diff --git a/src/components/image-editor/useCanvasHistory.test.tsx b/src/components/image-editor/useCanvasHistory.test.tsx index 3db9b51e5..a45157ad6 100644 --- a/src/components/image-editor/useCanvasHistory.test.tsx +++ b/src/components/image-editor/useCanvasHistory.test.tsx @@ -533,6 +533,7 @@ describe('useCanvasHistory', () => { resourceId: 'resource-first-icon', assetKind: undefined, }), + expect.any(Number), ); act(() => { @@ -543,6 +544,7 @@ describe('useCanvasHistory', () => { ); expect(persistRestoredAssetKind).toHaveBeenLastCalledWith( expect.objectContaining({ assetKind: 'icon' }), + persistRestoredAssetKind.mock.calls[0]?.[1], ); expect(persistRestoredAssetKind).toHaveBeenCalledTimes(2); }); diff --git a/src/components/image-editor/useCanvasHistory.ts b/src/components/image-editor/useCanvasHistory.ts index fa91a6117..c15f8a953 100644 --- a/src/components/image-editor/useCanvasHistory.ts +++ b/src/components/image-editor/useCanvasHistory.ts @@ -42,7 +42,10 @@ type CanvasHistoryResetters = { }; type CanvasHistoryEffects = { - persistRestoredAssetKind?: (layer: CanvasLayer) => void; + persistRestoredAssetKind?: ( + layer: CanvasLayer, + historyEntryId?: number, + ) => void; }; function cloneGenerateDialog(dialog: GenerateDialogState): GenerateDialogState { @@ -74,6 +77,7 @@ export function useCanvasHistory({ }) { const undoStackRef = useRef([]); const redoStackRef = useRef([]); + const nextHistoryEntryIdRef = useRef(0); const [historyVersion, setHistoryVersion] = useState(0); const getCanvasHistorySnapshot = useCallback( @@ -93,11 +97,21 @@ export function useCanvasHistory({ ); const restoreCanvasHistorySnapshot = useCallback( - (snapshot: CanvasHistorySnapshot, action?: CanvasHistoryAction) => { + ( + snapshot: CanvasHistorySnapshot, + action?: CanvasHistoryAction, + historyEntryId?: number, + ) => { const currentSnapshot = getCanvasHistorySnapshot(); const mergedSnapshot = mergeCanvasHistorySnapshotForRestore({ current: currentSnapshot, target: snapshot, + assetKindLayerIds: + action?.type === 'change-asset-kind' + ? action.layerIds?.length + ? new Set(action.layerIds) + : undefined + : new Set(), }); setters.setLayers(mergedSnapshot.layers.map((layer) => ({ ...layer }))); setters.setViewport({ ...mergedSnapshot.viewport }); @@ -122,7 +136,7 @@ export function useCanvasHistory({ for (const layer of mergedSnapshot.layers) { const currentLayer = currentLayerById.get(layer.id); if (currentLayer && currentLayer.assetKind !== layer.assetKind) { - effects?.persistRestoredAssetKind?.({ ...layer }); + effects?.persistRestoredAssetKind?.({ ...layer }, historyEntryId); } } } @@ -135,9 +149,12 @@ export function useCanvasHistory({ action: CanvasHistoryAction, options: { snapshot?: CanvasHistorySnapshot } = {}, ) => { + nextHistoryEntryIdRef.current += 1; + const entryId = nextHistoryEntryIdRef.current; undoStackRef.current = [ ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), { + entryId, snapshot: options.snapshot ?? getCanvasHistorySnapshot(), action, createdAt: Date.now(), @@ -145,6 +162,7 @@ export function useCanvasHistory({ ]; redoStackRef.current = []; setHistoryVersion((version) => version + 1); + return entryId; }, [getCanvasHistorySnapshot], ); @@ -168,6 +186,28 @@ export function useCanvasHistory({ [], ); + const discardCanvasHistoryEntriesById = useCallback( + (entryIds: ReadonlySet) => { + if (entryIds.size === 0) { + return; + } + const keepEntry = (entry: CanvasHistoryEntry) => + !entryIds.has(entry.entryId); + const nextUndoStack = undoStackRef.current.filter(keepEntry); + const nextRedoStack = redoStackRef.current.filter(keepEntry); + if ( + nextUndoStack.length === undoStackRef.current.length && + nextRedoStack.length === redoStackRef.current.length + ) { + return; + } + undoStackRef.current = nextUndoStack; + redoStackRef.current = nextRedoStack; + setHistoryVersion((version) => version + 1); + }, + [], + ); + const undoCanvasChange = useCallback((): CanvasHistoryApplyResult => { const previousEntry = undoStackRef.current.at(-1); if (!previousEntry) { @@ -191,12 +231,17 @@ export function useCanvasHistory({ redoStackRef.current = [ ...redoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), { + entryId: previousEntry.entryId, snapshot: currentSnapshot, action: previousEntry.action, createdAt: Date.now(), }, ]; - restoreCanvasHistorySnapshot(previousEntry.snapshot, previousEntry.action); + restoreCanvasHistorySnapshot( + previousEntry.snapshot, + previousEntry.action, + previousEntry.entryId, + ); setHistoryVersion((version) => version + 1); return { status: 'success', action: previousEntry.action }; }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); @@ -223,12 +268,17 @@ export function useCanvasHistory({ undoStackRef.current = [ ...undoStackRef.current.slice(-(MAX_HISTORY_STEPS - 1)), { + entryId: nextEntry.entryId, snapshot: currentSnapshot, action: nextEntry.action, createdAt: Date.now(), }, ]; - restoreCanvasHistorySnapshot(nextEntry.snapshot, nextEntry.action); + restoreCanvasHistorySnapshot( + nextEntry.snapshot, + nextEntry.action, + nextEntry.entryId, + ); setHistoryVersion((version) => version + 1); return { status: 'success', action: nextEntry.action }; }, [getCanvasHistorySnapshot, restoreCanvasHistorySnapshot]); @@ -241,6 +291,7 @@ export function useCanvasHistory({ restoreCanvasHistorySnapshot, captureCanvasHistory, discardCanvasHistoryEntriesContainingLayer, + discardCanvasHistoryEntriesById, undoCanvasChange, redoCanvasChange, }; diff --git a/src/components/image-editor/useImageCanvasLayerCommands.ts b/src/components/image-editor/useImageCanvasLayerCommands.ts index 720f0f78f..4aa6ecc98 100644 --- a/src/components/image-editor/useImageCanvasLayerCommands.ts +++ b/src/components/image-editor/useImageCanvasLayerCommands.ts @@ -355,7 +355,11 @@ export function useImageCanvasLayerCommands({ if (!targetIds.length) { return; } - captureCanvasHistory({ type: 'change-asset-kind', count: targetIds.length }); + captureCanvasHistory({ + type: 'change-asset-kind', + count: targetIds.length, + layerIds: targetIds, + }); setLayers((currentLayers) => updateCanvasLayersByIds(currentLayers, targetIds, updater), ); From edc99e55e4a9e2a7e9e66d663215c35d893a87bf Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 29 Jul 2026 13:51:49 +0800 Subject: [PATCH 3/3] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B7=A8=E8=AE=BE?= =?UTF-8?q?=E5=A4=87=E7=94=BB=E5=B8=83=E6=97=A7=E5=BF=AB=E7=85=A7=E8=A6=86?= =?UTF-8?q?=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按用户隔离画布缓存并等待权威 revision 后开放保存 固化保存重试版本并安全处理认证重载与冲突恢复 合并迟到资源图层且隔离跨账号异步请求 强制 Web 与 External 画布写入使用 expectedRevision CAS 补充竞态回归测试并同步 OpenAPI、Skill 与技术文档 --- .../genarrative-external-editor-api/SKILL.md | 2 +- .../references/api-selection.md | 2 +- .../scripts/genarrative_external_api.py | 14 +- .../genarrative-external-v1.openapi.json | 5 +- docs/project-memory/shared-memory/pitfalls.md | 2 +- ...架构】图片画布编辑器MVP接入方案-2026-06-11.md | 6 +- ...片画布结构化持久化与迁移回滚方案-2026-07-19.md | 2 +- .../crates/api-server/src/editor_project.rs | 93 +- .../api-server/src/external_editor_api.rs | 62 +- .../useImageCanvasProjectPersistence.test.tsx | 974 +++++++++++++++++- .../useImageCanvasProjectPersistence.ts | 508 +++++++-- .../image-editor/editorProjectClient.ts | 4 +- 12 files changed, 1477 insertions(+), 197 deletions(-) diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index ac684ddf7..bca5a3cbd 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -36,7 +36,7 @@ Prefer the bundled Python helper for runnable examples: `scripts/genarrative_ext | Intent | Method and path | Required fields | | --- | --- | --- | | List/create projects | `GET/POST /api/external/v1/editor/projects` | create: optional `title` | -| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` | +| Save canvas | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` | | Upload local media | `POST /api/external/v1/assets/direct-upload-tickets` -> OSS form -> `POST /api/external/v1/assets/objects/confirm` | ticket: `legacyPrefix`, `fileName`; confirm: `objectKey`, `assetKind` | | Read private media | `GET /api/external/v1/assets/read-url` | `objectKey` or `legacyPublicPath` | | Image generation | `POST /api/external/v1/editor/images/generations` | `prompt` | diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index 77f7269e9..ad5232b9f 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -53,7 +53,7 @@ Ask a follow-up only when two routes could both be correct and produce different | Load recent project | `GET /api/external/v1/editor/projects/recent` | API Key | | Get/delete project | `GET` or `DELETE /api/external/v1/editor/projects/{projectId}` | `projectId` | | Rename project | `PATCH /api/external/v1/editor/projects/{projectId}/metadata` | `title` | -| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers` | +| Save canvas layout | `PATCH /api/external/v1/editor/projects/{projectId}/canvas` | `viewport`, `layers`, `expectedRevision` | | Add project resource | `POST /api/external/v1/editor/projects/{projectId}/resources` | `imageSrc`, `width`, `height`, `sourceType` | | Create upload ticket | `POST /api/external/v1/assets/direct-upload-tickets` | `legacyPrefix`, `fileName` | | Confirm uploaded object | `POST /api/external/v1/assets/objects/confirm` | `objectKey`, `assetKind` | diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index 6998db36c..2ddc78502 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -270,11 +270,21 @@ class GenarrativeExternalClient: fields[asset_label_field] = normalize_optional_text(asset_label) or "生成素材" return fields - def save_canvas(self, project_id: str, viewport: dict[str, Any], layers: dict[str, Any]) -> Any: + def save_canvas( + self, + project_id: str, + viewport: dict[str, Any], + layers: dict[str, Any], + expected_revision: int, + ) -> Any: return self.request_json( "PATCH", f"/api/external/v1/editor/projects/{urllib.parse.quote(project_id, safe='')}/canvas", - {"viewport": viewport, "layers": layers}, + { + "viewport": viewport, + "layers": layers, + "expectedRevision": expected_revision, + }, ) def _apply_art_spec(self, fields: dict[str, Any], prompt: str) -> str: diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 7d76b8e11..8154159d9 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -1761,7 +1761,8 @@ "type": "object", "required": [ "viewport", - "layers" + "layers", + "expectedRevision" ], "properties": { "viewport": { @@ -1778,7 +1779,7 @@ "expectedRevision": { "type": "integer", "minimum": 0, - "description": "可选的画布 revision CAS;不匹配时返回 409。" + "description": "必填的画布 revision CAS;不匹配时返回 409。" } }, "additionalProperties": false diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 8c63ce94b..35c409ae2 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -349,7 +349,7 @@ - 现象:`/api/editor/projects*`、素材库、项目资源或 layout payload 里出现数 MB 的 `data:image/*`、`data:video/*`、`data:audio/*`,刷新恢复变慢,发布入口可能 OOM / 413,素材库缩略图还可能只显示文件名。 - 原因:生成、规范图、角色图、图标 / UI spritesheet、音视频或动画帧如果直接把 Data URL / signed URL 写入 `editor_project_resource`、`editor_asset` 或 `editor_canvas.layers_json`,就把媒体本体塞进了项目快照;signed URL 还会过期,素材库也无法稳定换签。 -- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/"`、`objectKey`、`assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage` 传 `objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,也不能在后端快照回来前自动保存。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。 +- 处理:登录态媒体必须先上传 OSS / asset object,持久化只写 `imageSrc: "/"`、`objectKey`、`assetObjectId`;素材库和图层缩略图都通过 `PlatformMediaFrame -> ResolvedAssetImage` 传 `objectKey` 并调用 `/api/assets/read-url`。layout 序列化和后端保存要递归拒绝 `data:*` / `blob:`;旧行有 `objectKey` 时读出归一成 `/`,没有 `objectKey` 的旧 Data URL 必须走修复上传后回写轻量引用。刷新恢复可先用 session 轻量缓存显示,但缓存不得含内联媒体,必须按用户隔离,而且不能在后端快照回来前自动保存。认证状态变化重跑加载 effect 时,要同步用 ref 关闭写门禁并清除 revision、pending save 和 timer;不能只等 `isProjectReady=false` 的下一次 render,否则旧 effect 会先消费 skip 标记,再把公司浏览器的旧缓存无版本 PATCH 到服务端,覆盖另一台设备的新画布布局。现役 Web 与 External layout PATCH 的 `expectedRevision` 都必填,三层门禁分别放在 autosave effect、queue 和真正发送前;session cache 即使带 revision 也只有显示权。异步 project resource 创建必须把未发请求队列按用户 / 项目隔离,并记录发起时已接受的权威快照序号;若资源响应前发生认证重载、409 恢复或生成完成快照替换,只把新资源对应图层合并进当前权威布局,禁止用历史 `snapshotLayers` 整体覆盖。生成扣费、失败退款或 queue 终态后,右上角泥点余额通过 `/profile/dashboard` 回读,不做本地乐观扣减。 - 验证:Network 中 `/api/editor/projects*`、`PATCH /api/editor/projects/{id}`、素材库接口不应出现 `data:image` / `data:video` / `data:audio`;素材库和图层面板缩略图都能换签显示;`npm run test -- src/components/image-editor/ImageCanvasEditorModel.test.ts src/components/image-editor/useImageCanvasProjectPersistence.test.tsx src/components/image-editor/ImageCanvasAssetRowView.test.tsx src/components/common/PlatformMediaFrame.test.tsx src/services/assetReadUrlService.test.ts src/services/image-editor/editorProjectClient.test.ts`,后端跑 `cargo test -p api-server editor_project --manifest-path server-rs/Cargo.toml`。 - 关联:`server-rs/crates/api-server/src/editor_project.rs`、`src/components/image-editor/ImageCanvasEditorModel.ts`、`src/components/image-editor/useImageCanvasProjectPersistence.ts`、`src/components/common/PlatformMediaFrame.tsx`、`src/services/assetReadUrlService.ts`。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 3bff57d46..8569ba97b 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -46,7 +46,7 @@ - 吸附阈值以屏幕像素为准,换算到世界坐标后参与拖拽计算;边缘 / 中心线和等距吸附共用同一阈值。拖拽结束后只保存最终图层或生成占位布局,不保存临时参考线。 - 项目页封面和画布图片图层必须先渲染项目卡、图层外框、标题、尺寸和操作 chrome;图片换签或解码未完成时,只在图片区域显示轻量加载态,不阻塞外框和文字等低成本信息先出现。 - 素材量增大时,拖拽吸附热路径不得对所有素材做全量两两配对。边缘 / 中心线吸附保持线性扫描;等距吸附只在跨轴相交且轴向邻近的候选图层之间计算,避免大量远处素材拖慢 pointermove。 -- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。`PATCH /api/editor/projects/{projectId}` 只返回 `{ projectId, canvasId, updatedAt }` 轻量 ack,不再返回完整 project,前端必须以后续显式读取或生成完成返回的后端快照作为项目真相。 +- 画布自动保存使用防抖 + 串行队列:图层拖拽、缩放、资源新增和修改结果创建后延迟保存工程快照;如果上一次 `PATCH /api/editor/projects/{projectId}` 尚未完成,只保留最新待保存快照,待当前请求结束后再发送下一次保存,避免慢保存请求并发堆积触发发布入口连接限流。手型平移和小地图拖动属于临时 viewport 交互,拖动中只更新画布显示,不触发 `serializeCanvasLayout`、sessionStorage 项目缓存写入或封面快照上传,`pointerup` / `pointercancel` 后再保存最终 viewport。每次 `PATCH /api/editor/projects/{projectId}` 都必须携带最近一次服务端权威快照或保存 ack 给出的 `expectedRevision`;缺少版本号的请求在 HTTP 写入口直接拒绝,不允许回退到无版本覆盖。接口只返回 `{ projectId, canvasId, revision, updatedAt }` 轻量 ack,不返回完整 project;前端用 ack 更新后续保存版本,仍必须以后续显式读取或生成完成返回的后端快照作为项目真相。 - 移动端保留同一套状态模型,底部工具栏可横向滚动,侧边栏默认可收起。 - 项目页卡片默认点击打开工程;hover 项目卡片右下角显示 `...` 菜单,菜单承载重命名和删除。选择模式下项目卡片只切换选中态,不进入画布;底部批量工具栏提供全选 / 取消全选、已选数量、批量删除和退出选择模式。 @@ -67,7 +67,7 @@ - 画布 Agent 会话按“SpacetimeDB 元数据 + OSS 消息正文”存储:`editor_agent_conversation` 只保存 `conversationId/projectId/ownerUserId/title/messagesObjectKey/deleted/createdAt/updatedAt` 等会话元数据;消息正文整体保存为私有 OSS JSON 文档 `editor-agent/{conversationId}.json`。消息文档单对象上限为 2 MiB,同一会话的消息追加和工具结果回填由 api-server 按 `conversationId` 串行化,避免“读 OSS → 改消息 → 写 OSS”并发覆盖。前端只通过 api-server BFF 读取和发送会话,不直接读写 SpacetimeDB,也不直接读写 OSS。 - Agent 消息附件只允许引用当前工程画布资源或账号素材库图片,来源类型为 `canvas_resource` / `library_asset`,最多 9 张。附件请求可携带展示用 `imageSrc/thumbnailSrc/objectKey/width/height/label`,但持久化真相仍以后端校验后的 resource / asset 行和 OSS 对象为准;不得把 Data URL、signed URL 或 blob URL 当作会话长期事实。 - 前端不直接订阅 SpacetimeDB,统一通过 api-server 的 `/api/editor/projects*` BFF 读写。 -- 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存快照必须排除 `data:*` / `blob:` 内联媒体,且在后端项目快照返回前不得触发自动保存。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。 +- 工程刷新恢复可先应用 session 级轻量项目快照缓存,让画布和素材 chrome 尽快显示;缓存 key 和 envelope 必须按当前用户隔离并携带 revision,缓存快照必须排除 `data:*` / `blob:` 内联媒体。session 缓存只提供显示,不授予写权限;项目加载或认证身份变化触发重载时,必须先通过同步 ref 关闭写门禁、清除 revision 与待保存 timer,只有本次服务端权威快照携带 revision 并应用完成后才重新开放保存。不能只依赖异步 `isProjectReady` state 阻止同一轮 effect,也不能把缓存中的 revision 当作权威写凭据。未创建的 project resource 队列必须绑定发起用户和目标项目;已发出的 resource 请求还要捕获发起时的权威快照序号,若响应前画布已被更新权威快照替换,只把本次新资源对应的单个图层合并到当前布局后用最新 revision 保存,不得恢复请求发起前的整份旧图层数组,也不得把一个用户的 pending 图层排入另一个用户项目。后端快照回来后覆盖本地缓存显示并恢复正常保存队列。 - 未登录用户可以使用本地演示态,但不触发工程自动保存;真实图片生成 / 修改需要登录。编辑器 API 请求允许使用 refresh cookie 静默补 access token,但 401 / 403 只在编辑器局部提示登录,不清空整站登录态,也不把后端 requestId 直接作为生图弹窗主文案。 ## 后端接口 @@ -76,7 +76,7 @@ - `GET /api/editor/projects`:读取当前用户所有图片画布工程,按更新时间倒序返回。 - `POST /api/editor/projects`:创建图片画布工程。 - `GET /api/editor/projects/{projectId}`:读取指定工程及资源列表。 -- `PATCH /api/editor/projects/{projectId}`:保存 viewport 与图层布局快照;响应只包含 `{ projectId, canvasId, updatedAt }` ack,不返回完整工程快照。 +- `PATCH /api/editor/projects/{projectId}`:携带必填 `expectedRevision` 保存 viewport 与图层布局快照;响应只包含 `{ projectId, canvasId, revision, updatedAt }` ack,不返回完整工程快照。 - `PATCH /api/editor/projects/{projectId}/metadata`:重命名指定工程。 - `DELETE /api/editor/projects/{projectId}`:删除指定工程,并级联删除默认画布和资源元数据。 - `POST /api/editor/projects/{projectId}/resources`:创建画布资源记录,接收上传资源或真实生成资源元数据。 diff --git a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md index 8639b6569..bb6795e99 100644 --- a/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md +++ b/docs/【编辑器】图片画布结构化持久化与迁移回滚方案-2026-07-19.md @@ -45,7 +45,7 @@ layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualP 所有用户布局写入必须携带读取快照时获得的 `expectedRevision`。procedure 在事务中校验 canvas 当前 revision;不一致返回 `409`,前端应重载后端最新快照,不能只换上新 revision 就原样重放冲突前的整包布局。 -前端保存队列只对无 HTTP 响应的传输失败以及 `408 / 425 / 429 / 502 / 503 / 504` 做有界退避重试,`400 / 403 / 404 / 413` 等确定性错误不重复提交。若旧请求执行期间已有更新布局排队,旧请求失败后必须继续发送最新布局;`409` 后权威快照暂时加载失败时保留 pending save 并定时重新进入冲突恢复,不能等待用户再次拖动画布才恢复保存。 +前端保存队列只对无 HTTP 响应的传输失败以及 `408 / 425 / 429 / 502 / 503 / 504` 做有界退避重试,`400 / 403 / 404 / 413` 等确定性错误不重复提交。若旧请求执行期间已有更新布局排队,旧请求失败后必须继续发送最新布局;`409` 后冲突布局立即作废,权威快照暂时加载失败时保留冲突恢复状态并定时只重试 GET,不能把旧布局换上新 revision 后重放,也不能等待用户再次拖动画布才恢复。 本次结构化 V1 先保留旧 `{ viewport, layers }` PATCH 作为兼容输入。legacy canvas 即使携带 `expectedRevision` 也只做 CAS legacy 保存,不允许用户写入绕过 migration operator 直接激活 structured;只有已完成 backfill / activate、且 active 迁移记录的 revision / hash / 数量 / 资源引用校验均通过时,后端才在单个事务内把兼容输入拆成 layer / dialog 行并递增一次 revision。旧无 CAS procedure 不得写 structured canvas。V1 快照从 typed 列重组,`item_json / dialog_json` 只保留最大 512 KiB 的未结构化扩展字段。自包含本地图片序列在 active canvas 中只能继续保存已回填且 `layerId / resourceId / sourceType / item_json` 语义完全一致的原行;允许修改几何、层级、分组、显隐等 typed 布局字段。前端序列化按正常资源真相边界省略 `assetKind / generationInputs` 时,后端只从既有结构化行恢复这两个冻结字段再校验;显式修改仍拒绝。active 路径不再经过 legacy 元数据清洗,拒绝新增缺资源序列或改写既有帧、预览、prompt 和生成扩展。后续将新增、移动、缩放、删除、重排和分组收窄为有界 batch mutation;在此之前 2 MiB 仍是兼容整包入口的上限。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 5f3c0c041..af5e67aeb 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -41,14 +41,14 @@ use spacetime_client::{ EditorAssetMediaRepairRecordInput, EditorAssetRecord, EditorAssetUpdateRecordInput, EditorCanvasRecord, EditorCanvasViewportRecord, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, EditorProjectGetRecordInput, - EditorProjectLayoutSaveRecordInput, EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, - EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, - EditorProjectResourceMediaRepairRecordInput, EditorProjectResourceRecord, - EditorProjectResourceShowcaseUpdateRecordInput, EditorShowcaseAssetLikeToggleRecordInput, - EditorShowcaseAssetPublicListRecordInput, EditorShowcaseAssetRecord, - EditorShowcaseAssetSubmitRecordInput, EditorShowcaseCampaignConfigGetRecordInput, - EditorShowcaseCampaignConfigRecord, ExternalGenerationJobPhaseUpdateError, - ExternalGenerationJobPhaseUpdateRecordInput, SpacetimeClientError, + EditorProjectLayoutSaveV2RecordInput, EditorProjectRecord, EditorProjectRenameRecordInput, + EditorProjectResourceCreateRecordInput, EditorProjectResourceMediaRepairRecordInput, + EditorProjectResourceRecord, EditorProjectResourceShowcaseUpdateRecordInput, + EditorShowcaseAssetLikeToggleRecordInput, EditorShowcaseAssetPublicListRecordInput, + EditorShowcaseAssetRecord, EditorShowcaseAssetSubmitRecordInput, + EditorShowcaseCampaignConfigGetRecordInput, EditorShowcaseCampaignConfigRecord, + ExternalGenerationJobPhaseUpdateError, ExternalGenerationJobPhaseUpdateRecordInput, + SpacetimeClientError, }; use crate::{ @@ -157,7 +157,7 @@ pub struct EditorCanvasViewportPayload { pub struct EditorProjectLayoutSaveRequest { pub(crate) viewport: EditorCanvasViewportPayload, pub(crate) layers: Value, - pub(crate) expected_revision: Option, + pub(crate) expected_revision: u64, } #[derive(Debug, Deserialize)] @@ -446,8 +446,7 @@ pub struct EditorProjectResponse { pub struct EditorProjectLayoutSaveResponse { project_id: String, canvas_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - revision: Option, + revision: u64, updated_at: String, } @@ -1064,48 +1063,26 @@ pub async fn save_editor_project_layout( let layers_json = serialize_editor_layers(payload.layers)?; let owner_user_id = authenticated.claims().user_id().to_string(); let updated_at_micros = current_utc_micros(); - let (project_id, canvas_id, revision, updated_at) = - if let Some(expected_revision) = expected_revision { - let ack = state - .spacetime_client() - .save_editor_project_layout_v2_ack(EditorProjectLayoutSaveV2RecordInput { - project_id, - owner_user_id, - viewport, - layers_json, - expected_revision, - updated_at_micros, - }) - .await - .map_err(map_editor_project_error)?; - ( - ack.project_id, - ack.canvas_id, - Some(ack.revision), - ack.updated_at, - ) - } else { - let ack = state - .spacetime_client() - .save_editor_project_layout_ack(EditorProjectLayoutSaveRecordInput { - project_id, - owner_user_id, - viewport, - layers_json, - updated_at_micros, - }) - .await - .map_err(map_editor_project_error)?; - (ack.project_id, ack.canvas_id, None, ack.updated_at) - }; + let ack = state + .spacetime_client() + .save_editor_project_layout_v2_ack(EditorProjectLayoutSaveV2RecordInput { + project_id, + owner_user_id, + viewport, + layers_json, + expected_revision, + updated_at_micros, + }) + .await + .map_err(map_editor_project_error)?; Ok(json_success_body( Some(&request_context), EditorProjectLayoutSaveResponse { - project_id, - canvas_id, - revision, - updated_at, + project_id: ack.project_id, + canvas_id: ack.canvas_id, + revision: ack.revision, + updated_at: ack.updated_at, }, )) } @@ -7893,6 +7870,24 @@ mod tests { assert_eq!(error.status_code(), StatusCode::CONFLICT); } + #[test] + fn editor_project_layout_save_request_requires_expected_revision() { + let missing_revision = serde_json::from_value::(json!({ + "viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 }, + "layers": [], + })) + .expect_err("现役画布保存缺少 expectedRevision 时必须在进入写路径前失败"); + assert!(missing_revision.to_string().contains("expectedRevision")); + + let request = serde_json::from_value::(json!({ + "viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 }, + "layers": [], + "expectedRevision": 7, + })) + .expect("携带 expectedRevision 的画布保存请求应通过反序列化"); + assert_eq!(request.expected_revision, 7); + } + fn manual_screen_background_decision(hex: &str) -> EditorScreenBackgroundDecision { EditorScreenBackgroundDecision { color: parse_editor_screen_background_color(Some(hex)) diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index ee3f93733..af53d6f64 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -11,8 +11,8 @@ use spacetime_client::{ EditorAssetCreateRecordInput, EditorAssetDeleteRecordInput, EditorAssetFolderCreateRecordInput, EditorAssetFolderDeleteRecordInput, EditorAssetFolderUpdateRecordInput, EditorAssetUpdateRecordInput, EditorProjectCreateRecordInput, EditorProjectDeleteRecordInput, - EditorProjectGetRecordInput, EditorProjectLayoutSaveRecordInput, - EditorProjectRenameRecordInput, EditorProjectResourceCreateRecordInput, + EditorProjectGetRecordInput, EditorProjectRenameRecordInput, + EditorProjectResourceCreateRecordInput, }; use crate::{ @@ -33,7 +33,6 @@ use crate::{ generate_editor_icon_spritesheet_for_owner, generate_editor_image_for_owner, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, save_editor_project_layout_with_revision_and_get, serialize_editor_asset_metadata, - serialize_editor_layers, }, external_api_auth::ExternalApiPrincipal, http_error::AppError, @@ -63,7 +62,7 @@ pub struct ExternalEditorProjectCreateRequest { pub struct ExternalEditorCanvasSaveRequest { viewport: EditorCanvasViewportPayload, layers: Value, - expected_revision: Option, + expected_revision: u64, } #[derive(Debug, Deserialize)] @@ -341,29 +340,15 @@ pub async fn save_external_editor_canvas( Json(payload): Json, ) -> Result, AppError> { require_scope(&principal, SCOPE_EDITOR_CANVAS)?; - let project = if let Some(expected_revision) = payload.expected_revision { - save_editor_project_layout_with_revision_and_get( - &state, - project_id.as_str(), - principal.owner_user_id(), - payload.viewport.into_record(), - payload.layers, - expected_revision, - ) - .await? - } else { - state - .spacetime_client() - .save_editor_project_layout(EditorProjectLayoutSaveRecordInput { - project_id, - owner_user_id: principal.owner_user_id().to_string(), - viewport: payload.viewport.into_record(), - layers_json: serialize_editor_layers(payload.layers)?, - updated_at_micros: current_utc_micros(), - }) - .await - .map_err(map_editor_project_error)? - }; + let project = save_editor_project_layout_with_revision_and_get( + &state, + project_id.as_str(), + principal.owner_user_id(), + payload.viewport.into_record(), + payload.layers, + payload.expected_revision, + ) + .await?; Ok(json_success_body( Some(&request_context), @@ -803,6 +788,24 @@ fn normalize_project_title(title: Option) -> String { mod tests { use super::*; + #[test] + fn external_editor_canvas_save_request_requires_expected_revision() { + let missing_revision = serde_json::from_value::(json!({ + "viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 }, + "layers": [], + })) + .expect_err("外部画布保存缺少 expectedRevision 时必须在进入写路径前失败"); + assert!(missing_revision.to_string().contains("expectedRevision")); + + let request = serde_json::from_value::(json!({ + "viewport": { "x": 0.0, "y": 0.0, "scale": 1.0 }, + "layers": [], + "expectedRevision": 7, + })) + .expect("外部画布保存携带 expectedRevision 时应通过反序列化"); + assert_eq!(request.expected_revision, 7); + } + #[test] fn exported_openapi_json_contains_external_editor_routes_and_security() { let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse"); @@ -876,6 +879,11 @@ mod tests { parsed["components"]["schemas"]["EditorCanvas"]["properties"]["layers"]["type"], "array" ); + assert!( + parsed["components"]["schemas"]["ExternalEditorCanvasSaveRequest"]["required"] + .as_array() + .is_some_and(|required| required.contains(&json!("expectedRevision"))) + ); assert!( parsed["paths"] .get("/api/external/v1/editor/images/edits") diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx index c433c4597..ac471fb51 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx +++ b/src/components/image-editor/useImageCanvasProjectPersistence.test.tsx @@ -2,7 +2,7 @@ import { act, render, screen, waitFor } from '@testing-library/react'; import { useCallback, useMemo, useRef, useState } from 'react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ApiClientError } from '../../services/apiClient'; import type { EditorProjectSnapshot } from '../../services/image-editor/editorProjectClient'; @@ -25,7 +25,7 @@ const putEditorProjectCoverCacheMock = vi.hoisted(() => vi.fn()); const saveEditorProjectLayoutMock = vi.hoisted(() => vi.fn()); const uploadEditorMediaAssetFileMock = vi.hoisted(() => vi.fn()); const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY = - 'genarrative.imageCanvas.projectSnapshot.v1:recent'; + 'genarrative.imageCanvas.projectSnapshot.v2:user-test:recent'; vi.mock('./ImageCanvasProjectCoverSnapshotModel', async () => { const actual = await vi.importActual< @@ -92,11 +92,13 @@ function createDeferred() { function ProjectPersistenceHarness({ canAccessProtectedData = true, + currentUserId = 'user-test', initialCanvasBackgroundColor = DEFAULT_CANVAS_BACKGROUND_COLOR, initialGenerationDialogs = [], onProjectAccessLost, }: { canAccessProtectedData?: boolean; + currentUserId?: string | null; initialCanvasBackgroundColor?: string; initialGenerationDialogs?: CanvasGenerationDialogState[]; onProjectAccessLost?: () => void; @@ -174,6 +176,7 @@ function ProjectPersistenceHarness({ canvasBackgroundColor, isViewportInteracting, canAccessProtectedData, + currentUserId, openEditorLoginModal: openEditorLoginModalRef.current, onProjectAccessLost, }); @@ -380,6 +383,16 @@ function ProjectPersistenceHarness({ persistence.applyProjectSnapshot({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 1, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:01.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -435,13 +448,91 @@ function ProjectPersistenceHarness({ > apply completed project + + ); } describe('useImageCanvasProjectPersistence', () => { + afterEach(() => { + vi.useRealTimers(); + }); + beforeEach(() => { - vi.clearAllMocks(); + vi.resetAllMocks(); try { globalThis.sessionStorage?.clear(); } catch { @@ -450,6 +541,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValue({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], @@ -467,10 +568,8 @@ describe('useImageCanvasProjectPersistence', () => { }); saveEditorProjectLayoutMock.mockResolvedValue({ projectId: 'editor-project-default', - title: '空画布项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], + canvasId: 'editor-project-default:canvas:default', + revision: 1, updatedAt: '2026-06-12T00:00:00.000Z', }); createProjectCoverSnapshotBlobMock.mockResolvedValue(null); @@ -521,6 +620,227 @@ describe('useImageCanvasProjectPersistence', () => { }); }); + it('merges a late resource response into a reloaded authoritative layout', async () => { + const resourceCreate = + createDeferred(); + createEditorProjectResourceMock.mockReturnValueOnce(resourceCreate.promise); + const { rerender } = render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + + act(() => { + screen.getByRole('button', { name: 'append' }).click(); + }); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-a:local-layer-a', + ); + + rerender(); + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '其它设备的新画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 4, + layoutStorageVersion: 1, + updatedAt: '2026-07-29T02:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-remote', + resourceId: 'resource-remote', + x: 20, + y: 30, + }, + ], + resources: [ + { + resourceId: 'resource-remote', + projectId: 'editor-project-default', + imageSrc: '/generated/remote.png', + objectKey: 'generated/remote.png', + width: 320, + height: 240, + sourceType: 'uploaded', + }, + ], + updatedAt: '2026-07-29T02:00:00.000Z', + }); + rerender(); + await waitFor(() => { + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-remote:resource-remote', + ); + }); + saveEditorProjectLayoutMock.mockClear(); + + await act(async () => { + resourceCreate.resolve({ + resourceId: 'resource-added-asset-a', + projectId: 'editor-project-default', + imageSrc: '/generated-character-drafts/editor/assets/asset-a.png', + objectKey: 'generated-character-drafts/editor/assets/asset-a.png', + assetObjectId: 'asset-object-a', + width: 320, + height: 240, + sourceType: 'uploaded', + }); + await resourceCreate.promise; + }); + + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-remote:resource-remote', + ); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-a:resource-added-asset-a', + ); + await waitFor(() => { + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + expectedRevision: 4, + layers: expect.arrayContaining([ + expect.objectContaining({ layerId: 'layer-remote' }), + expect.objectContaining({ + layerId: 'layer-a', + resourceId: 'resource-added-asset-a', + }), + ]), + }), + ); + }); + }); + + it('merges a late resource response after conflict recovery replaces the layout', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '冲突前画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 3, + layoutStorageVersion: 1, + updatedAt: '2026-07-29T05:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T05:00:00.000Z', + }); + const resourceCreate = + createDeferred(); + createEditorProjectResourceMock.mockReturnValueOnce(resourceCreate.promise); + const conflict = new ApiClientError({ + message: '图片画布版本冲突', + status: 409, + code: 'conflict', + }); + saveEditorProjectLayoutMock + .mockRejectedValueOnce(conflict) + .mockResolvedValueOnce({ + projectId: 'editor-project-default', + canvasId: 'editor-project-default:canvas:default', + revision: 5, + updatedAt: '2026-07-29T05:00:02.000Z', + }); + loadEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '其它设备画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 4, + layoutStorageVersion: 1, + updatedAt: '2026-07-29T05:00:01.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [ + { + layerId: 'layer-remote', + resourceId: 'resource-remote', + x: 20, + y: 30, + }, + ], + resources: [ + { + resourceId: 'resource-remote', + projectId: 'editor-project-default', + imageSrc: '/generated/remote.png', + objectKey: 'generated/remote.png', + width: 320, + height: 240, + sourceType: 'uploaded', + }, + ], + updatedAt: '2026-07-29T05:00:01.000Z', + }); + + render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'append' }).click(); + }); + await act(async () => { + vi.advanceTimersByTime(451); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-remote:resource-remote', + ); + expect(screen.getByTestId('layers').textContent).not.toContain('layer-a:'); + + await act(async () => { + resourceCreate.resolve({ + resourceId: 'resource-added-asset-a', + projectId: 'editor-project-default', + imageSrc: '/generated-character-drafts/editor/assets/asset-a.png', + objectKey: 'generated-character-drafts/editor/assets/asset-a.png', + assetObjectId: 'asset-object-a', + width: 320, + height: 240, + sourceType: 'uploaded', + }); + await resourceCreate.promise; + await Promise.resolve(); + }); + + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-remote:resource-remote', + ); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-a:resource-added-asset-a', + ); + expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith( + 2, + 'editor-project-default', + expect.objectContaining({ + expectedRevision: 4, + layers: expect.arrayContaining([ + expect.objectContaining({ layerId: 'layer-remote' }), + expect.objectContaining({ + layerId: 'layer-a', + resourceId: 'resource-added-asset-a', + }), + ]), + }), + ); + }); + it('reloads the authoritative snapshot after a revision conflict without replaying the stale layout', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', @@ -613,6 +933,160 @@ describe('useImageCanvasProjectPersistence', () => { ); }); + it('ignores an authoritative callback older than the applied project revision', async () => { + render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + + act(() => { + screen.getByRole('button', { name: 'apply completed project' }).click(); + screen.getByRole('button', { name: 'apply stale project' }).click(); + }); + + expect(screen.getByTestId('project-title').textContent).toBe('空画布项目'); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-generated:resource-generated', + ); + }); + + it('rejects an asynchronous project callback after authority is revoked', async () => { + const { rerender } = render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + + rerender(); + act(() => { + screen.getByRole('button', { name: 'apply completed project' }).click(); + }); + + expect(screen.getByTestId('project-title').textContent).toBe('空画布项目'); + expect(screen.getByTestId('layers').textContent).toBe(''); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + }); + + it('keeps a newer same-user completion snapshot that arrives during an authority reload', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '重载前画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 3, + layoutStorageVersion: 0, + updatedAt: '2026-07-29T02:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T02:00:00.000Z', + }); + const { rerender } = render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + const staleReload = createDeferred(); + + await act(async () => { + rerender(); + await Promise.resolve(); + }); + loadOrCreateRecentEditorProjectMock.mockReturnValueOnce(staleReload.promise); + await act(async () => { + rerender(); + await Promise.resolve(); + }); + act(() => { + screen.getByRole('button', { name: 'apply newer project' }).click(); + }); + expect(screen.getByTestId('project-title').textContent).toBe( + '异步完成的新画布', + ); + + await act(async () => { + staleReload.resolve({ + projectId: 'editor-project-default', + title: '更早发起的旧读取', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 4, + layoutStorageVersion: 0, + updatedAt: '2026-07-29T02:30:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T02:30:00.000Z', + }); + await staleReload.promise; + await Promise.resolve(); + }); + + expect(screen.getByTestId('project-title').textContent).toBe( + '异步完成的新画布', + ); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-async-completed:resource-async-completed', + ); + saveEditorProjectLayoutMock.mockClear(); + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ expectedRevision: 5 }), + ); + }); + + it('does not drain one user pending resource layer into another user project', async () => { + const firstUserLoad = createDeferred(); + loadOrCreateRecentEditorProjectMock.mockReturnValueOnce( + firstUserLoad.promise, + ); + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-user-two', + title: '用户二画布', + canvas: { + canvasId: 'editor-project-user-two:canvas:default', + projectId: 'editor-project-user-two', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 7, + layoutStorageVersion: 0, + updatedAt: '2026-07-29T04:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T04:00:00.000Z', + }); + const { rerender } = render( + , + ); + act(() => { + screen.getByRole('button', { name: 'append' }).click(); + }); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + + rerender(); + await waitFor(() => { + expect(screen.getByTestId('project-title').textContent).toBe( + '用户二画布', + ); + }); + + expect(screen.getByTestId('layers').textContent).toBe(''); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + }); + it('continues with the latest pending layout when an in-flight save fails', async () => { const firstSave = createDeferred(); render(); @@ -672,7 +1146,175 @@ describe('useImageCanvasProjectPersistence', () => { } }); - it('retries conflict recovery when the authoritative snapshot reload fails', async () => { + it('drains a new-authority save after an old in-flight request fails', async () => { + const firstSave = createDeferred<{ + projectId: string; + canvasId: string; + revision: number; + updatedAt: string; + }>(); + const { rerender } = render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + saveEditorProjectLayoutMock.mockClear(); + saveEditorProjectLayoutMock + .mockReturnValueOnce(firstSave.promise) + .mockResolvedValue({ + projectId: 'editor-project-default', + canvasId: 'editor-project-default:canvas:default', + revision: 1, + updatedAt: '2026-07-20T00:00:02.000Z', + }); + vi.useFakeTimers(); + + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + + await act(async () => { + rerender(); + await Promise.resolve(); + }); + await act(async () => { + rerender(); + await Promise.resolve(); + await Promise.resolve(); + }); + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + + await act(async () => { + firstSave.reject(new TypeError('old connection dropped')); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); + expect(saveEditorProjectLayoutMock).toHaveBeenNthCalledWith( + 2, + 'editor-project-default', + expect.objectContaining({ expectedRevision: 0 }), + ); + }); + + it('keeps conflict recovery cache isolated when an old runner drains a new user save', async () => { + const projectForUser = ( + userNumber: 'one' | 'two', + revision: number, + title: string, + ): EditorProjectSnapshot => ({ + projectId: `editor-project-user-${userNumber}`, + title, + canvas: { + canvasId: `editor-project-user-${userNumber}:canvas:default`, + projectId: `editor-project-user-${userNumber}`, + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision, + layoutStorageVersion: 0, + updatedAt: `2026-07-20T00:00:0${revision}.000Z`, + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: `2026-07-20T00:00:0${revision}.000Z`, + }); + loadOrCreateRecentEditorProjectMock + .mockResolvedValueOnce(projectForUser('one', 3, '用户一画布')) + .mockResolvedValueOnce(projectForUser('two', 7, '用户二画布')); + const firstSave = createDeferred<{ + projectId: string; + canvasId: string; + revision: number; + updatedAt: string; + }>(); + const conflict = new ApiClientError({ + message: '图片画布版本冲突', + status: 409, + code: 'conflict', + }); + saveEditorProjectLayoutMock + .mockReturnValueOnce(firstSave.promise) + .mockRejectedValueOnce(conflict); + loadEditorProjectMock.mockResolvedValueOnce( + projectForUser('two', 8, '用户二冲突恢复画布'), + ); + + const { rerender } = render( + , + ); + await waitFor(() => { + expect(screen.getByTestId('project-title').textContent).toBe( + '用户一画布', + ); + }); + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); + + await act(async () => { + rerender(); + await Promise.resolve(); + await Promise.resolve(); + }); + expect(screen.getByTestId('project-title').textContent).toBe('用户二画布'); + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + }); + act(() => { + vi.advanceTimersByTime(451); + }); + + await act(async () => { + firstSave.resolve({ + projectId: 'editor-project-user-one', + canvasId: 'editor-project-user-one:canvas:default', + revision: 4, + updatedAt: '2026-07-20T00:00:04.000Z', + }); + await firstSave.promise; + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); + expect(loadEditorProjectMock).toHaveBeenCalledWith( + 'editor-project-user-two', + ); + expect(screen.getByTestId('project-title').textContent).toBe( + '用户二冲突恢复画布', + ); + const userOneCache = globalThis.sessionStorage.getItem( + 'genarrative.imageCanvas.projectSnapshot.v2:user-one:recent', + ); + const userTwoCache = globalThis.sessionStorage.getItem( + 'genarrative.imageCanvas.projectSnapshot.v2:user-two:recent', + ); + expect(userOneCache).toContain('editor-project-user-one'); + expect(userOneCache).not.toContain('editor-project-user-two'); + expect(userTwoCache).toContain('editor-project-user-two'); + expect(userTwoCache).toContain('用户二冲突恢复画布'); + }); + + it('retries only the authoritative reload after conflict recovery fails', async () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '初始画布', @@ -696,9 +1338,7 @@ describe('useImageCanvasProjectPersistence', () => { status: 409, code: 'conflict', }); - saveEditorProjectLayoutMock - .mockRejectedValueOnce(conflict) - .mockRejectedValueOnce(conflict); + saveEditorProjectLayoutMock.mockRejectedValueOnce(conflict); loadEditorProjectMock .mockRejectedValueOnce(new TypeError('connection dropped')) .mockResolvedValueOnce({ @@ -732,6 +1372,8 @@ describe('useImageCanvasProjectPersistence', () => { vi.advanceTimersByTime(451); await Promise.resolve(); await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); }); expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); expect(loadEditorProjectMock).toHaveBeenCalledTimes(1); @@ -741,7 +1383,7 @@ describe('useImageCanvasProjectPersistence', () => { await Promise.resolve(); await Promise.resolve(); }); - expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(2); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledTimes(1); expect(loadEditorProjectMock).toHaveBeenCalledTimes(2); expect(screen.getByTestId('project-title').textContent).toBe( '其它标签页已保存', @@ -813,8 +1455,12 @@ describe('useImageCanvasProjectPersistence', () => { expect(cachedRaw).toBeTruthy(); expect(cachedRaw).not.toContain('data:image'); const cached = JSON.parse(cachedRaw ?? '{}') as { + ownerUserId?: string; project?: EditorProjectSnapshot; + revision?: number; }; + expect(cached.ownerUserId).toBe('user-test'); + expect(cached.revision).toBe(1); expect(cached.project?.resources).toEqual([ expect.objectContaining({ resourceId: 'resource-added-asset-a', @@ -912,6 +1558,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '已有封面画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 12, y: -8, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 12, y: -8, scale: 1 }, layers: [ { @@ -996,6 +1652,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '本地封面画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1073,10 +1739,7 @@ describe('useImageCanvasProjectPersistence', () => { }); it('does not serialize or write session cache while viewport interactions are active', async () => { - const sessionSetItemSpy = vi.spyOn( - Storage.prototype, - 'setItem', - ); + const sessionSetItemSpy = vi.spyOn(Storage.prototype, 'setItem'); render(); expect(await screen.findByText('editor-project-default')).toBeTruthy(); @@ -1085,7 +1748,9 @@ describe('useImageCanvasProjectPersistence', () => { vi.useFakeTimers(); act(() => { - screen.getByRole('button', { name: 'begin viewport interaction' }).click(); + screen + .getByRole('button', { name: 'begin viewport interaction' }) + .click(); }); expect(screen.getByTestId('viewport-interacting').textContent).toBe('true'); act(() => { @@ -1152,6 +1817,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '已有角色项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1280,6 +1955,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1350,6 +2035,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1410,10 +2105,8 @@ describe('useImageCanvasProjectPersistence', () => { await act(async () => { saveResolvers[0]?.({ projectId: 'editor-project-default', - title: '空画布项目', - viewport: { x: 0, y: 0, scale: 1 }, - layers: [], - resources: [], + canvasId: 'editor-project-default:canvas:default', + revision: 1, updatedAt: '2026-06-12T00:00:01.000Z', }); await Promise.resolve(); @@ -1457,6 +2150,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1514,6 +2217,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '空画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1568,6 +2281,16 @@ describe('useImageCanvasProjectPersistence', () => { loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ projectId: 'editor-project-default', title: '角色画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 0, + layoutStorageVersion: 0, + updatedAt: '2026-06-21T12:15:00.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [ { @@ -1638,6 +2361,8 @@ describe('useImageCanvasProjectPersistence', () => { EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, JSON.stringify({ cachedAt: Date.now(), + ownerUserId: 'user-test', + revision: 2, project: { projectId: 'editor-project-default', title: '缓存画布项目', @@ -1689,6 +2414,16 @@ describe('useImageCanvasProjectPersistence', () => { backendProjectLoad.resolve({ projectId: 'editor-project-default', title: '后端画布项目', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 3, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:02.000Z', + }, viewport: { x: 0, y: 0, scale: 1 }, layers: [], resources: [], @@ -1706,6 +2441,203 @@ describe('useImageCanvasProjectPersistence', () => { expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); }); + it('waits for authority before creating a cached-project resource and then restores its layer', async () => { + const backendProjectLoad = createDeferred(); + loadOrCreateRecentEditorProjectMock.mockReturnValueOnce( + backendProjectLoad.promise, + ); + globalThis.sessionStorage.setItem( + EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, + JSON.stringify({ + cachedAt: Date.now(), + ownerUserId: 'user-test', + revision: 2, + project: { + projectId: 'editor-project-default', + title: '缓存画布项目', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:01.000Z', + } satisfies EditorProjectSnapshot, + }), + ); + + render(); + await waitFor(() => { + expect(screen.getByTestId('project-title').textContent).toBe( + '缓存画布项目', + ); + }); + act(() => { + screen.getByRole('button', { name: 'append' }).click(); + }); + expect(createEditorProjectResourceMock).not.toHaveBeenCalled(); + + await act(async () => { + backendProjectLoad.resolve({ + projectId: 'editor-project-default', + title: '后端权威画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 3, + layoutStorageVersion: 0, + updatedAt: '2026-06-12T00:00:02.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-06-12T00:00:02.000Z', + }); + await backendProjectLoad.promise; + }); + + await waitFor(() => { + expect(createEditorProjectResourceMock).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('layers').textContent).toContain( + 'layer-a:resource-added-asset-a', + ); + expect(saveEditorProjectLayoutMock).toHaveBeenCalledWith( + 'editor-project-default', + expect.objectContaining({ + expectedRevision: 3, + layers: expect.arrayContaining([ + expect.objectContaining({ + layerId: 'layer-a', + resourceId: 'resource-added-asset-a', + }), + ]), + }), + ); + }); + }); + + it('does not autosave a cached snapshot when user identity restarts the authoritative load', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '首次后端画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 3, + layoutStorageVersion: 0, + updatedAt: '2026-07-29T01:00:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T01:00:00.000Z', + }); + const { rerender } = render( + , + ); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + await waitFor(() => { + expect(screen.getByTestId('project-title').textContent).toBe( + '首次后端画布', + ); + }); + + globalThis.sessionStorage.setItem( + EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, + JSON.stringify({ + cachedAt: Date.now(), + ownerUserId: 'user-test', + revision: 1, + project: { + projectId: 'editor-project-default', + title: '公司旧缓存画布', + viewport: { x: 100, y: 200, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-28T01:00:00.000Z', + } satisfies EditorProjectSnapshot, + }), + ); + const secondBackendLoad = createDeferred(); + loadOrCreateRecentEditorProjectMock.mockReturnValueOnce( + secondBackendLoad.promise, + ); + saveEditorProjectLayoutMock.mockClear(); + vi.useFakeTimers(); + + await act(async () => { + rerender(); + await Promise.resolve(); + }); + expect(screen.getByTestId('project-title').textContent).toBe( + '公司旧缓存画布', + ); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + + await act(async () => { + secondBackendLoad.resolve({ + projectId: 'editor-project-default', + title: '二次后端权威画布', + canvas: { + canvasId: 'editor-project-default:canvas:default', + projectId: 'editor-project-default', + title: '默认画布', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + revision: 4, + layoutStorageVersion: 0, + updatedAt: '2026-07-29T01:01:00.000Z', + }, + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T01:01:00.000Z', + }); + await secondBackendLoad.promise; + await Promise.resolve(); + }); + expect(screen.getByTestId('project-title').textContent).toBe( + '二次后端权威画布', + ); + act(() => { + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + + it('does not save when the authoritative snapshot has no revision', async () => { + loadOrCreateRecentEditorProjectMock.mockResolvedValueOnce({ + projectId: 'editor-project-default', + title: '缺少版本的异常快照', + viewport: { x: 0, y: 0, scale: 1 }, + layers: [], + resources: [], + updatedAt: '2026-07-29T01:00:00.000Z', + }); + render(); + expect(await screen.findByText('editor-project-default')).toBeTruthy(); + await waitFor(() => { + expect(screen.getByTestId('project-title').textContent).toBe( + '缺少版本的异常快照', + ); + }); + saveEditorProjectLayoutMock.mockClear(); + vi.useFakeTimers(); + act(() => { + screen.getByRole('button', { name: 'move viewport' }).click(); + vi.advanceTimersByTime(451); + }); + expect(saveEditorProjectLayoutMock).not.toHaveBeenCalled(); + vi.useRealTimers(); + }); + it('does not load protected project data before login is available', () => { render(); diff --git a/src/components/image-editor/useImageCanvasProjectPersistence.ts b/src/components/image-editor/useImageCanvasProjectPersistence.ts index 3adecfecf..16037985a 100644 --- a/src/components/image-editor/useImageCanvasProjectPersistence.ts +++ b/src/components/image-editor/useImageCanvasProjectPersistence.ts @@ -44,22 +44,45 @@ import { type ProjectResourceOptions = { onCreated?: (resourceId: string) => void; snapshotLayers?: CanvasLayer[]; + restoreMissingLayer?: boolean; }; type PendingProjectResourceLayer = { layer: CanvasLayer; options: ProjectResourceOptions; + ownerUserId: string | null | undefined; + targetProjectId: string | null; +}; + +type PendingCreatedProjectResourceLayer = { + layer: CanvasLayer; + options: ProjectResourceOptions; + projectId: string; + ownerUserId: string | null | undefined; + authoritativeSnapshotSequence: number; + resourceId: string; }; type PendingProjectLayoutSave = { projectId: string; - input: Parameters[1]; + input: Omit< + Parameters[1], + 'expectedRevision' + >; + attemptExpectedRevision?: number; transportRetries?: number; }; type CachedEditorProjectSnapshot = { project: EditorProjectSnapshot; cachedAt: number; + ownerUserId: string; + revision: number; +}; + +type ApplyProjectSnapshotOptions = { + authoritative?: boolean; + allowProjectSwitch?: boolean; }; type ImageCanvasProjectPersistenceRefs = { @@ -141,8 +164,7 @@ function resolveProjectResourceCreateImageSrc(layer: CanvasLayer) { } const EDITOR_PROJECT_SESSION_CACHE_PREFIX = - 'genarrative.imageCanvas.projectSnapshot.v1:'; -const EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY = `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}recent`; + 'genarrative.imageCanvas.projectSnapshot.v2:'; function getEditorProjectSessionStorage() { try { @@ -152,8 +174,16 @@ function getEditorProjectSessionStorage() { } } -function editorProjectSessionCacheKey(projectId: string) { - return `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}${encodeURIComponent(projectId)}`; +function editorProjectSessionCacheKey(ownerUserId: string, projectId: string) { + return `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}${encodeURIComponent( + ownerUserId, + )}:${encodeURIComponent(projectId)}`; +} + +function editorProjectRecentSessionCacheKey(ownerUserId: string) { + return `${EDITOR_PROJECT_SESSION_CACHE_PREFIX}${encodeURIComponent( + ownerUserId, + )}:recent`; } function projectSnapshotContainsInlineMedia(project: EditorProjectSnapshot) { @@ -166,21 +196,30 @@ function projectSnapshotContainsInlineMedia(project: EditorProjectSnapshot) { } } -function readEditorProjectSessionCache(projectId: string | null) { +function readEditorProjectSessionCache( + projectId: string | null, + ownerUserId: string | null | undefined, +): CachedEditorProjectSnapshot | null { const storage = getEditorProjectSessionStorage(); - if (!storage) { + const normalizedOwnerUserId = ownerUserId?.trim(); + if (!storage || !normalizedOwnerUserId) { return null; } const key = projectId - ? editorProjectSessionCacheKey(projectId) - : EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY; + ? editorProjectSessionCacheKey(normalizedOwnerUserId, projectId) + : editorProjectRecentSessionCacheKey(normalizedOwnerUserId); try { const rawValue = storage.getItem(key); if (!rawValue) { return null; } const cached = JSON.parse(rawValue) as Partial; - if (!cached.project || typeof cached.cachedAt !== 'number') { + if ( + !cached.project || + typeof cached.cachedAt !== 'number' || + cached.ownerUserId !== normalizedOwnerUserId || + typeof cached.revision !== 'number' + ) { storage.removeItem(key); return null; } @@ -196,7 +235,12 @@ function readEditorProjectSessionCache(projectId: string | null) { storage.removeItem(key); return null; } - return cached.project; + return { + project: cached.project, + cachedAt: cached.cachedAt, + ownerUserId: cached.ownerUserId, + revision: cached.revision, + }; } catch { try { storage.removeItem(key); @@ -207,20 +251,35 @@ function readEditorProjectSessionCache(projectId: string | null) { } } -function removeEditorProjectSessionCache(projectId: string) { +function removeEditorProjectSessionCache( + projectId: string, + ownerUserId: string | null | undefined, +) { const storage = getEditorProjectSessionStorage(); - if (!storage) { + const normalizedOwnerUserId = ownerUserId?.trim(); + if (!storage || !normalizedOwnerUserId) { return; } try { - storage.removeItem(editorProjectSessionCacheKey(projectId)); + storage.removeItem( + editorProjectSessionCacheKey(normalizedOwnerUserId, projectId), + ); } catch { // ignore storage cleanup errors } } -function writeEditorProjectSessionCache(project: EditorProjectSnapshot) { - if (projectSnapshotContainsInlineMedia(project)) { +function writeEditorProjectSessionCache( + project: EditorProjectSnapshot, + ownerUserId: string | null | undefined, + revision: number | null | undefined = project.canvas?.revision, +) { + const normalizedOwnerUserId = ownerUserId?.trim(); + if ( + !normalizedOwnerUserId || + typeof revision !== 'number' || + projectSnapshotContainsInlineMedia(project) + ) { return; } const storage = getEditorProjectSessionStorage(); @@ -230,14 +289,19 @@ function writeEditorProjectSessionCache(project: EditorProjectSnapshot) { const cached: CachedEditorProjectSnapshot = { project, cachedAt: Date.now(), + ownerUserId: normalizedOwnerUserId, + revision, }; try { const serialized = JSON.stringify(cached); storage.setItem( - editorProjectSessionCacheKey(project.projectId), + editorProjectSessionCacheKey(normalizedOwnerUserId, project.projectId), + serialized, + ); + storage.setItem( + editorProjectRecentSessionCacheKey(normalizedOwnerUserId), serialized, ); - storage.setItem(EDITOR_PROJECT_RECENT_SESSION_CACHE_KEY, serialized); } catch { // ignore quota or privacy mode failures } @@ -322,12 +386,32 @@ export function useImageCanvasProjectPersistence({ }: ImageCanvasProjectPersistenceOptions) { const projectIdRef = useRef(null); const projectRevisionRef = useRef(null); + const currentUserIdRef = useRef(currentUserId); + currentUserIdRef.current = currentUserId; + const canAccessProtectedDataRef = useRef(canAccessProtectedData); + canAccessProtectedDataRef.current = canAccessProtectedData; + const authoritativeProjectIdRef = useRef(null); + const hasAuthoritativeProjectSnapshotRef = useRef(false); + const acceptedAuthoritativeSnapshotSequenceRef = useRef(0); + const lastAuthoritativeOwnerUserIdRef = useRef( + undefined, + ); + const lastAuthoritativeProjectIdRef = useRef(null); + const lastAuthoritativeRevisionRef = useRef(null); + const projectAuthorityEpochRef = useRef(0); const applyProjectSnapshotRef = useRef< - ((project: EditorProjectSnapshot) => void) | null + | (( + project: EditorProjectSnapshot, + options?: ApplyProjectSnapshotOptions, + ) => boolean) + | null >(null); const pendingProjectResourceLayersRef = useRef( [], ); + const pendingCreatedProjectResourceLayersRef = useRef< + PendingCreatedProjectResourceLayer[] + >([]); const pendingProjectLayoutSaveRef = useRef( null, ); @@ -367,17 +451,41 @@ export function useImageCanvasProjectPersistence({ if (!pendingSave) { return; } + const expectedRevision = + pendingSave.attemptExpectedRevision ?? projectRevisionRef.current; + if ( + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== pendingSave.projectId || + expectedRevision === null + ) { + pendingProjectLayoutSaveRef.current = null; + return; + } pendingProjectLayoutSaveRef.current = null; isProjectLayoutSaveRunningRef.current = true; + const saveAuthorityEpoch = projectAuthorityEpochRef.current; + const saveOwnerUserId = currentUserIdRef.current; + const attemptedSave: PendingProjectLayoutSave = { + ...pendingSave, + attemptExpectedRevision: expectedRevision, + }; + const saveStillBelongsToCurrentAuthority = () => + projectAuthorityEpochRef.current === saveAuthorityEpoch && + currentUserIdRef.current === saveOwnerUserId && + hasAuthoritativeProjectSnapshotRef.current && + authoritativeProjectIdRef.current === pendingSave.projectId; let runNextSave = false; - const expectedRevision = projectRevisionRef.current; void saveEditorProjectLayout(pendingSave.projectId, { ...pendingSave.input, - ...(expectedRevision === null ? {} : { expectedRevision }), + expectedRevision, }) .then((result) => { - if (result && typeof result.revision === 'number') { + if ( + saveStillBelongsToCurrentAuthority() && + result && + typeof result.revision === 'number' + ) { projectRevisionRef.current = Math.max( projectRevisionRef.current ?? 0, result.revision, @@ -386,29 +494,64 @@ export function useImageCanvasProjectPersistence({ runNextSave = Boolean(pendingProjectLayoutSaveRef.current); }) .catch(async (error: unknown) => { + if (!saveStillBelongsToCurrentAuthority()) { + runNextSave = Boolean(pendingProjectLayoutSaveRef.current); + return; + } if (isEditorAuthError(error)) { openEditorLoginModal(); return; } if (isEditorProjectRevisionConflict(error)) { - try { - const latestProject = await loadEditorProject(pendingSave.projectId); - writeEditorProjectSessionCache(latestProject); + const applyLatestProject = ( + latestProject: EditorProjectSnapshot, + ) => { + if (!saveStillBelongsToCurrentAuthority()) { + return; + } applyProjectSnapshotRef.current?.(latestProject); + }; + const scheduleAuthoritativeReload = (retryCount: number) => { + if ( + !saveStillBelongsToCurrentAuthority() || + retryCount >= 3 || + saveTimerRef.current !== null + ) { + return; + } + saveTimerRef.current = window.setTimeout( + () => { + saveTimerRef.current = null; + if (!saveStillBelongsToCurrentAuthority()) { + return; + } + void loadEditorProject(pendingSave.projectId) + .then(applyLatestProject) + .catch((reloadError: unknown) => { + if (!saveStillBelongsToCurrentAuthority()) { + return; + } + if (isEditorAuthError(reloadError)) { + openEditorLoginModal(); + return; + } + scheduleAuthoritativeReload(retryCount + 1); + }); + }, + Math.min(1_000 * 2 ** retryCount, 8_000), + ); + }; + try { + const latestProject = await loadEditorProject( + pendingSave.projectId, + ); + applyLatestProject(latestProject); } catch (reloadError: unknown) { if (isEditorAuthError(reloadError)) { openEditorLoginModal(); return; } - if (!pendingProjectLayoutSaveRef.current) { - pendingProjectLayoutSaveRef.current = pendingSave; - } - if (saveTimerRef.current === null) { - saveTimerRef.current = window.setTimeout(() => { - saveTimerRef.current = null; - runPendingProjectLayoutSave(); - }, 1_000); - } + scheduleAuthoritativeReload(0); } return; } @@ -422,7 +565,7 @@ export function useImageCanvasProjectPersistence({ const transportRetries = pendingSave.transportRetries ?? 0; if (transportRetries < 3) { pendingProjectLayoutSaveRef.current = { - ...pendingSave, + ...attemptedSave, transportRetries: transportRetries + 1, }; if (saveTimerRef.current === null) { @@ -536,9 +679,20 @@ export function useImageCanvasProjectPersistence({ const queueProjectLayoutSave = useCallback( ( nextProjectId: string, - input: Parameters[1], + input: Omit< + Parameters[1], + 'expectedRevision' + >, options: { delayMs?: number } = {}, ) => { + const revision = projectRevisionRef.current; + if ( + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== nextProjectId || + revision === null + ) { + return; + } pendingProjectLayoutSaveRef.current = { projectId: nextProjectId, input, @@ -551,7 +705,11 @@ export function useImageCanvasProjectPersistence({ layers: refs.layersRef.current, }); if (sessionSnapshot) { - writeEditorProjectSessionCache(sessionSnapshot); + writeEditorProjectSessionCache( + sessionSnapshot, + currentUserId, + revision, + ); } if (saveTimerRef.current) { window.clearTimeout(saveTimerRef.current); @@ -579,16 +737,83 @@ export function useImageCanvasProjectPersistence({ ); runPendingProjectLayoutSave(); }, - [persistProjectCoverSnapshot, refs.layersRef, runPendingProjectLayoutSave], + [ + currentUserId, + persistProjectCoverSnapshot, + refs.layersRef, + runPendingProjectLayoutSave, + ], + ); + + const applyCreatedProjectResourceLayer = useCallback( + (pendingLayer: PendingCreatedProjectResourceLayer) => { + if ( + currentUserIdRef.current !== pendingLayer.ownerUserId || + projectIdRef.current !== pendingLayer.projectId || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== pendingLayer.projectId + ) { + return false; + } + const { layer, options } = pendingLayer; + options.onCreated?.(pendingLayer.resourceId); + const currentLayers = refs.layersRef.current; + const currentLayer = currentLayers.find( + (candidate) => candidate.id === layer.id, + ); + const snapshotLayer = options.snapshotLayers?.find( + (candidate) => candidate.id === layer.id, + ); + const layerToPersist = currentLayer + ? { ...currentLayer, resourceId: pendingLayer.resourceId } + : (options.restoreMissingLayer || + acceptedAuthoritativeSnapshotSequenceRef.current !== + pendingLayer.authoritativeSnapshotSequence) && + snapshotLayer + ? { ...snapshotLayer, resourceId: pendingLayer.resourceId } + : null; + if (!layerToPersist) { + return true; + } + const nextLayers = currentLayer + ? currentLayers.map((candidate) => + candidate.id === layer.id ? layerToPersist : candidate, + ) + : [...currentLayers, layerToPersist]; + refs.layersRef.current = nextLayers; + setLayers(nextLayers); + queueProjectLayoutSave(pendingLayer.projectId, { + viewport: viewportToCanvasDisplayViewport(refs.viewportRef.current), + layers: serializeCanvasLayout({ + layers: nextLayers, + canvasGenerationDialogs: refs.canvasGenerationDialogsRef.current, + canvasBackgroundColor: refs.canvasBackgroundColorRef.current, + }), + }); + return true; + }, + [queueProjectLayoutSave, refs, setLayers], ); const createProjectResourceForLayer = useCallback( (layer: CanvasLayer, options: ProjectResourceOptions = {}) => { const readyProjectId = projectIdRef.current; - if (!readyProjectId) { - pendingProjectResourceLayersRef.current.push({ layer, options }); + if ( + !readyProjectId || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== readyProjectId + ) { + pendingProjectResourceLayersRef.current.push({ + layer, + options: { ...options, restoreMissingLayer: true }, + ownerUserId: currentUserIdRef.current, + targetProjectId: readyProjectId, + }); return; } + const requestOwnerUserId = currentUserIdRef.current; + const requestAuthoritativeSnapshotSequence = + acceptedAuthoritativeSnapshotSequenceRef.current; const imageSrc = resolveProjectResourceCreateImageSrc(layer); if (!imageSrc) { return; @@ -611,43 +836,23 @@ export function useImageCanvasProjectPersistence({ generationInputs: layer.generationInputs, }) .then((resource) => { - options.onCreated?.(resource.resourceId); - const layerWithResourceId = { - ...layer, + const pendingLayer: PendingCreatedProjectResourceLayer = { + projectId: readyProjectId, + ownerUserId: requestOwnerUserId, + authoritativeSnapshotSequence: + requestAuthoritativeSnapshotSequence, + layer, + options, resourceId: resource.resourceId, }; - const currentLayers = refs.layersRef.current; - const nextLayers = currentLayers.some( - (currentLayer) => currentLayer.id === layer.id, - ) - ? currentLayers.map((currentLayer) => - currentLayer.id === layer.id - ? layerWithResourceId - : currentLayer, - ) - : options.snapshotLayers?.some( - (snapshotLayer) => snapshotLayer.id === layer.id, - ) - ? options.snapshotLayers.map((snapshotLayer) => - snapshotLayer.id === layer.id - ? layerWithResourceId - : snapshotLayer, - ) - : currentLayers; - refs.layersRef.current = nextLayers; - setLayers(nextLayers); - if (nextLayers.length) { - queueProjectLayoutSave(readyProjectId, { - viewport: viewportToCanvasDisplayViewport( - refs.viewportRef.current, - ), - layers: serializeCanvasLayout({ - layers: nextLayers, - canvasGenerationDialogs: - refs.canvasGenerationDialogsRef.current, - canvasBackgroundColor: refs.canvasBackgroundColorRef.current, - }), - }); + if ( + currentUserIdRef.current !== requestOwnerUserId || + projectIdRef.current !== readyProjectId + ) { + return; + } + if (!applyCreatedProjectResourceLayer(pendingLayer)) { + pendingCreatedProjectResourceLayersRef.current.push(pendingLayer); } }) .catch((error: unknown) => { @@ -656,11 +861,79 @@ export function useImageCanvasProjectPersistence({ } }); }, - [openEditorLoginModal, queueProjectLayoutSave, refs, setLayers], + [applyCreatedProjectResourceLayer, openEditorLoginModal], + ); + + const drainPendingProjectResourceLayers = useCallback( + (readyProjectId: string) => { + const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); + pendingLayers.forEach((pendingLayer) => { + if ( + pendingLayer.ownerUserId === currentUserIdRef.current && + (pendingLayer.targetProjectId === null || + pendingLayer.targetProjectId === readyProjectId) + ) { + createProjectResourceForLayer( + pendingLayer.layer, + pendingLayer.options, + ); + } + }); + const pendingCreatedLayers = + pendingCreatedProjectResourceLayersRef.current.splice(0); + pendingCreatedLayers.forEach((pendingLayer) => { + applyCreatedProjectResourceLayer(pendingLayer); + }); + }, + [applyCreatedProjectResourceLayer, createProjectResourceForLayer], ); const applyProjectSnapshot = useCallback( - (project: EditorProjectSnapshot) => { + ( + project: EditorProjectSnapshot, + { + authoritative = true, + allowProjectSwitch = false, + }: ApplyProjectSnapshotOptions = {}, + ) => { + if (authoritative) { + const activeProjectId = projectIdRef.current; + const incomingRevision = project.canvas?.revision; + const hasCurrentProjectAuthority = + hasAuthoritativeProjectSnapshotRef.current && + authoritativeProjectIdRef.current === project.projectId; + const canResumeSameUserProjectAuthority = + !hasCurrentProjectAuthority && + canAccessProtectedDataRef.current && + activeProjectId === project.projectId && + lastAuthoritativeOwnerUserIdRef.current === currentUserId && + lastAuthoritativeProjectIdRef.current === project.projectId && + typeof incomingRevision === 'number'; + const lastKnownRevision = + lastAuthoritativeOwnerUserIdRef.current === currentUserId && + lastAuthoritativeProjectIdRef.current === project.projectId + ? lastAuthoritativeRevisionRef.current + : null; + const minimumAcceptedRevision = Math.max( + projectRevisionRef.current ?? 0, + lastKnownRevision ?? 0, + ); + if ( + currentUserIdRef.current !== currentUserId || + (!allowProjectSwitch && + !hasCurrentProjectAuthority && + !canResumeSameUserProjectAuthority) || + (!allowProjectSwitch && + activeProjectId !== null && + activeProjectId !== project.projectId) || + (activeProjectId === project.projectId && + (typeof incomingRevision !== 'number' || + incomingRevision < minimumAcceptedRevision)) + ) { + return false; + } + acceptedAuthoritativeSnapshotSequenceRef.current += 1; + } clearPendingProjectLayoutSave(); skipNextProjectLayoutSaveRef.current = true; if (projectIdRef.current !== project.projectId) { @@ -668,7 +941,20 @@ export function useImageCanvasProjectPersistence({ coverSnapshotUploadRequestRef.current += 1; } projectIdRef.current = project.projectId; - projectRevisionRef.current = project.canvas?.revision ?? null; + if (authoritative) { + const revision = project.canvas?.revision; + const hasRevision = typeof revision === 'number'; + projectRevisionRef.current = hasRevision ? revision : null; + authoritativeProjectIdRef.current = hasRevision + ? project.projectId + : null; + hasAuthoritativeProjectSnapshotRef.current = hasRevision; + if (hasRevision) { + lastAuthoritativeOwnerUserIdRef.current = currentUserId; + lastAuthoritativeProjectIdRef.current = project.projectId; + lastAuthoritativeRevisionRef.current = revision; + } + } setProjectId(project.projectId); const nextProjectTitle = project.title?.trim() || '未命名画布'; projectTitleRef.current = nextProjectTitle; @@ -717,6 +1003,18 @@ export function useImageCanvasProjectPersistence({ applyCanvasBackgroundColor( canvasBackgroundColor ?? DEFAULT_CANVAS_BACKGROUND_COLOR, ); + const projectIsAuthoritative = + authoritative && + hasAuthoritativeProjectSnapshotRef.current && + authoritativeProjectIdRef.current === project.projectId; + if (projectIsAuthoritative) { + writeEditorProjectSessionCache( + project, + currentUserId, + project.canvas?.revision, + ); + } + return projectIsAuthoritative; }, [ applyCanvasBackgroundColor, @@ -752,10 +1050,22 @@ export function useImageCanvasProjectPersistence({ ); useEffect(() => { + projectAuthorityEpochRef.current += 1; + coverSnapshotUploadRequestRef.current += 1; if (!canAccessProtectedData) { + hasAuthoritativeProjectSnapshotRef.current = false; + authoritativeProjectIdRef.current = null; + projectRevisionRef.current = null; + pendingCreatedProjectResourceLayersRef.current = []; + clearPendingProjectLayoutSave(); setIsProjectReady(false); return undefined; } + hasAuthoritativeProjectSnapshotRef.current = false; + authoritativeProjectIdRef.current = null; + projectRevisionRef.current = null; + clearPendingProjectLayoutSave(); + setIsProjectReady(false); let cancelled = false; const projectIdFromQuery = typeof window === 'undefined' @@ -763,9 +1073,12 @@ export function useImageCanvasProjectPersistence({ : new URLSearchParams(window.location.search) .get('projectid') ?.trim() || null; - const cachedProject = readEditorProjectSessionCache(projectIdFromQuery); + const cachedProject = readEditorProjectSessionCache( + projectIdFromQuery, + currentUserId, + ); if (cachedProject) { - applyProjectSnapshot(cachedProject); + applyProjectSnapshot(cachedProject.project, { authoritative: false }); } const loadProject = projectIdFromQuery ? loadEditorProject(projectIdFromQuery) @@ -776,12 +1089,24 @@ export function useImageCanvasProjectPersistence({ if (cancelled) { return; } - applyProjectSnapshot(project); - writeEditorProjectSessionCache(project); - const pendingLayers = pendingProjectResourceLayersRef.current.splice(0); - pendingLayers.forEach(({ layer, options }) => { - createProjectResourceForLayer(layer, options); + const projectIsAuthoritative = applyProjectSnapshot(project, { + allowProjectSwitch: true, }); + if (!projectIsAuthoritative) { + const activeAuthoritativeProjectId = authoritativeProjectIdRef.current; + if ( + hasAuthoritativeProjectSnapshotRef.current && + activeAuthoritativeProjectId !== null && + activeAuthoritativeProjectId === projectIdRef.current + ) { + drainPendingProjectResourceLayers(activeAuthoritativeProjectId); + setIsProjectReady(true); + } else { + setIsProjectReady(false); + } + return; + } + drainPendingProjectResourceLayers(project.projectId); setIsProjectReady(true); }) .catch((error: unknown) => { @@ -796,7 +1121,7 @@ export function useImageCanvasProjectPersistence({ return; } if (projectIdFromQuery && isEditorProjectAccessError(error)) { - removeEditorProjectSessionCache(projectIdFromQuery); + removeEditorProjectSessionCache(projectIdFromQuery, currentUserId); if (onProjectAccessLost) { onProjectAccessLost(); return; @@ -811,7 +1136,10 @@ export function useImageCanvasProjectPersistence({ }, [ canAccessProtectedData, applyProjectSnapshot, + clearPendingProjectLayoutSave, createProjectResourceForLayer, + currentUserId, + drainPendingProjectResourceLayers, onProjectAccessLost, openEditorLoginModal, ]); @@ -826,7 +1154,13 @@ export function useImageCanvasProjectPersistence({ }, []); useEffect(() => { - if (!projectId || !isProjectReady) { + if ( + !projectId || + !isProjectReady || + !hasAuthoritativeProjectSnapshotRef.current || + authoritativeProjectIdRef.current !== projectId || + projectRevisionRef.current === null + ) { return undefined; } if (skipNextProjectLayoutSaveRef.current) { diff --git a/src/services/image-editor/editorProjectClient.ts b/src/services/image-editor/editorProjectClient.ts index 41a0b0c87..fb213d9cb 100644 --- a/src/services/image-editor/editorProjectClient.ts +++ b/src/services/image-editor/editorProjectClient.ts @@ -555,13 +555,13 @@ export type EditorProjectCreateInput = { export type EditorProjectLayoutSaveInput = { viewport: EditorCanvasViewport; layers: EditorProjectLayerSnapshot[]; - expectedRevision?: number; + expectedRevision: number; }; export type EditorProjectLayoutSaveResult = { projectId: string; canvasId: string; - revision?: number; + revision: number; updatedAt: string; };