diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 72b4e9aee..bf492c40e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,18 @@ --- +## 2026-07-20 角色动作抠图前禁止透明 padding + +- 背景:图片画布角色动作此前在 BgFilter 前复用最终帧 finalizer,把 FFmpeg 抽帧先转成目标尺寸 RGBA 画布并用透明黑像素补边;透明区域进入 BgFilter、阿里云和本地键色共同读取的 OSS 源帧后,会干扰主体边缘判断并降低抠图质量。 +- 决策:仅图片画布角色动作链路在抠图前把 FFmpeg 帧转为 RGB8,按最终帧宽高的 contain 比例使用 `Triangle` 缩放到内容尺寸,不创建最终目标画布、不引入 Alpha、不插入 padding;该 RGB8 PNG owned 上传 OSS 后由三段抠图链共享。抠图返回后继续复用原最终帧 finalizer,转为 RGBA8、居中放入最终目标尺寸,并以 `RGBA(0,0,0,0)` 补边。`560×752 → 323×480` 的固定验收结果为 `323×434 RGB8` 抠图输入和上下各 `23px` 透明补边的 `323×480 RGBA8` 最终帧。 +- 补充(2026-07-21 实现收口):转 RGB8 时若解码帧携带 Alpha 通道(共享 FFmpeg 抽帧命令不固定 `-pix_fmt`,源视频为 alpha 格式时 PNG 可能是 RGBA),必须先把像素按白底合成为不透明再转 RGB8(`flatten_alpha_onto_white_rgb`),禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入本决策要消除的杂色边缘。该白底合成职责只属于图片画布角色动作的 BgFilter 输入准备阶段,不得为此在共享抽帧命令里固定像素格式。 +- 边界:不改变最终帧的 RGBA/padding 语义与透明帧格式、BgFilter 请求、OSS 上传与签名、抽帧数量和采样时间,也不改变旧 `/api/assets/character-animation/*` 动作发布链路;因为降级链复用同一个 object key,阿里云和本地键色同样读取新的无补边 RGB8 源帧。 +- 影响范围:`server-rs/crates/api-server/src/character_animation_assets.rs`、后端融合架构、角色动作专题和图片画布当前接入方案;不涉及 DTO、前端接口、SpacetimeDB schema 或运维配置。 +- 验证方式:像素测试断言 `560×752 RGB8 → 323×434 RGB8` 且无 Alpha/补边,并断言抠图结果最终成为上下各 `23px` 透明补边的 `323×480 RGBA8`;运行 `cargo test -p api-server character_animation --manifest-path server-rs/Cargo.toml`、`cargo check -p api-server --manifest-path server-rs/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 +- 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md`、`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。 + +--- + ## 2026-07-20 角色动画帧 OSS 请求使用专用连接池、并发保护与结构化重试 - 背景:角色动作逐帧流水线会同时发起源帧 PUT、透明帧 PUT 和最终帧 HEAD;原路径每次请求新建 `reqwest::Client`,且 OSS 请求错误丢失 HTTP 状态和 timeout/connect/transport 分类,多个动画任务叠加时无法在进程级限制 OSS 在途请求,也无法安全区分 PUT 与 HEAD 的失败。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index bf81fd136..8a3749ec8 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -3234,6 +3234,14 @@ - 验证: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/`。 +## 画布 Agent 的规划请求不能关闭瞬时失败重试 + +- 现象:美术 Agent 对话返回红色错误气泡 `completion error: LLM 请求超时,累计尝试 1 次`;HTTP 本身仍返回 200,前端 20 分钟 transport timeout 没有触发。 +- 原因:规划请求虽然有 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 秒,保证理论上限小于前端 20 分钟 transport timeout;响应头后的体读取/解析错误按明确失败收口。规划错误对用户统一为中文。重试发生在任何生成工具执行前,不会重复提交生成任务或扣费,不要通过提高前端 timeout 或 runner `max_turns` 掩盖 provider 重试缺失。 +- 验证:`platform-editor-agent` 测试锁定 8 分钟 hard timeout 与中文错误;前端 fake timer 用例锁定 120 秒前只显示思考动画、到点后显示耐心等待、成功/失败后移除;`api-server` AppState 测试锁定专用 client 透传 retry 次数。运行态排障按同一 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`。 + ## 前端退役目录不能只靠扫描和 ignore 隔离 - 现象:Tailwind `@source`、TypeScript 根 `include`、ESLint ignore 和 Vitest include 都排除了旧创作目录,但干净打开新版页面时,Vite 仍转换 `services/rpg-entry/index.ts`,构建产物也包含旧作品库和旧 profile 逻辑。 @@ -3241,6 +3249,14 @@ - 处理:把仍在用的公共账号 / 钱包 / 设置能力迁到明确的现役 client 与 presentation model;Vite `pre` transform 对退役模块真实路径直接失败,ESLint 在现役源上增加 restricted imports。每次恢复公共 UI 后用 `tsc --listFilesOnly` 和全新浏览器 context 复核,不能用已有 HMR 会话判绿。 - 关联:`vite.config.ts`、`.eslintrc.cjs`、`src/services/platform-entry/`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 +## SpacetimeDB schema guard 的基线不能递归扫描保留源码 + +- 现象:旧业务按“数据壳保留、业务实现退役”落地后,`check:spacetime-schema` 报几十个 `legacy_schema` 与原路径 accessor 重复;同一提交对自身比较也失败,但 `cargo` 实际可以正常编译 module。 +- 原因:当前工作树按 `Cargo.toml [lib].path` 的 crate root 可达模块扫描,基线提交却通过 `git ls-tree -r` 扫描整个 `spacetime-module/src`。原 `src/lib.rs` 和旧业务源码只供追溯、不进入 active crate,但基线全目录扫描仍会把它们与 `#[path]` 引入的历史数据壳同时解析。 +- 处理:current 与 base 必须各自读取所在快照的 Cargo manifest,并沿各自 `mod` / `#[path]` 图扫描;base 文件存在性和内容从该 Git tree 读取,不能复用当前工作树。不要忽略 `legacy_schema`、删除历史源码或吞掉 base duplicate,因为历史数据壳正是正式 schema,真实可达重复仍须失败。 +- 验证:回归测试同时覆盖“不可达旧源码同 accessor 不报错”和“两个可达模块同 accessor 仍失败”;再运行 `npm run check:spacetime-schema -- --base-ref HEAD`,确认 self-base 按当前 136 张表通过。 +- 关联:`scripts/check-spacetime-schema-guard.mjs`、`scripts/check-spacetime-schema-guard.test.ts`、`server-rs/crates/spacetime-module/Cargo.toml`、`docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md`。 + ## VectorEngine 请求超时不能脱离 worker 绝对预算(2026-07-20) - 现象:VectorEngine 单次请求超时大于 worker job 执行预算时,worker 已停止续租,provider 才超时或开始重试;最终 lease 过期、任务失败并退款,上游却可能继续消耗资源或迟到成功。 diff --git a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md index 043e13bf9..59b7c2141 100644 --- a/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md +++ b/docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md @@ -31,6 +31,11 @@ - 画布底部工具栏 / 面板 Dock 提供“画布 Agent”入口。点击后打开右侧独立 Agent 对话面板;桌面端为右侧窄面板,移动端占满可用宽度。该面板只与右上角任务侧栏互斥;素材 / 图层侧栏允许与 Agent 同时展开,切换左侧栏不得关闭 Agent。Agent 面板不得在当前画布内容下方追加内联内容,也不默认展示大段功能说明文案。 - 所有会新建画布生成占位的入口必须先创建 draft,再统一经过 `ImageCanvasGenerationPlacementModel` 计算落点,禁止各入口自行使用当前视口中心裸坐标或原图右侧固定偏移。当前覆盖入口包括 `生成图片`、`生成规范`、`生成角色形象`、`生成图标素材`、`生成视频`、`生成UI设计图` 和 `生成角色动作`。placement 模型的避让对象为所有未隐藏画布图层,以及当前 active / inactive generation dialogs 中仍存在的 placeholder;每个避让矩形按 32px 画布世界坐标间距外扩。候选落点以当前视口世界中心为距离目标,优先选择离视口中心最近且不重叠的占位位置;若中心被占用,会按上下左右和环形候选继续寻找。打开生成面板时必须把避让后的 placeholder 写入 `openCanvasGenerationDialog(...)`,并立即调用 `centerViewportOnPlacement(...)` 居中到新占位中心,保持原 viewport scale 不变;图片快速编辑不属于新建占位入口,提交后覆盖源图。 +### 角色动作帧抠图像素边界 + +- 图片画布角色动作的 FFmpeg 抽帧在上传 OSS 前转为 RGB8,并按最终帧宽高 contain 到内容尺寸;抠图前不创建最终尺寸画布、不引入 Alpha 通道、不增加 padding。同一个无补边 object key 供 `BgFilter → 阿里云通用抠图 → 本地键色` 三段链路使用。抠图完成后才转为最终目标尺寸 RGBA8,并以 `RGBA(0,0,0,0)` 居中补边。`560×752 → 323×480` 的验收样例中,抠图输入为 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。该规则只作用于图片画布角色动作输入准备,不改变旧动作发布、采样、BgFilter 请求或 OSS 流程。 +- 转 RGB8 时若解码帧携带 Alpha 通道,必须先按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha:全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘。共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只发生在 BgFilter 输入准备阶段。 + ## 交互规则 - `适合视图` 的正式语义为“显示画布所有可见元素”,不再回到固定 `x/y/scale`。 diff --git a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md index 722a2479e..de40cc649 100644 --- a/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md +++ b/docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md @@ -1,6 +1,6 @@ # 旧创作模板业务退役方案 -更新时间:`2026-07-18` +更新时间:`2026-07-21` ## 目标 @@ -55,6 +55,7 @@ - 旧 `public_work_asset_read_grant` view 与十类旧作品授权计算退出 module;匿名资产读取只保留现役 editor showcase 授权。 - `spacetime-module` 与 `spacetime-client` 的 Cargo `lib.path` 固定指向各自的 `src/active.rs`;原 `src/lib.rs` 及旧业务源码继续原位保留,但不再作为 crate 根参与编译。 - 历史表最小定义集中在 `spacetime-module/src/legacy_schema/` 与 `spacetime-module/src/runtime/legacy_schema/`,混合 profile 表的在运数据壳位于 `spacetime-module/src/runtime/active/profile.rs`;这些目录只允许 schema 和必要兼容读取定义。 +- 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 复用。 diff --git a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md index 467512157..5bdd6e6ea 100644 --- a/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md +++ b/docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md @@ -2,7 +2,7 @@ > 2026-07-18 状态更新:旧创作入口、全部模板业务 API/worker/运行态及 SpacetimeDB 业务逻辑已退役。本文逐玩法路由、流程和 DTO 章节仅作为历史设计记录;相关持久化表仍按原结构作为最小 schema 数据壳编译,当前编译与运行边界以 `server-rs/Cargo.toml`、`server-rs/crates/api-server/src/app.rs` 和 `docs/technical/【架构下线】旧创作模板业务退役方案-2026-07-17.md` 为准。 -更新时间:`2026-07-18` +更新时间:`2026-07-21` ## 后端主线 @@ -75,7 +75,8 @@ 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。 - `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` 保持为空;前端隐藏前缀并显示红色错误气泡,后端仍把该 system 消息注入后续 LLM memory,使 Agent 能读取失败上下文。工具失败同样必须形成可回读记录,不能只返回瞬时错误。 +- 完整消息文档存 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 秒,使两次 attempt 的理论上限仍早于前端消息 POST 的 20 分钟 timeout;已收到成功响应头后的响应体读取或解析失败直接按明确失败收口。重试只包围 LLM 规划请求并发生在任何待确认工具执行之前,因此不会重复提交生成任务或扣费。 - 对话附件只允许引用当前工程 `editor_project_resource` 或当前账号 `editor_asset` 的图片;前端可提交展示用 `imageSrc` / `thumbnailSrc`,后端必须按 `resourceId` / `assetId` 重新归一、校验 owner / project 和 `objectKey`,再给 LLM 或生成工具使用。 - 画布 Agent 工具复用既有编辑器图片生成 / 修改 / 图标 spritesheet BFF,并继续使用后端模型定价和 `execute_billable_asset_operation_with_cost`;前端不提交 `priceMudPoints`。 - `/messages/{messageId}/confirm` 与 `/messages/{messageId}/cancel` 只返回成功确认;前端成功后立即重新读取整个会话,以会话详情中的权威消息状态和 `externalJobId` 驱动气泡展示与任务轮询。 @@ -241,6 +242,7 @@ npm run check:server-rs-ddd - LLM:通用 LLM 门面继续使用 `GENARRATIVE_LLM_*`;创意 Agent `gpt-5.4-mini` Chat Completions 文本链路已于 2026-06 从 APIMart 迁移到 VectorEngine,使用 `VECTOR_ENGINE_BASE_URL` / `VECTOR_ENGINE_API_KEY` 构造 OpenAI-compatible client,`api-server` 会把未带 `/v1` 的 VectorEngine base URL 规范化到 `/v1` 后请求 `/chat/completions`。通用 `/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` 时可复用 `VECTOR_ENGINE_API_KEY`。`APIMART_BASE_URL` / `APIMART_API_KEY` 只作为历史残留,不再作为创意 Agent gpt-5.4-mini 客户端来源;后续排障时优先确认 VectorEngine `/v1/models`、`/v1/chat/completions` 和 `/v1/responses` 可用性。 - 图片生成:VectorEngine `gpt-image-2` 图片 provider 归属 `platform-image`,密钥只在后端环境变量中;`api-server` 内的 `openai_image_generation.rs` 只是兼容调用面和外部失败审计桥接,不再承载 provider 协议实现。实际外部生成运行记录统一落 `tracking_event`,`event_key = external_generation_run`,metadata 记录开始 / 结束时间、耗时、状态、成功标记、失败原因、provider task id 和结果摘要,不再写回过时的 `ai_task`。DashScope 只按仍在使用的历史能力单独处理,不作为 GPT-image-2 兜底。VectorEngine `/v1/images/generations` 和 `/v1/images/edits` 上游 POST 使用 `libcurl` 发送;`reqwest` 只保留给参考图 URL 下载和响应中图片 URL 下载。`/v1/images/edits` 的 multipart 参考图必须作为 libcurl 文件上传 part 发送,字段名为 `image`,实现上使用 `Form::buffer(file_name, bytes)` 并设置 `Content-Type`;不能只用 `contents(...).filename(...)`,否则上游会把请求转码为缺少图片并返回 `image is required`。`request_send` 阶段的 curl timeout / connect error 按可重试传输错误处理,最多尝试 5 次,并使用指数退避加短抖动;排障时优先看 `attempt`、`max_attempts`、`retry_delay_ms`、`reference_image_bytes_total` 和 `request_params`,不要把 `SendRequest` 当成上游业务错误。 - 抠图输入以私有 OSS 作为内存生命周期边界:生成原图和角色动作抽取帧上传时消费图片字节所有权,上传完成后不保留原图缓冲;手动去背景直接解析并校验已有 OSS object key,不下载原图。BgFilter 必须为 object key 签发 600 秒 GET URL 并通过 multipart `image_url` 提交,不用 `file` 重传;flat 链路进入阿里云 fallback 时由 `platform-matting` URL 接口单独下载并上传 `AuthorizeFileUpload` 临时对象,在推理前释放下载缓冲,继续 fallback 到本地键色时再单独下载一次原图,本地产出后释放本次原图下载缓冲。签名 URL 不得写入日志、审计或持久化。 +- 角色动作抠图输入像素边界:仅图片画布角色动作链路在 FFmpeg 抽帧后、源帧上传 OSS 前,把帧解码为 RGB8,并按最终 `frameWidth × frameHeight` 的 contain 比例使用 `Triangle` 只缩放到内容尺寸;该阶段不得创建最终目标尺寸画布、不得引入 Alpha 通道,也不得插入任何 padding。BgFilter、阿里云通用抠图和本地键色降级共享这个无补边源帧 object key。抠图返回后才统一转为 RGBA8,按相同比例居中放入最终目标尺寸画布,并用 `RGBA(0,0,0,0)` 补齐透明 padding。以 `560×752 → 323×480` 为例,抠图输入固定为无 Alpha、无补边的 `323×434 RGB8 PNG`,最终输出为上下各 `23px` 透明补边的 `323×480 RGBA8 PNG`。旧 `/api/assets/character-animation/*` 动作发布链路继续保留原有帧 finalizer,不适用该输入规则。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 阿里云通用抠图的非上海地域输入不得使用 `viapiutils/GetOssStsToken`、固定 `viapi-customer-temp` 或 OSS V1 PUT。`platform-matting` 必须按官方新版 SDK Advance 协议调用 `AuthorizeFileUpload`,使用动态返回的单对象 Policy 执行 multipart POST,再把临时上海 OSS URL 交给 `SegmentCommonImage`;输入归一化、结果下载与原尺寸 Alpha 回贴继续留在同一适配器内。该协议仍上传图片字节,不等同于阿里云服务端直接抓取任意公网 URL,也不改变上层 BgFilter → 阿里云 → 本地降级顺序。 - 编辑器抠图服务:手动 `POST /api/editor/images/background-removals` 与角色形象生成、图标 spritesheet 生成、UI 设计图素材提取、角色动作抽帧后的透明化统一走 BgFilter,配置为 `GENARRATIVE_EDITOR_BGFILTER_BASE_URL`、`GENARRATIVE_EDITOR_BGFILTER_TOKEN` 和 `GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS`,默认 base URL 为 `http://58.87.105.82/bgfilter`,默认请求超时为 `180000ms`(BgFilter 当前为 CPU 推理,单次抠图较慢,必须留足超时);旧 `GENARRATIVE_EDITOR_BACKGROUND_REMOVAL_TOKEN` 只作为 BgFilter token 的兼容回退别名,原手动去背景专用 base URL / timeout 配置已经删除。手动去背景固定传 `image_url`、`background_mode=complex`、`seg_model=birefnet`、`cross_check=off`,不传 `file` 或 `screen_color`;该 API 接收 `objectKey`、`resourceId` 或 `assetId` 候选引用;BFF 入队前统一拒绝 `data:` / `blob:`;worker 不重复入口校验,只调用 `resolve_editor_reference_object_key_for_owner`,底层 resolver 在解析引用前拒绝内联媒体,并在签名前完成登记状态和 owner 校验;直接签发 OSS URL,不下载原图。标准纯色背景四条链路固定传 `background_mode=flat`,并显式传 `image_url`、`screen_color=`、`seg_model=` 和 `cross_check=`,其中角色形象生成和角色动作逐帧去背传 `cross_check=on`,图标 spritesheet 生成和 UI 设计图素材提取传 `cross_check=off`。前端用户路径不展示抠图模型、模式或 cross-check,固定提交默认 `birefnet`,后端仍识别内部保留的 `anime-seg`;这些参数只属于后端内部供应商策略,不进入前端或外部 OpenAPI。标准纯色背景 BgFilter 调用失败,或连续失败达到 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_FAILURE_THRESHOLD`(默认 `3`)并在 `GENARRATIVE_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS`(默认 `300`)内打开熔断时,继续复用“阿里云通用抠图 → 本地 `editor_green_screen` 键色扣除”兜底链,熔断期不得直接退化到本地兜底。角色动作视频生成的背景色已与生图链路统一:`screenColor=auto` 时由视觉 LLM(`gpt-5-mini`,Responses 协议、low 推理档)读源角色图自动决策,并经硬过滤器剔除与前景 / 皮肤撞色的候选,手动 hex 则尊重用户选择;透明源角色图在提交 Ark 图生视频前先合成到选定背景色实色,使视频背景等于抠图键色;抽帧后每帧先上传私有 OSS 并释放原帧缓冲,再以该 object key 的签名 URL 固定使用 `seg_model=birefnet`、`cross_check=on` 进入上述三段式链路。阿里云通用抠图配置为 `GENARRATIVE_ALIYUN_MATTING_ENABLED`、`GENARRATIVE_ALIYUN_MATTING_ENDPOINT`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_ID`、`GENARRATIVE_ALIYUN_MATTING_ACCESS_KEY_SECRET` 和 `GENARRATIVE_ALIYUN_MATTING_REQUEST_TIMEOUT_MS`;未配置专用 AK/SK 时可复用 `ALIBABA_CLOUD_ACCESS_KEY_ID` / `ALIBABA_CLOUD_ACCESS_KEY_SECRET`,默认 endpoint 为 `imageseg.cn-shanghai.aliyuncs.com`。标准纯色背景链路中,BgFilter 调用失败和阿里云抠图链路已开始后的失败(包括源 OSS GET 成功后的解码、尺寸校验和归一化失败)都写入 `external_api_call_failure` 审计;真正开始外部调用前的本地预检不写该审计,并在 `failureStage` 中保留 `source_decode`、`source_validate` 等阶段。 - BgFilter 连接复用、重试与动作帧流水线:api-server 必须在 `AppState` 复用同一个 BgFilter HTTP Client 及 keep-alive 连接池。`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 是所有路径的基准请求超时;角色动作逐帧 BgFilter 的每一次 HTTP attempt 使用“基准超时 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`,角色形象单图、图标、UI 和手动去背景仍使用基准值。api-server 只在共享 Client 的单次 RequestBuilder 上覆盖该值;它覆盖从请求发起到响应体读取完成,是单次 attempt 的总 deadline,不是整批帧或 worker job 超时,重试会重新签发 600 秒 OSS URL 并获得同样的 request deadline,整项任务仍受 worker long-job 预算约束。flat 与 complex 请求首次失败后都立即重试 `1` 次;标准纯色背景 flat 请求第二次仍失败才进入“阿里云通用抠图 → 本地键色”降级链,手动 complex 请求第二次仍失败则返回最终错误,不接入依赖纯色键值的降级链,也不改变 flat 路径的熔断状态。角色动作全部 `32 / 40 / 48` 帧按“单帧绿幕源图 owned 上传 OSS 并释放原帧 → 以签名 URL 调 BgFilter/按 object key 降级 → 透明帧落 OSS”独立流水化,使用覆盖本次全部帧的无序在途集合连续发射;不限制 BgFilter、阿里云或本地处理,但角色动画源帧 PUT、透明帧 PUT 和最终帧 HEAD 统一复用 `AppState` 内初始化一次的 OSS HTTP Client(连接池参数为 connect 30 秒、request 60 秒、idle 300 秒、每 host 8 个 idle 连接、TCP keepalive 60 秒),并受进程级 8 路 OSS semaphore 限制。每个 OSS 网络 attempt 单独获取 permit,退避期间释放;PUT/HEAD 动画帧请求最多 3 次(250ms、500ms 退避),只重试无 HTTP 响应的传输错误、timeout、OSS PutObject 的 `400 + RequestTimeout`、PUT `400` 错误体读取失败(未解析出 `Code`,按 timeout/transport 归类)、408、429 和 5xx。动作帧 PUT 只在 400 响应中有界读取最多 16 KiB OSS 错误 XML,并保留 `Code` 与响应头优先的 `x-oss-request-id`;错误体读取超时/断流时保留已读字节,已解析出的 `Code` 优先生效,未解析出 `Code` 则按 timeout/transport 归类重试;除 `RequestTimeout` 与该错误体读取失败情形外的其他 400、401/403/404、配置、URL/签名和空请求体错误不重试。返回结果携带原始帧序并在收口时排序。任一帧最终失败时必须先排空全部已启动 Future,再让整个动作任务失败退款,不能发布缺帧动画。 diff --git a/docs/【编辑器】画布Agent对话面板-2026-07-03.md b/docs/【编辑器】画布Agent对话面板-2026-07-03.md index ed45c26c1..4f60749bb 100644 --- a/docs/【编辑器】画布Agent对话面板-2026-07-03.md +++ b/docs/【编辑器】画布Agent对话面板-2026-07-03.md @@ -96,12 +96,12 @@ ## LLM 与计费 - 编排复用 `creative_agent_gpt5_client` 的 LLM 接入配置(同 provider/env,独立用途标识),画布 Agent 规划请求固定使用 VectorEngine `gpt-5.4-mini` Chat Completions;function-calling 注册八类工具。 -- 每个用户回合必须由 LLM 返回结构化计划;LLM 未配置、请求失败或返回格式不可解析时,后端写入正文为 `ERROR <错误内容>` 的 system 消息,不使用本地关键词或“收到:...”回显兜底。该错误消息与其它 system 消息一样进入后续 LLM memory,使 Agent 能看到上一轮失败上下文。 +- 每个用户回合必须由 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。 - 工具参数中的图片 ID 是由真实 object key 或图片地址计算的稳定 SHA-256 标识;真实 data key 仅存于 api-server 的工具上下文映射,所有图片工具在执行时查表恢复,不能把 object key 或图片地址作为 LLM 可见的工具 ID。 - 用户使用「这张」「刚才那个」「上一张」「把衣服换成……」等方式指代或编辑上一张结果图时,LLM 默认选择 `edit_image` 并引用 `latestGeneratedImage` 作为源图;除非用户明确要求全新生成,否则不能因为本轮没有重新上传附件而降级为 `generate_image`。 - 规划 prompt 必须显式区分“规范展板”和“实际素材产出”:规范图、视觉规范图、风格规范图、素材规范展板、角色规范图等规范展板请求走 `generate_image`,并补齐统一视角、线条粗细、色卡、材质、阴影、圆角、状态层级、尺寸标注等要求;实际角色立绘才走 `generate_character`,多个图标素材 / 图集才走 `generate_icon_spritesheet`。 -- 画布 Agent 规划请求使用 Chat Completions、1024 `max_tokens` 和 60 秒 Agent 专用请求超时;生成图片/编辑图片仍走对应生成工具和模型计费。 +- 画布 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 秒,使两次 attempt 的理论最坏等待仍早于前端 20 分钟 transport timeout;已收到成功响应头后的响应体读取或解析失败直接按明确失败收口。规划重试发生在任何生成工具执行之前,不会重复提交生成任务或扣费;生成图片/编辑图片仍走对应生成工具和模型计费。 - function-calling runner 必须把“等待用户确认”作为显式工具语义:当本批所有工具都校验成功并进入待确认状态时,立即以成功结果结束当前规划回合并持久化助手文本与待确认卡,不得继续依赖 LLM 自行停止;未知工具、参数错误、普通连续工具和不可解析响应仍受 `max_turns` 保护。 - **对话回合免费**(聊天、分析回复不扣泥点),仅 Agent 实际触发生成工具时按对应模型定价扣泥点。 - 工具调用前后端校验泥点余额;不足时该次生成失败并在对话中以明确错误气泡告知,对话本身可继续。 @@ -116,7 +116,7 @@ 4. 消息内生成结果缩略图(纯预览,不显示名称,不点击聚焦图层); 5. 生成中的进行中动画; 6. 错误气泡(失败/余额不足,带原因); -7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,避免后端已持久化消息但前端中断请求后产生会话状态错位。 +7. 普通消息请求等待期间禁用发送按钮,不提供客户端停止操作;前端持续等待后端响应,超过 120 秒但 POST 仍 pending 时在思考气泡中显示“仍在处理中,请耐心等待”,最终成功或失败后自动移除,避免后端已持久化消息但前端中断请求后产生会话状态错位。 8. 桌面端右键消息正文可复制该条可见文本;右键消息附件或生成结果可下载素材,图片额外支持复制图片本体和“引用”到当前输入区。引用复用附件去重、9 张上限和发送链路; 9. 消息右键菜单遵循 Canva 式单实例交互:任一菜单已打开时,下一次右键必须先关闭旧菜单;新落点是消息正文或素材时再在新位置打开对应菜单,新落点没有右键动作时仅收起旧菜单,不允许多个消息菜单并存。复制、引用或下载成功后自动关闭菜单;失败时保留菜单和失败状态,避免错误无提示消失。 diff --git a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md index f2371fc67..edd32af5b 100644 --- a/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md +++ b/docs/【编辑器】画板角色形象生成入口设计-2026-06-15.md @@ -2,6 +2,8 @@ 日期:`2026-06-15` +更新时间:`2026-07-21` + ## 背景 图片画布编辑器已有普通图片生成与“生成规范”能力。本次新增“生成角色形象”入口,用于在同一画布内生成标注为“角色”的单张角色形象图片,并支持绑定角色规范与常规参考图。 @@ -162,8 +164,10 @@ - 视频生成完成后,后端先把带纯色背景的预览视频登记为 OSS 私有对象、`asset_object`、项目资源和账号素材,再按面板选择抽取对应帧数:`32`、`40` 或 `48`。未传 `assetFolderId` 时进入默认“项目”素材文件夹;后续抽帧或抠图失败不能抹掉这份已经生成成功的可恢复视频。 - 抽帧采样必须按目标帧数预留视频尾部安全步长,例如 `32帧·4秒` 最后一帧采 `3.875s`,避免 FFmpeg 在尾点附近返回成功但输出 `0` 帧。 +- 图片画布角色动作的 FFmpeg 原始帧在上传 OSS 前必须转为 RGB8,并按最终帧宽高的 contain 比例使用 `Triangle` 只缩放到内容尺寸;不得提前创建最终目标尺寸 RGBA 画布,不得引入 Alpha 通道或透明 padding。以 `560×752` 原始帧、`323×480` 最终目标为例,上传给抠图链路的源帧必须是 `323×434 RGB8 PNG`,没有上下补边。抽帧解码后若携带 Alpha 通道,必须先把像素按白底合成为不透明再转 RGB8,禁止直接丢弃 Alpha——全透明像素下未定义的 RGB 值会以杂色进入抠图输入,重新引入杂色边缘;共享 FFmpeg 抽帧命令保持不固定 `-pix_fmt`,白底合成只属于该链路的 BgFilter 输入准备阶段。 - 后端先计算整批精确采样时刻,再用单个 FFmpeg filter graph 统一解码预览视频并输出 `32 / 40 / 48` 张源帧;不得为每帧重新启动 FFmpeg、重复解码同一视频,也不得用会改变现有尾帧安全时刻的粗粒度 `fps` 抽帧替代。批量命令成功后必须逐一确认全部目标帧文件存在,缺少任一帧都按整批失败处理并保留缺帧编号、目标时刻和输出路径诊断。 - 每帧绿幕源图字节由上传 owned 消费(`frame.bytes` 移入 put,上传完成后释放原帧缓冲,不克隆保留);后续只持 object key。每次 BgFilter attempt 重新签发 600 秒 GET URL,multipart 仅传 `image_url`(加 `background_mode=flat`、`seg_model=birefnet`、`cross_check=on` 与同一次生成已选定的 `screenColor`),不传 `file`。BgFilter 主路径不重新下载原帧;失败后走 `阿里云通用抠图(按签名 URL 单独下载)→ 本地 editor_green_screen(再按 object key 独立下载一次并在产出后释放)`。BgFilter 每一次 HTTP attempt 的 timeout 使用“`GENARRATIVE_EDITOR_BGFILTER_REQUEST_TIMEOUT_MS` 基准值 + `2000ms × 本次实际帧数`”,默认 `32 / 40 / 48` 帧分别为 `244000 / 260000 / 276000ms`;首次失败后重试 `1` 次。 +- BgFilter、阿里云或本地键色返回透明结果后,后端继续通过现有最终帧 finalizer 转为 RGBA8,按宽高比居中放入最终目标尺寸,并使用 `RGBA(0,0,0,0)` 补边。上述样例最终输出必须为 `323×480 RGBA8 PNG`,顶部和底部各 `23px` 透明 padding,内容区域完整保留抠图结果。 - 全部 `32 / 40 / 48` 帧以覆盖本次所有帧的无序在途集合连续发射,允许乱序完成并最终按 `frameIndex` 排序;任一帧最终失败时先排空全部已启动 Future,再让整项任务失败退款,不发布缺帧动画。 - 抽帧结果写入 OSS,并返回帧路径、帧尺寸、帧数、fps、预览视频路径、模型、价格和实际 prompt。 - 画板前端回填角色动作结果时,必须以 `frames[0].imageSrc` 创建 `mediaType: "image-sequence"`、`assetKind: "character-animation"` 图层,并把完整 `frames` 保存为图层 `imageSequenceFrames`;`previewVideoPath` 只保留为上游预览视频来源,不作为画布主媒体。 diff --git a/scripts/check-spacetime-schema-guard.mjs b/scripts/check-spacetime-schema-guard.mjs index 618b0df9f..63dbbd9d4 100644 --- a/scripts/check-spacetime-schema-guard.mjs +++ b/scripts/check-spacetime-schema-guard.mjs @@ -55,8 +55,7 @@ function resolveBaseRef() { return 'HEAD'; } -function resolveCurrentCrateRoot() { - const manifest = readFileSync(join(repoRoot, moduleManifestPath), 'utf8'); +export function resolveCrateRootFromManifest(manifest) { const libSection = /\[lib\]\s*\n([\s\S]*?)(?=\n\[|$)/u.exec(manifest)?.[1] ?? ''; const configuredPath = /^\s*path\s*=\s*"([^"]+)"/mu.exec(libSection)?.[1]; return normalizePath( @@ -79,8 +78,9 @@ function childModuleDirectory(sourcePath, isCrateRoot) { return join(dirname(sourcePath), fileName.slice(0, -'.rs'.length)); } -function listReachableCurrentRustFiles() { - const crateRoot = resolveCurrentCrateRoot(); +export function listReachableRustFiles(readSource) { + const manifest = readSource(moduleManifestPath) ?? ''; + const crateRoot = resolveCrateRootFromManifest(manifest); const pending = [{ path: crateRoot, isCrateRoot: true }]; const visited = new Set(); const externalModulePattern = /((?:[ \t]*#\[[^\]\r\n]*\][ \t]*\r?\n)*)[ \t]*(?:pub(?:\([^)]*\))?[ \t]+)?mod[ \t]+([A-Za-z_][A-Za-z0-9_]*)[ \t]*;/gmu; @@ -91,13 +91,12 @@ function listReachableCurrentRustFiles() { continue; } - const absolutePath = join(repoRoot, current.path); - if (!existsSync(absolutePath)) { + const source = readSource(current.path); + if (source === null) { continue; } visited.add(current.path); - const source = readFileSync(absolutePath, 'utf8'); const defaultModuleDir = childModuleDirectory(current.path, current.isCrateRoot); let match; @@ -118,7 +117,7 @@ function listReachableCurrentRustFiles() { ]; const modulePath = candidates .map(normalizePath) - .find((candidate) => existsSync(join(repoRoot, candidate))); + .find((candidate) => readSource(candidate) !== null); if (modulePath && !visited.has(modulePath)) { pending.push({ path: modulePath, isCrateRoot: false }); @@ -129,26 +128,48 @@ function listReachableCurrentRustFiles() { return [...visited].sort(); } -function listBaseRustFiles(baseRef) { - const output = tryGit(['ls-tree', '-r', '--name-only', baseRef, '--', moduleSrcRoot]); +function listBaseSourcePaths(baseRef) { + const output = tryGit([ + 'ls-tree', + '-r', + '--name-only', + baseRef, + '--', + moduleManifestPath, + moduleSrcRoot, + ]); if (!output) { - return []; + return new Set(); } - return output - .split(/\r?\n/u) - .map(normalizePath) - .filter((path) => path.endsWith('.rs')) - .sort(); + return new Set(output.split(/\r?\n/u).map(normalizePath).filter(Boolean)); } function readCurrentFile(path) { + if (!existsSync(join(repoRoot, path))) { + return null; + } return readFileSync(join(repoRoot, path), 'utf8'); } function readBaseFile(baseRef, path) { - const text = tryGit(['show', `${baseRef}:${path}`]); - return text ?? ''; + return tryGit(['show', `${baseRef}:${path}`]); +} + +function createBaseSourceReader(baseRef) { + const sourcePaths = listBaseSourcePaths(baseRef); + const sourceCache = new Map(); + + return (path) => { + const normalizedPath = normalizePath(path); + if (!sourcePaths.has(normalizedPath)) { + return null; + } + if (!sourceCache.has(normalizedPath)) { + sourceCache.set(normalizedPath, readBaseFile(baseRef, normalizedPath)); + } + return sourceCache.get(normalizedPath); + }; } function lineNumberAt(text, index) { @@ -486,7 +507,7 @@ function parseTablesFromFile(path, text) { return tables; } -function collectTablesFromSources(sources) { +export function collectTablesFromSources(sources) { const tables = new Map(); const failures = []; @@ -507,16 +528,17 @@ function collectTablesFromSources(sources) { } function loadCurrentSources() { - return listReachableCurrentRustFiles().map((path) => ({ + return listReachableRustFiles(readCurrentFile).map((path) => ({ path, - text: readCurrentFile(path), + text: readCurrentFile(path) ?? '', })); } function loadBaseSources(baseRef) { - return listBaseRustFiles(baseRef).map((path) => ({ + const readSource = createBaseSourceReader(baseRef); + return listReachableRustFiles(readSource).map((path) => ({ path, - text: readBaseFile(baseRef, path), + text: readSource(path) ?? '', })); } @@ -696,4 +718,6 @@ function main() { ); } -main(); +if (process.argv[1] === fileURLToPath(import.meta.url)) { + main(); +} diff --git a/scripts/check-spacetime-schema-guard.test.ts b/scripts/check-spacetime-schema-guard.test.ts new file mode 100644 index 000000000..1ceb91956 --- /dev/null +++ b/scripts/check-spacetime-schema-guard.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; + +import { + collectTablesFromSources, + listReachableRustFiles, +} from './check-spacetime-schema-guard.mjs'; + +const manifestPath = 'server-rs/crates/spacetime-module/Cargo.toml'; +const sourceRoot = 'server-rs/crates/spacetime-module/src'; + +type SourceFiles = Record; + +function createSourceReader(files: SourceFiles) { + const sources = new Map(Object.entries(files)); + return (path: string) => sources.get(path) ?? null; +} + +function collectReachableTables(files: SourceFiles) { + const readSource = createSourceReader(files); + const paths = listReachableRustFiles(readSource); + return { + paths, + result: collectTablesFromSources( + paths.map((path: string) => ({ path, text: readSource(path) ?? '' })), + ), + }; +} + +function tableSource(structName: string, accessor: string) { + return `#[spacetimedb::table(accessor = ${accessor})]\npub struct ${structName} {\n pub id: u64,\n}\n`; +} + +describe('SpacetimeDB schema guard module reachability', () => { + it('ignores duplicate accessors in retained but unreachable legacy sources', () => { + const activePath = `${sourceRoot}/active.rs`; + const shellPath = `${sourceRoot}/legacy_schema/example.rs`; + const retiredPath = `${sourceRoot}/example.rs`; + const { paths, result } = collectReachableTables({ + [manifestPath]: '[lib]\npath = "src/active.rs"\n', + [activePath]: '#[path = "legacy_schema/example.rs"]\nmod example;\n', + [shellPath]: tableSource('ExampleSchemaShell', 'example'), + [retiredPath]: tableSource('RetiredExample', 'example'), + }); + + expect(paths).toEqual([activePath, shellPath]); + expect(result.failures).toEqual([]); + expect([...result.tables.keys()]).toEqual(['example']); + }); + + it('still rejects duplicate accessors when both definitions are reachable', () => { + const activePath = `${sourceRoot}/active.rs`; + const firstPath = `${sourceRoot}/first.rs`; + const secondPath = `${sourceRoot}/second.rs`; + const { result } = collectReachableTables({ + [manifestPath]: '[lib]\npath = "src/active.rs"\n', + [activePath]: 'mod first;\nmod second;\n', + [firstPath]: tableSource('FirstExample', 'example'), + [secondPath]: tableSource('SecondExample', 'example'), + }); + + expect(result.failures).toHaveLength(1); + expect(result.failures[0]).toMatch(/table accessor example 重复定义/u); + }); +}); diff --git a/server-rs/crates/api-server/src/character_animation_assets.rs b/server-rs/crates/api-server/src/character_animation_assets.rs index 03d37f0fc..43aa0111b 100644 --- a/server-rs/crates/api-server/src/character_animation_assets.rs +++ b/server-rs/crates/api-server/src/character_animation_assets.rs @@ -2320,6 +2320,7 @@ async fn extract_and_persist_editor_character_animation_frames( let plan = AnimationFrameExtractionPlan { frame_count: request.frame_count, apply_chroma_key: false, + prepare_for_bgfilter_input: true, sample_start_ratio: 0.0, sample_end_ratio: 1.0, }; @@ -4077,6 +4078,7 @@ fn normalize_animation_frame_extraction_plan( AnimationFrameExtractionPlan { frame_count, apply_chroma_key, + prepare_for_bgfilter_input: false, sample_start_ratio, sample_end_ratio, } @@ -4155,13 +4157,23 @@ async fn extract_animation_frames_from_preview_video( "message": format!("读取动作抽帧结果失败:{error}"), })) })?; - finalized_frames.push(finalize_animation_frame_payload( - frame_bytes.as_slice(), - "image/png", - frame_width, - frame_height, - plan.apply_chroma_key, - )?); + let finalized_frame = if plan.prepare_for_bgfilter_input { + prepare_editor_character_animation_bgfilter_input( + frame_bytes.as_slice(), + "image/png", + frame_width, + frame_height, + )? + } else { + finalize_animation_frame_payload( + frame_bytes.as_slice(), + "image/png", + frame_width, + frame_height, + plan.apply_chroma_key, + )? + }; + finalized_frames.push(finalized_frame); } Ok::<_, AppError>(finalized_frames) @@ -4457,6 +4469,74 @@ fn run_process_with_timeout( } } +/// BgFilter 只接受不透明 RGB 输入;直接丢弃 alpha 会让全透明像素下未定义的 +/// RGB 值以杂色进入抠图,这里先按白底合成再转 RGB。 +fn flatten_alpha_onto_white_rgb(image: image::DynamicImage) -> image::RgbImage { + if !image.color().has_alpha() { + return image.to_rgb8(); + } + let rgba = image.to_rgba8(); + let mut flattened = image::RgbImage::new(rgba.width(), rgba.height()); + for (source, target) in rgba.pixels().zip(flattened.pixels_mut()) { + let [red, green, blue, alpha] = source.0; + let opacity = f32::from(alpha) / 255.0; + let blend = |channel: u8| -> u8 { + (f32::from(channel) * opacity + 255.0 * (1.0 - opacity)).round() as u8 + }; + target.0 = [blend(red), blend(green), blend(blue)]; + } + flattened +} + +fn prepare_editor_character_animation_bgfilter_input( + source: &[u8], + mime_type: &str, + target_width: u32, + target_height: u32, +) -> Result { + let image = match image_format_from_mime(mime_type) { + Some(format) => image::load_from_memory_with_format(source, format), + None => image::load_from_memory(source), + } + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "character-animation", + "message": format!("解析 BgFilter 输入动作帧图片失败:{error}"), + })) + })?; + let image = flatten_alpha_onto_white_rgb(image); + + let (draw_width, draw_height) = + compute_contain_dimensions(image.width(), image.height(), target_width, target_height); + let resized = if (draw_width, draw_height) == (image.width(), image.height()) { + image + } else { + image::imageops::resize(&image, draw_width, draw_height, FilterType::Triangle) + }; + + let mut encoded = Vec::new(); + let encoder = PngEncoder::new(&mut encoded); + encoder + .write_image( + resized.as_raw(), + resized.width(), + resized.height(), + ColorType::Rgb8.into(), + ) + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "character-animation", + "message": format!("编码 BgFilter 输入动作帧 PNG 失败:{error}"), + })) + })?; + + Ok(FinalizedAnimationFrame { + bytes: encoded, + mime_type: "image/png".to_string(), + extension: "png".to_string(), + }) +} + fn finalize_animation_frame_payload( source: &[u8], mime_type: &str, @@ -4464,13 +4544,7 @@ fn finalize_animation_frame_payload( frame_height: u32, apply_chroma_key: bool, ) -> Result { - let image_format = match mime_type { - "image/png" => Some(ImageFormat::Png), - "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), - "image/webp" => Some(ImageFormat::WebP), - _ => None, - }; - let mut image = match image_format { + let mut image = match image_format_from_mime(mime_type) { Some(format) => image::load_from_memory_with_format(source, format), None => image::load_from_memory(source), } @@ -4517,8 +4591,39 @@ fn finalize_animation_frame_payload( fn contain_rgba_image(source: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage { let mut canvas = RgbaImage::from_pixel(target_width, target_height, Rgba([0, 0, 0, 0])); - let source_width = source.width().max(1); - let source_height = source.height().max(1); + let (draw_width, draw_height) = + compute_contain_dimensions(source.width(), source.height(), target_width, target_height); + let offset_x = ((target_width - draw_width) / 2) as i64; + let offset_y = ((target_height - draw_height) / 2) as i64; + if (draw_width, draw_height) == (source.width(), source.height()) { + image::imageops::overlay(&mut canvas, source, offset_x, offset_y); + } else { + let resized = + image::imageops::resize(source, draw_width, draw_height, FilterType::Triangle); + image::imageops::overlay(&mut canvas, &resized, offset_x, offset_y); + } + canvas +} + +fn image_format_from_mime(mime_type: &str) -> Option { + match mime_type { + "image/png" => Some(ImageFormat::Png), + "image/jpeg" | "image/jpg" => Some(ImageFormat::Jpeg), + "image/webp" => Some(ImageFormat::WebP), + _ => None, + } +} + +fn compute_contain_dimensions( + source_width: u32, + source_height: u32, + target_width: u32, + target_height: u32, +) -> (u32, u32) { + let target_width = target_width.max(1); + let target_height = target_height.max(1); + let source_width = source_width.max(1); + let source_height = source_height.max(1); let scale = (target_width as f32 / source_width as f32) .min(target_height as f32 / source_height as f32); let draw_width = ((source_width as f32 * scale).round() as u32) @@ -4527,11 +4632,7 @@ fn contain_rgba_image(source: &RgbaImage, target_width: u32, target_height: u32) let draw_height = ((source_height as f32 * scale).round() as u32) .max(1) .min(target_height); - let resized = image::imageops::resize(source, draw_width, draw_height, FilterType::Triangle); - let offset_x = ((target_width - draw_width) / 2) as i64; - let offset_y = ((target_height - draw_height) / 2) as i64; - image::imageops::overlay(&mut canvas, &resized, offset_x, offset_y); - canvas + (draw_width, draw_height) } async fn load_media_source_payload( @@ -5848,6 +5949,7 @@ struct BackendFrameExtractionSettings { struct AnimationFrameExtractionPlan { frame_count: u32, apply_chroma_key: bool, + prepare_for_bgfilter_input: bool, sample_start_ratio: f32, sample_end_ratio: f32, } @@ -6356,6 +6458,109 @@ mod tests { ); } + #[test] + fn editor_character_animation_bgfilter_input_is_rgb_without_padding() { + let source = image::RgbImage::from_pixel(560, 752, image::Rgb([17, 99, 201])); + let mut source_png = Vec::new(); + PngEncoder::new(&mut source_png) + .write_image( + source.as_raw(), + source.width(), + source.height(), + ColorType::Rgb8.into(), + ) + .expect("source RGB frame should encode"); + + let prepared = prepare_editor_character_animation_bgfilter_input( + source_png.as_slice(), + "image/png", + 323, + 480, + ) + .expect("BgFilter input should be prepared"); + let decoded = + image::load_from_memory_with_format(prepared.bytes.as_slice(), ImageFormat::Png) + .expect("prepared BgFilter input should decode"); + + assert_eq!(prepared.mime_type, "image/png"); + assert_eq!(prepared.extension, "png"); + assert_eq!(decoded.width(), 323); + assert_eq!(decoded.height(), 434); + assert_eq!(decoded.color(), ColorType::Rgb8); + assert!( + decoded + .to_rgb8() + .pixels() + .all(|pixel| pixel.0 == [17, 99, 201]) + ); + } + + #[test] + fn editor_character_animation_bgfilter_input_flattens_alpha_onto_white() { + let mut source = RgbaImage::from_pixel(8, 8, Rgba([17, 99, 201, 255])); + source.put_pixel(0, 0, Rgba([255, 0, 0, 0])); + source.put_pixel(1, 0, Rgba([0, 0, 0, 127])); + let mut source_png = Vec::new(); + PngEncoder::new(&mut source_png) + .write_image( + source.as_raw(), + source.width(), + source.height(), + ColorType::Rgba8.into(), + ) + .expect("source RGBA frame should encode"); + + let prepared = prepare_editor_character_animation_bgfilter_input( + source_png.as_slice(), + "image/png", + 8, + 8, + ) + .expect("BgFilter input should be prepared"); + let decoded = + image::load_from_memory_with_format(prepared.bytes.as_slice(), ImageFormat::Png) + .expect("prepared BgFilter input should decode") + .to_rgb8(); + + assert_eq!(decoded.get_pixel(0, 0).0, [255, 255, 255]); + assert_eq!(decoded.get_pixel(1, 0).0, [128, 128, 128]); + assert_eq!(decoded.get_pixel(2, 0).0, [17, 99, 201]); + } + + #[test] + fn editor_character_animation_final_frame_adds_transparent_vertical_padding() { + let source = RgbaImage::from_pixel(323, 434, Rgba([31, 127, 223, 191])); + let mut source_png = Vec::new(); + PngEncoder::new(&mut source_png) + .write_image( + source.as_raw(), + source.width(), + source.height(), + ColorType::Rgba8.into(), + ) + .expect("source RGBA frame should encode"); + + let finalized = + finalize_animation_frame_payload(source_png.as_slice(), "image/png", 323, 480, false) + .expect("final transparent frame should be finalized"); + let decoded = + image::load_from_memory_with_format(finalized.bytes.as_slice(), ImageFormat::Png) + .expect("final transparent frame should decode"); + + assert_eq!(decoded.width(), 323); + assert_eq!(decoded.height(), 480); + assert_eq!(decoded.color(), ColorType::Rgba8); + let output = decoded.to_rgba8(); + for (x, y, pixel) in output.enumerate_pixels() { + let expected = if (23..457).contains(&y) { + [31, 127, 223, 191] + } else { + [0, 0, 0, 0] + }; + assert_eq!(pixel.0, expected, "unexpected pixel at ({x}, {y})"); + } + } + #[test] fn editor_character_animation_frames_use_three_stage_matting_fallback() { let source = include_str!("character_animation_assets.rs"); @@ -6365,6 +6570,7 @@ mod tests { "async fn publish_animation_set", &[ "apply_chroma_key: false", + "prepare_for_bgfilter_input: true", "editor_character_animation_bgfilter_request_timeout_ms", "state.config.editor_bgfilter_request_timeout_ms", "process_and_persist_editor_character_animation_frame", @@ -6375,6 +6581,22 @@ mod tests { "frame_errors.sort_by_key", ], ); + assert_function_contains( + source, + "fn normalize_animation_frame_extraction_plan", + "fn normalize_sample_ratio", + &["apply_chroma_key", "prepare_for_bgfilter_input: false"], + ); + assert_function_contains_in_order( + source, + "async fn extract_animation_frames_from_preview_video", + "fn create_animation_temp_dir", + &[ + "plan.prepare_for_bgfilter_input", + "prepare_editor_character_animation_bgfilter_input", + "finalize_animation_frame_payload", + ], + ); assert_function_contains_in_order( source, "async fn process_and_persist_editor_character_animation_frame", diff --git a/server-rs/crates/api-server/src/state.rs b/server-rs/crates/api-server/src/state.rs index 6d0b18d9a..4e263c3f3 100644 --- a/server-rs/crates/api-server/src/state.rs +++ b/server-rs/crates/api-server/src/state.rs @@ -48,6 +48,8 @@ use crate::work_author::{ }; const ADMIN_ROLE: &str = "admin"; +const EDITOR_AGENT_LLM_MAX_RETRIES: u32 = 1; +const EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS: u64 = 60_000; pub(crate) const CHARACTER_ANIMATION_OSS_MAX_CONCURRENCY: usize = 8; pub type HttpRequestPermitPool = Semaphore; @@ -2084,8 +2086,10 @@ fn build_editor_agent_llm_client( api_key.to_string(), platform_llm::EDITOR_AGENT_GPT5_MODEL.to_string(), config.llm_request_timeout_ms, - 0, - config.llm_retry_backoff_ms, + config.llm_max_retries.min(EDITOR_AGENT_LLM_MAX_RETRIES), + config + .llm_retry_backoff_ms + .min(EDITOR_AGENT_LLM_MAX_RETRY_BACKOFF_MS), )?; Ok(Some(LlmClient::new(llm_config)?)) @@ -2354,6 +2358,8 @@ mod tests { fn app_state_builds_editor_agent_llm_client_from_vector_engine_settings() { let mut config = AppConfig::default(); config.llm_api_key = None; + config.llm_max_retries = 2; + config.llm_retry_backoff_ms = 120_000; config.vector_engine_base_url = "https://api.vectorengine.test".to_string(); config.vector_engine_api_key = Some("ve-key".to_string()); @@ -2371,6 +2377,8 @@ mod tests { "https://api.vectorengine.test/v1/chat/completions" ); assert!(!client.config().official_fallback()); + assert_eq!(client.config().max_retries(), 1); + assert_eq!(client.config().retry_backoff_ms(), 60_000); } fn test_feature_gate(gate_key: &str) -> module_runtime::FeatureGateConfigSnapshot { 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 380f4f2d8..226f8ef61 100644 --- a/server-rs/crates/platform-editor-agent/src/agent/agent.rs +++ b/server-rs/crates/platform-editor-agent/src/agent/agent.rs @@ -8,7 +8,7 @@ use platform_llm::{EDITOR_AGENT_GPT5_MODEL, LlmClient, LlmMessage, LlmTextReques use serde_json::Value; const EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS: u32 = 1024; -const EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS: u64 = 60_000; +const EDITOR_AGENT_LLM_HARD_REQUEST_TIMEOUT_MS: u64 = 480_000; pub struct LlmCompletionModel { client: LlmClient, @@ -41,7 +41,7 @@ fn build_editor_agent_llm_request(messages: Vec) -> LlmTextRequest { LlmTextRequest::new(messages) .with_model(EDITOR_AGENT_GPT5_MODEL) .with_max_tokens(EDITOR_AGENT_LLM_MAX_OUTPUT_TOKENS) - .with_request_timeout_ms(EDITOR_AGENT_LLM_REQUEST_TIMEOUT_MS) + .with_request_timeout_ms(EDITOR_AGENT_LLM_HARD_REQUEST_TIMEOUT_MS) } pub struct LlmChatAgentBuilder { @@ -188,7 +188,7 @@ mod tests { assert_eq!(request.model.as_deref(), Some(EDITOR_AGENT_GPT5_MODEL)); assert_eq!(request.max_tokens, Some(1024)); - assert_eq!(request.request_timeout_ms, Some(60_000)); + assert_eq!(request.request_timeout_ms, Some(480_000)); assert_eq!(request.messages.len(), 2); } } diff --git a/server-rs/crates/platform-editor-agent/src/framework/error.rs b/server-rs/crates/platform-editor-agent/src/framework/error.rs index bc830dbac..5d2e92581 100644 --- a/server-rs/crates/platform-editor-agent/src/framework/error.rs +++ b/server-rs/crates/platform-editor-agent/src/framework/error.rs @@ -9,14 +9,45 @@ pub enum PromptError { impl std::fmt::Display for PromptError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { - Self::CompletionError(msg) => write!(f, "completion error: {msg}"), - Self::ToolError(msg) => write!(f, "tool error: {msg}"), - Self::InternalError(msg) => write!(f, "internal error: {msg}"), + 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, "max turns reached: {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/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx index 0059a0a00..a275dbb4b 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.test.tsx @@ -8,13 +8,14 @@ import { waitFor, within, } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { EditorAgentMessage, EditorAgentMessageResponse, } from '@/packages/shared/src/contracts'; import type { EditorAgentConversationClient } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts'; +import { EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS } from '@/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts'; import { useImageCanvasContextStore } from '@/src/components/image-editor/useImageCanvasContextStore.ts'; import { EditorAgentConversationPanelView } from './EditorAgentConversationPanelView.tsx'; @@ -114,6 +115,10 @@ function createClient(): EditorAgentConversationClient { }; } +afterEach(() => { + vi.useRealTimers(); +}); + function createPendingToolCallMessage(): EditorAgentMessage { return { id: 2, @@ -428,18 +433,26 @@ describe('EditorAgentConversationPanelView', () => { await waitFor(() => { expect(screen.getByText('已经看到画布内容')).toBeTruthy(); }); + vi.useFakeTimers(); fireEvent.change(screen.getByLabelText('发送给画布 Agent'), { target: { value: '继续规划' }, }); fireEvent.click(screen.getByRole('button', { name: '发送' })); - await waitFor(() => { - expect( - screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'), - ).toBe(true); + await act(async () => { + await Promise.resolve(); }); + expect( + screen.getByRole('button', { name: '发送' }).hasAttribute('disabled'), + ).toBe(true); expect(screen.queryByRole('button', { name: '停止' })).toBeNull(); + expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull(); + + act(() => { + vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS); + }); + expect(screen.getByText('仍在处理中,请耐心等待')).toBeTruthy(); await act(async () => { resolveSend({ @@ -452,7 +465,9 @@ describe('EditorAgentConversationPanelView', () => { deltaMessages: [], errorMessage: null, }); + await Promise.resolve(); }); + expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull(); }); it('uploads pasted images as canvas attachments before sending', async () => { diff --git a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx index 23d1a3341..17a43090c 100644 --- a/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx +++ b/src/components/image-editor/EditorAgentConversation/EditorAgentConversationPanelView.tsx @@ -76,6 +76,7 @@ export function EditorAgentConversationPanelView({ isCreatingConversation, isDeletingConversation, isWaiting, + isPatienceNoticeVisible, toolCallAction, isToolCallActionPending, errorMessage, @@ -267,7 +268,9 @@ export function EditorAgentConversationPanelView({ }} /> ))} - {isWaiting ? : null} + {isWaiting ? ( + + ) : null} ) : (
diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx index 328738e8b..1ba2db6c3 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.test.tsx @@ -12,7 +12,7 @@ import { } from '@/src/services/host-bridge/hostBridge.ts'; import { resetNativeAppHostBridgeForTest } from '@/src/services/host-bridge/nativeAppHostBridge.ts'; -import { MessageBubble } from './MessageBubble.tsx'; +import { MessageBubble, ThinkingBubble } from './MessageBubble.tsx'; vi.mock('@/src/services/assetReadUrlService.ts', () => ({ getSignedAssetReadUrl: vi.fn().mockResolvedValue('https://asset.test/signed'), @@ -51,6 +51,17 @@ describe('MessageBubble', () => { resetNativeAppHostBridgeForTest(); resetHostRuntimeCacheForTest(); }); + it('shows a patience notice only for an extended pending request', () => { + const { rerender } = render(); + + expect(screen.getByLabelText('Agent思考中')).toBeTruthy(); + expect(screen.queryByText('仍在处理中,请耐心等待')).toBeNull(); + + rerender(); + + expect(screen.getByLabelText('Agent仍在处理中')).toBeTruthy(); + expect(screen.getByText('仍在处理中,请耐心等待')).toBeTruthy(); + }); it('shows prefixed system errors as red Agent errors without the wire prefix', () => { renderMessage({ diff --git a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx index a6796519f..c5f287a25 100644 --- a/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx +++ b/src/components/image-editor/EditorAgentConversation/MessageBubble.tsx @@ -20,9 +20,16 @@ function messageRoleLabel(role: EditorAgentMessage['role']) { return 'Agent'; } -export function ThinkingBubble() { +export function ThinkingBubble({ + showPatienceNotice = false, +}: { + showPatienceNotice?: boolean; +}) { return ( -
+
@@ -39,6 +46,9 @@ export function ThinkingBubble() { style={{ animationDelay: '300ms' }} /> + {showPatienceNotice ? ( + 仍在处理中,请耐心等待 + ) : null}
diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx index 5dcc6ebf9..3df8d34ea 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { act, renderHook, waitFor } from '@testing-library/react'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { EditorAgentConversationDetail, @@ -9,6 +9,7 @@ import type { EditorAgentMessageResponse, } from '../../../../packages/shared/src/contracts/editorAgent.ts'; import { + EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS, type EditorAgentConversationClient, useEditorAgentConversation, } from './useEditorAgentConversation.ts'; @@ -116,6 +117,10 @@ describe('useEditorAgentConversation', () => { vi.clearAllMocks(); }); + afterEach(() => { + vi.useRealTimers(); + }); + it('loads conversations and applies delta messages', async () => { const client = createClient(); const onCanvasRefreshRequested = vi.fn(); @@ -445,6 +450,87 @@ describe('useEditorAgentConversation', () => { ); }); + it('does not send or apply a stale conversation created after switching projects', async () => { + const client = createClient(); + let resolveCreate!: (detail: EditorAgentConversationDetail) => void; + vi.mocked(client.listConversations) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + conversationId: 'conversation-project-2', + projectId: 'project-2', + title: '项目二会话', + updatedAt: '2026-07-03T00:02:00.000Z', + }, + ]); + vi.mocked(client.createConversation).mockImplementationOnce( + () => + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + vi.mocked(client.getConversation).mockResolvedValueOnce({ + conversationId: 'conversation-project-2', + projectId: 'project-2', + title: '项目二会话', + messages: [ + { + id: 20, + role: 'assistant', + text: '项目二消息', + attachments: [], + toolCall: null, + createdAt: '2026-07-03T00:02:00.000Z', + }, + ], + createdAt: '2026-07-03T00:02:00.000Z', + updatedAt: '2026-07-03T00:02:00.000Z', + }); + const { result, rerender } = renderHook( + ({ projectId }) => useEditorAgentConversation({ projectId, client }), + { initialProps: { projectId: 'project-1' } }, + ); + + await waitFor(() => { + expect(result.current.isLoadingConversations).toBe(false); + }); + + let sendPromise!: Promise; + act(() => { + sendPromise = result.current.sendMessage('旧项目消息'); + }); + await waitFor(() => { + expect(client.createConversation).toHaveBeenCalledWith('project-1', {}); + }); + + rerender({ projectId: 'project-2' }); + await waitFor(() => { + expect(result.current.activeConversationId).toBe( + 'conversation-project-2', + ); + }); + + await act(async () => { + resolveCreate({ + conversationId: 'conversation-project-1', + projectId: 'project-1', + title: '旧项目新会话', + messages: [], + createdAt: '2026-07-03T00:01:00.000Z', + updatedAt: '2026-07-03T00:01:00.000Z', + }); + await sendPromise; + }); + + expect(client.sendMessage).not.toHaveBeenCalled(); + expect(result.current.activeConversationId).toBe('conversation-project-2'); + expect(result.current.messages.map((message) => message.text)).toEqual([ + '项目二消息', + ]); + expect(result.current.isWaiting).toBe(false); + expect(result.current.isPatienceNoticeVisible).toBe(false); + }); + it('allows sending an attachment-only message', async () => { const client = createClient(); const { result } = renderHook(() => @@ -769,7 +855,9 @@ describe('useEditorAgentConversation', () => { ); await waitFor(() => { - expect(result.current.messages[0]?.toolCall?.status).toBe('not_completed'); + expect(result.current.messages[0]?.toolCall?.status).toBe( + 'not_completed', + ); }); const getConversationCallsBeforeCancel = vi.mocked(client.getConversation) .mock.calls.length; @@ -809,6 +897,78 @@ describe('useEditorAgentConversation', () => { expect(result.current.messages).toHaveLength(0); }); + it('replaces the extended patience notice with the actual request failure', async () => { + const client = createClient(); + let rejectSend!: (error: Error) => void; + vi.mocked(client.sendMessage).mockImplementation( + () => + new Promise((_resolve, reject) => { + rejectSend = reject; + }), + ); + const { result } = renderHook(() => + useEditorAgentConversation({ projectId: 'project-1', client }), + ); + + await waitFor(() => { + expect(result.current.activeConversation?.conversationId).toBe( + 'conversation-1', + ); + }); + + vi.useFakeTimers(); + let sendPromise!: Promise; + act(() => { + sendPromise = result.current.sendMessage('请继续'); + }); + await act(async () => { + await Promise.resolve(); + }); + act(() => { + vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS); + }); + expect(result.current.isPatienceNoticeVisible).toBe(true); + + await act(async () => { + rejectSend(new Error('LLM 连接已断开')); + await sendPromise.catch(() => undefined); + }); + + expect(result.current.isPatienceNoticeVisible).toBe(false); + expect(result.current.isWaiting).toBe(false); + expect(result.current.errorMessage).toBe('LLM 连接已断开'); + expect(result.current.messages).toHaveLength(0); + }); + + it('cleans the patience timer when the hook unmounts', async () => { + const client = createClient(); + vi.mocked(client.sendMessage).mockImplementation( + () => new Promise(() => undefined), + ); + const { result, unmount } = renderHook(() => + useEditorAgentConversation({ projectId: 'project-1', client }), + ); + + await waitFor(() => { + expect(result.current.activeConversation?.conversationId).toBe( + 'conversation-1', + ); + }); + + vi.useFakeTimers(); + act(() => { + void result.current.sendMessage('请继续'); + }); + await act(async () => { + await Promise.resolve(); + }); + expect(vi.getTimerCount()).toBe(1); + + unmount(); + + expect(vi.getTimerCount()).toBe(0); + }); + it('keeps the active request pending without exposing a stop action', async () => { const client = createClient(); let capturedSignal: AbortSignal | null = null; @@ -830,16 +990,27 @@ describe('useEditorAgentConversation', () => { ); }); - void act(() => { - void result.current.sendMessage('请继续'); + vi.useFakeTimers(); + let sendPromise!: Promise; + act(() => { + sendPromise = result.current.sendMessage('请继续'); + void result.current.sendMessage('不要重复发送'); }); - await waitFor(() => { - expect(result.current.isWaiting).toBe(true); + await act(async () => { + await Promise.resolve(); }); + expect(result.current.isWaiting).toBe(true); + expect(result.current.isPatienceNoticeVisible).toBe(false); + expect(client.sendMessage).toHaveBeenCalledTimes(1); expect(capturedSignal).toBeNull(); expect('stopCurrentTurn' in result.current).toBe(false); + act(() => { + vi.advanceTimersByTime(EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS); + }); + expect(result.current.isPatienceNoticeVisible).toBe(true); + await act(async () => { resolveSend({ conversation: { @@ -851,10 +1022,10 @@ describe('useEditorAgentConversation', () => { deltaMessages: [], errorMessage: null, }); + await sendPromise; }); - await waitFor(() => { - expect(result.current.isWaiting).toBe(false); - }); + expect(result.current.isWaiting).toBe(false); + expect(result.current.isPatienceNoticeVisible).toBe(false); }); }); diff --git a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts index 5c1d66326..765ba77bf 100644 --- a/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts +++ b/src/components/image-editor/EditorAgentConversation/useEditorAgentConversation.ts @@ -40,14 +40,8 @@ export type EditorAgentConversationClient = { payload: EditorAgentMessageRequest, options: SendEditorAgentMessageOptions, ) => Promise; - confirmToolCall: ( - conversationId: string, - messageId: number, - ) => Promise; - cancelToolCall: ( - conversationId: string, - messageId: number, - ) => Promise; + confirmToolCall: (conversationId: string, messageId: number) => Promise; + cancelToolCall: (conversationId: string, messageId: number) => Promise; }; type UseEditorAgentConversationOptions = { @@ -74,6 +68,8 @@ const defaultEditorAgentConversationClient: EditorAgentConversationClient = { cancelToolCall: cancelEditorAgentToolCall, }; +export const EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS = 120_000; + function createEditorAgentClientMessageId() { const randomId = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' @@ -155,16 +151,51 @@ export function useEditorAgentConversation({ const [isCreatingConversation, setIsCreatingConversation] = useState(false); const [isDeletingConversation, setIsDeletingConversation] = useState(false); const [isWaiting, setIsWaiting] = useState(false); + const [patienceNoticeConversationId, setPatienceNoticeConversationId] = + useState(null); const [toolCallAction, setToolCallAction] = useState(null); const [errorMessage, setErrorMessage] = useState(null); + const normalizedProjectIdRef = useRef(normalizedProjectId); const activeConversationIdRef = useRef(null); const activeToolCallActionRef = useRef(null); const conversationLoadRequestIdRef = useRef(0); + const createConversationRequestIdRef = useRef(0); + const isWaitingRef = useRef(false); + const pendingSendRequestIdRef = useRef(0); + const patienceNoticeTimerRef = useRef | null>( + null, + ); useEffect(() => { activeConversationIdRef.current = activeConversationId; }, [activeConversationId]); + normalizedProjectIdRef.current = normalizedProjectId; + + useEffect(() => { + return () => { + pendingSendRequestIdRef.current += 1; + createConversationRequestIdRef.current += 1; + isWaitingRef.current = false; + if (patienceNoticeTimerRef.current !== null) { + clearTimeout(patienceNoticeTimerRef.current); + patienceNoticeTimerRef.current = null; + } + }; + }, []); + + useEffect(() => { + pendingSendRequestIdRef.current += 1; + createConversationRequestIdRef.current += 1; + isWaitingRef.current = false; + if (patienceNoticeTimerRef.current !== null) { + clearTimeout(patienceNoticeTimerRef.current); + patienceNoticeTimerRef.current = null; + } + setIsWaiting(false); + setIsCreatingConversation(false); + setPatienceNoticeConversationId(null); + }, [normalizedProjectId]); const activeConversation = useMemo( () => @@ -193,10 +224,7 @@ export function useEditorAgentConversation({ ); const loadConversation = useCallback( - async ( - conversationId: string, - options: { showLoading?: boolean } = {}, - ) => { + async (conversationId: string, options: { showLoading?: boolean } = {}) => { const requestId = conversationLoadRequestIdRef.current + 1; conversationLoadRequestIdRef.current = requestId; const showLoading = options.showLoading ?? true; @@ -287,19 +315,37 @@ export function useEditorAgentConversation({ if (!normalizedProjectId) { throw new Error('缺少画布项目 ID'); } + const requestedProjectId = normalizedProjectId; + const requestId = createConversationRequestIdRef.current + 1; + createConversationRequestIdRef.current = requestId; setIsCreatingConversation(true); setErrorMessage(null); try { - const detail = await client.createConversation(normalizedProjectId, {}); - applyConversationDetail(detail); + const detail = await client.createConversation(requestedProjectId, {}); + if ( + createConversationRequestIdRef.current === requestId && + normalizedProjectIdRef.current === requestedProjectId + ) { + applyConversationDetail(detail); + } return detail; } catch (error) { - setErrorMessage( - error instanceof Error ? error.message : '创建画布 Agent 会话失败', - ); + if ( + createConversationRequestIdRef.current === requestId && + normalizedProjectIdRef.current === requestedProjectId + ) { + setErrorMessage( + error instanceof Error ? error.message : '创建画布 Agent 会话失败', + ); + } throw error; } finally { - setIsCreatingConversation(false); + if ( + createConversationRequestIdRef.current === requestId && + normalizedProjectIdRef.current === requestedProjectId + ) { + setIsCreatingConversation(false); + } } }, [applyConversationDetail, client, normalizedProjectId]); @@ -328,9 +374,9 @@ export function useEditorAgentConversation({ const toolCall = message.toolCall; return Boolean( toolCall?.externalJobId && - (toolCall.images.length > 0 || - (toolCall.videos?.length ?? 0) > 0 || - (toolCall.audios?.length ?? 0) > 0), + (toolCall.images.length > 0 || + (toolCall.videos?.length ?? 0) > 0 || + (toolCall.audios?.length ?? 0) > 0), ); }) ) { @@ -363,29 +409,49 @@ export function useEditorAgentConversation({ const text = rawText.trim(); if ( (!text && !attachments.length) || - isWaiting || + isWaitingRef.current || activeToolCallActionRef.current !== null || isLoadingConversations || isLoadingMessages ) { return; } - const conversationId = await ensureConversationForSend(); - const clientMessageId = createEditorAgentClientMessageId(); + isWaitingRef.current = true; + const requestedProjectId = normalizedProjectId; + const requestId = pendingSendRequestIdRef.current + 1; + pendingSendRequestIdRef.current = requestId; setErrorMessage(null); setIsWaiting(true); - const optimisticMessage = createLocalUserMessage({ - id: -1, - clientMessageId, - text, - attachments, - }); - setMessages((currentMessages) => [ - ...currentMessages, - optimisticMessage, - ]); + setPatienceNoticeConversationId(null); + let conversationId: string | null = null; + let optimisticMessage: EditorAgentMessage | null = null; try { + conversationId = await ensureConversationForSend(); + if ( + pendingSendRequestIdRef.current !== requestId || + normalizedProjectIdRef.current !== requestedProjectId + ) { + return; + } + const clientMessageId = createEditorAgentClientMessageId(); + const nextOptimisticMessage = createLocalUserMessage({ + id: -1, + clientMessageId, + text, + attachments, + }); + optimisticMessage = nextOptimisticMessage; + setMessages((currentMessages) => [ + ...currentMessages, + nextOptimisticMessage, + ]); + const pendingConversationId = conversationId; + patienceNoticeTimerRef.current = setTimeout(() => { + if (pendingSendRequestIdRef.current === requestId) { + setPatienceNoticeConversationId(pendingConversationId); + } + }, EDITOR_AGENT_PATIENCE_NOTICE_DELAY_MS); const response = await client.sendMessage( conversationId, { @@ -396,6 +462,9 @@ export function useEditorAgentConversation({ {}, ); + if (pendingSendRequestIdRef.current !== requestId) { + return; + } setConversations((currentConversations) => upsertConversationSummary( currentConversations, @@ -413,15 +482,31 @@ export function useEditorAgentConversation({ } catch (error) { const message = error instanceof Error ? error.message : '发送画布 Agent 消息失败'; - if (activeConversationIdRef.current === conversationId) { + const shouldReportError = + pendingSendRequestIdRef.current === requestId && + (!conversationId || + activeConversationIdRef.current === conversationId); + if (shouldReportError) { setErrorMessage(message); - setMessages((currentMessages) => - currentMessages.filter((message) => message !== optimisticMessage), - ); + if (optimisticMessage) { + setMessages((currentMessages) => + currentMessages.filter( + (message) => message !== optimisticMessage, + ), + ); + } + throw error; } - throw error; } finally { - setIsWaiting(false); + if (pendingSendRequestIdRef.current === requestId) { + if (patienceNoticeTimerRef.current !== null) { + clearTimeout(patienceNoticeTimerRef.current); + patienceNoticeTimerRef.current = null; + } + isWaitingRef.current = false; + setIsWaiting(false); + setPatienceNoticeConversationId(null); + } } }, [ @@ -430,7 +515,7 @@ export function useEditorAgentConversation({ applyDeltaMessages, isLoadingConversations, isLoadingMessages, - isWaiting, + normalizedProjectId, ], ); @@ -537,6 +622,8 @@ export function useEditorAgentConversation({ isCreatingConversation, isDeletingConversation, isWaiting, + isPatienceNoticeVisible: + isWaiting && patienceNoticeConversationId === activeConversationId, toolCallAction, isToolCallActionPending: toolCallAction !== null, errorMessage,