From 951caac32dc7e8d5d0ae14525afd83ea063a55d0 Mon Sep 17 00:00:00 2001 From: kdletters Date: Fri, 12 Jun 2026 23:15:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=89=A9=E5=B1=95=E5=A4=96=E9=83=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90Worker=E9=98=9F=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增外部生成队列概览和单任务状态契约 将跳一跳、拼消消、敲木鱼图片生成动作接入worker队列 前端生成等待页展示当前任务和队列数量 更新外部生成worker运维文档和团队决策记录 --- .hermes/shared-memory/decision-log.md | 8 + ...端架构】外部生成Worker化方案-2026-06-03.md | 40 ++- ...发运维】本地开发验证与生产运维-2026-05-15.md | 12 +- .../src/contracts/externalGeneration.ts | 29 ++ packages/shared/src/contracts/jumpHop.ts | 3 + .../src/contracts/puzzleAgentActions.ts | 2 + packages/shared/src/contracts/puzzleClear.ts | 3 + packages/shared/src/contracts/woodenFish.ts | 3 + packages/shared/src/index.ts | 1 + server-rs/crates/api-server/src/app.rs | 1 + .../api-server/src/external_generation.rs | 108 ++++++++ .../src/external_generation_worker.rs | 167 +++++++++++ server-rs/crates/api-server/src/jump_hop.rs | 259 +++++++++++++++--- server-rs/crates/api-server/src/main.rs | 1 + server-rs/crates/api-server/src/modules.rs | 1 + .../src/modules/external_generation.rs | 26 ++ .../crates/api-server/src/puzzle_clear.rs | 174 +++++++++++- .../crates/api-server/src/wooden_fish.rs | 151 +++++++++- .../src/external_generation.rs | 42 +++ .../crates/shared-contracts/src/jump_hop.rs | 4 + server-rs/crates/shared-contracts/src/lib.rs | 1 + .../shared-contracts/src/puzzle_clear.rs | 4 + .../shared-contracts/src/wooden_fish.rs | 4 + .../src/external_generation.rs | 25 ++ .../crates/spacetime-client/src/jump_hop.rs | 93 +++++++ server-rs/crates/spacetime-client/src/lib.rs | 16 +- .../crates/spacetime-client/src/mapper.rs | 4 +- .../src/mapper/external_generation.rs | 17 ++ .../spacetime-client/src/module_bindings.rs | 4 + .../external_generation_job_get_input_type.rs | 16 ++ ...nal_generation_job_and_return_procedure.rs | 59 ++++ .../spacetime-client/src/puzzle_clear.rs | 77 ++++++ .../spacetime-client/src/wooden_fish.rs | 93 +++++++ .../src/external_generation.rs | 39 +++ .../spacetime-module/src/puzzle_clear.rs | 68 +++++ src/components/CustomWorldGenerationView.tsx | 72 +++++ .../PlatformEntryFlowShellImpl.test.ts | 40 ++- .../PlatformEntryFlowShellImpl.tsx | 206 +++++++++++++- ...tformExternalGenerationQueueStatusModel.ts | 21 ++ .../UnifiedGenerationPage.test.tsx | 24 ++ .../UnifiedGenerationPage.tsx | 8 +- .../externalGenerationClient.ts | 49 ++++ src/services/external-generation/index.ts | 5 + 43 files changed, 1913 insertions(+), 67 deletions(-) create mode 100644 packages/shared/src/contracts/externalGeneration.ts create mode 100644 server-rs/crates/api-server/src/external_generation.rs create mode 100644 server-rs/crates/api-server/src/modules/external_generation.rs create mode 100644 server-rs/crates/shared-contracts/src/external_generation.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_get_input_type.rs create mode 100644 server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_and_return_procedure.rs create mode 100644 src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts create mode 100644 src/services/external-generation/externalGenerationClient.ts create mode 100644 src/services/external-generation/index.ts diff --git a/.hermes/shared-memory/decision-log.md b/.hermes/shared-memory/decision-log.md index d2aff5f2..0570e1a6 100644 --- a/.hermes/shared-memory/decision-log.md +++ b/.hermes/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-06-12 外部生成 worker 扩展到跳一跳、拼消消和敲木鱼 + +- 背景:外部图片生成已从 HTTP 长请求迁到 `external_generation_job` 队列;跳一跳、拼消消和敲木鱼继续扩展时需要统一 job 粒度、前端等待展示和本地 / 生产验证口径。 +- 决策:队列 BFF 暴露用户可见队列概览 `GET /api/runtime/external-generation/queue-overview` 和单 job 状态 `GET /api/runtime/external-generation/jobs/{jobId}`;首版固定“单动作单 job”,不拆提示词 / 生图 / 切图 / 持久化等阶段 job。进入队列的范围为跳一跳 `compile-draft` / `regenerate-tiles`、拼消消 `compile-draft` / `regenerate-atlas`、敲木鱼 `compile-draft` / `regenerate-hit-object` 图片资产动作;非外部图片生成动作继续 inline。 +- 影响范围:外部生成 worker Module、api-server BFF、生成页等待展示、跳一跳 / 拼消消 / 敲木鱼创作与结果页生成动作、本地和生产验证文档。 +- 验证方式:本地 `npm run dev` 默认保留 inline 开发体验;验证 worker 队列、等待展示、lease 或扩缩容时显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 并启动 worker,或运行 `npm run container:worker-smoke -- smoke`。部署后确认 `/healthz`、`/readyz`、队列概览 BFF、单 job 状态和对应玩法 session/detail 状态都能收敛。 +- 关联文档:`docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 + ## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板 - 背景:release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。 diff --git a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md index 6a9013a0..90eb4a19 100644 --- a/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md +++ b/docs/technical/【后端架构】外部生成Worker化方案-2026-06-03.md @@ -1,6 +1,6 @@ # 外部生成 Worker 化方案 -更新时间:`2026-06-07` +更新时间:`2026-06-12` ## 背景 @@ -13,7 +13,9 @@ - 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。 - 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。 - SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。 -- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`;后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。 +- 已接入拼图 `compile_puzzle_draft`、结果页 `generate_puzzle_images` 与结果页 `generate_puzzle_ui_background`;本轮扩展到跳一跳、拼消消和敲木鱼的外部图片生成动作。后续玩法继续复用同一队列 Module,不再为每个玩法发明独立队列。 +- 第一版外部生成队列粒度固定为“单个用户动作对应单个 job”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 job;job 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。 +- 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`。 ## Module 与 Interface @@ -25,9 +27,19 @@ - `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。 - `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。 - `get_external_generation_queue_stats_and_return`:controller 读取队列积压、运行中任务和过期 lease 数量,用于计算 worker 目标实例数;该 procedure 只读 `external_generation_job`,不直接操作 systemd。 +- `get_external_generation_job_and_return`:按 `job_id` 读取单个任务状态,给 BFF 和生成页展示使用;必须只返回调用者有权读取的任务,不能暴露其它用户的 payload、错误详情或 worker 内部字段。 这个 Module 的 **Seam** 在 SpacetimeDB procedure + `spacetime-client` facade;`api-server` HTTP role 和 worker role 都只依赖这个 Interface。外部 provider、OSS、计费补偿、玩法草稿回写仍留在 `api-server` worker implementation 内,不进入 SpacetimeDB reducer。 +## BFF 状态接口 + +队列状态对前端只通过 `api-server` BFF 暴露,不允许前端直接查询 SpacetimeDB private table: + +- `GET /api/runtime/external-generation/queue-overview`:队列概览,用于生成页、调试面板或后台观测当前用户可见的等待状态。返回 pending / running / completed / failed / cancelled 数量、最早等待时间、当前可见 job 摘要,以及是否存在过期 lease 需要等待 worker 重领。 +- `GET /api/runtime/external-generation/jobs/{jobId}`:单 job 状态,用于生成页轮询某次动作。返回 `jobId`、`jobKind`、`sourceModule`、`sourceEntityId`、`status`、`attempt`、`maxAttempts`、`createdAt`、`startedAt`、`completedAt`、`updatedAt`、可展示的 `requestLabel`、可展示的 `lastErrorMessage`、以及业务侧下一次轮询所需的 source 标识。 + +BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;队列事实仍以 `external_generation_job` 为准,业务结果仍以玩法 session / work profile 为准。生成页展示“排队中 / 处理中 / 失败 / 完成”时,应优先用单 job 状态补充等待信息,再继续按原玩法 session/detail 接口收敛到 ready 或 failed。队列接口不替代玩法恢复接口,也不把 private `request_payload_json` 原样传给前端。 + ## 任务表 新增私有表 `external_generation_job`: @@ -107,6 +119,8 @@ controller 配置: ## 已接入的拼图纵切 +### 拼图 + `compile_puzzle_draft`: 1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。 @@ -129,7 +143,23 @@ controller 配置: 2. worker 执行原结果页 UI 背景链路:归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。 3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job,让前端轮询能收敛。 -Match3D、Wooden Fish、Visual Novel 音频等后续外部生成 action 按同一模式迁移。 +### 跳一跳、拼消消和敲木鱼扩展范围 + +以下动作按同一 worker 模式迁移。命名以现有玩法 action 为准,队列 `job_kind` 采用后端稳定 snake_case,不新增平行队列: + +- 跳一跳 `jump-hop` + - `compile-draft`:草稿编译阶段需要生成地块 / 视觉资产时入队,例如 `jump_hop_compile_draft`。 + - `regenerate-tiles`:结果页地块图集重生入队,例如 `jump_hop_regenerate_tiles`。 +- 拼消消 `puzzle-clear` + - `compile-draft`:草稿编译阶段需要生成场地底图和卡片 atlas 时入队,例如 `puzzle_clear_compile_draft`。 + - `regenerate-atlas`:结果页素材 atlas 重生入队,例如 `puzzle_clear_regenerate_atlas`。 +- 敲木鱼 `wooden-fish` + - `compile-draft`:草稿编译阶段需要生成背景、敲击物或其它图片资产时入队,例如 `wooden_fish_compile_draft`。 + - `regenerate-hit-object`:结果页敲击物图片重生入队,例如 `wooden_fish_regenerate_hit_object`。 + +这些动作首版都保持“单动作单 job”:一次 `compile-draft` 或一次 `regenerate-*` 请求只创建一个 job,worker 内部负责该动作所需的 provider 调用、素材处理、OSS 持久化、失败态写回和业务成功写回。非外部图片生成动作,例如纯元信息保存、标签编辑、发布、试玩启动、运行态动作、删除和公开 read model 读取,继续 inline 执行。 + +每个玩法迁移时必须同时接入业务写回 lease guard:worker 路径带 `external_generation_job_id / worker_id / lease_token`,inline 路径三项同时为空。过期 worker 不得写 session / work profile;业务失败态写回成功后才允许 job 进入 `failed`。 ## 验收 @@ -159,7 +189,9 @@ GENARRATIVE_PROCESS_ROLE=all npm run dev curl -f http://127.0.0.1:/healthz ``` -本地同步排查可显式使用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline npm run dev:api-server`,用于确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行;该模式不覆盖 worker 队列 smoke。生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。 +本地 `npm run dev` 默认保持 `inline` 开发体验:未显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,普通本地联调可以同步确认 provider、OSS 和 SpacetimeDB 写回链路本身是否可行。需要验证 worker 队列、BFF 队列状态、lease 重领或扩缩容时,必须显式使用 `queue`,并启动 worker 角色;可以用 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 做临时单进程 smoke,也可以使用隔离容器 smoke。 + +生产 smoke 需要保持 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue`,并至少启动一个 `api` 角色、一个 `external-generation-worker` 角色和一个 `external-generation-controller` 角色;发布脚本会在默认 worker pattern 下自动启用并启动 `genarrative-external-generation-worker@1.service`,重启并验活 `genarrative-external-generation-controller.service`。若 worker 数量归零,生成任务会保持 `queued/running`,不会由 HTTP 进程偷偷执行。部署验证除 `/healthz` / `/readyz` 外,还要确认队列概览 BFF 可读、单 job 状态能从 `queued/running` 收敛到业务 session/detail 的 ready 或 failed。 systemd 生产 controller 与手动兜底示例: diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 312d079b..c2a7d172 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -1,6 +1,6 @@ # 本地开发验证与生产运维 -更新时间:`2026-06-09` +更新时间:`2026-06-12` ## 标准开发流程 @@ -51,9 +51,13 @@ Linux 本机多用户并发开发时,`npm run dev` 和 `npm run dev:*` 单模 开发态 `npm run dev` 与 `npm run dev:api-server` 会默认注入 `GENARRATIVE_DEV_PASSWORD_ENTRY_AUTO_REGISTER_ENABLED=true`,因此密码登录在本地开发环境可直接注册未知手机号账号;生产环境仍按 `api-server` 配置默认关闭该开关。 -本地排查外部内容生成 worker 时,可临时用 `GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server` 让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列。该模式只用于 smoke;生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费。外部生成执行策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制,生产与容器扩缩容验证保持 `queue`,拼图首图 `compile_puzzle_draft`、结果页关卡图片 `generate_puzzle_images` 和结果页 UI 背景 `generate_puzzle_ui_background` 会进入持久队列;worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。本地如果要让 `npm run dev` 或 `npm run dev:api-server` 同步等待生成结果,应在 `.env.local` 或本机环境显式配置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=inline`,由 handler 直接复用 worker executor 并在完成后返回 `completed`;该配置不得硬编码进 `scripts/dev.mjs`,且 inline 不创建 `external_generation_job`、不提供动态扩缩容能力。 +本地 `npm run dev` 和 `npm run dev:api-server` 默认保留 inline 开发体验:未显式设置 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue` 时,外部生成 handler 会同步复用 worker executor,完成后返回 `completed`,便于快速确认 provider、OSS 和 SpacetimeDB 写回链路。inline 不创建 `external_generation_job`,也不能验证 worker lease、队列等待展示或动态扩缩容。 -需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `worker_smoke_unsupported` 测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。 +本地排查外部内容生成 worker 队列时,必须显式使用 queue,例如 `GENARRATIVE_EXTERNAL_GENERATION_MODE=queue GENARRATIVE_PROCESS_ROLE=all npm run dev:api-server`,让同一 Rust 进程同时监听 HTTP 并消费 `external_generation_job` 队列;更接近生产的验证应分别启动 `api`、`external-generation-worker` 和 `external-generation-controller`。生产默认 `GENARRATIVE_PROCESS_ROLE=api`,外部生成任务由独立 `GENARRATIVE_PROCESS_ROLE=external-generation-worker` 进程消费;生产与容器扩缩容验证保持 `queue`。当前进入持久队列的外部图片生成动作包括:拼图 `compile_puzzle_draft` / `generate_puzzle_images` / `generate_puzzle_ui_background`,跳一跳 `compile-draft` / `regenerate-tiles`,拼消消 `compile-draft` / `regenerate-atlas`,敲木鱼 `compile-draft` / `regenerate-hit-object`。非外部图片生成动作继续 inline,不进入队列。worker 数量为 0 时,HTTP 只返回 queued/running,不会兜底执行外部 provider。 + +生成页或排障面板展示队列等待时,只读取 BFF 队列接口:`GET /api/runtime/external-generation/queue-overview` 查看当前用户可见队列概览,`GET /api/runtime/external-generation/jobs/{jobId}` 查看单 job 状态。队列接口只提供等待 / 运行 / 失败 / 完成状态补充,最终草稿、作品和结果页仍要轮询对应玩法 session/detail 接口收敛到 ready 或 failed;不要直接查询 `external_generation_job` private table,也不要把 worker 内部 payload 暴露到前端。 + +需要验证“更新 API 不停 worker”和“worker 是否持续消费队列”时,优先使用隔离容器 smoke:`npm run container:worker-smoke -- smoke`。该脚本生成 gitignored 的 `deploy/container/worker-smoke/api-server.env`,启动独立 compose project 与独立 SpacetimeDB,发布当前 `spacetime-module` 后写入 `worker_smoke_unsupported` 测试 job;预期 worker claim 后执行 unsupported 失败分支,再执行 API-only recreate 并确认 worker 容器 ID 不变,最后再次入队验证 API 更新后队列仍可消费。`external_generation_job` 是 private table,脚本通过 worker 日志确认 job_id 被消费,不用 CLI SQL 查询私表。该 smoke 不读取 `.env.local`,也不依赖真实 VectorEngine / OSS 密钥;真实生图链路联调再在本地私有 env 中补齐 provider 配置。worker-smoke 默认把本机 `spacetime` CLI 打成轻量 SpacetimeDB 镜像,避免本机首次 smoke 依赖官方大镜像下载。若容器内 Cargo 拉取 crates.io 依赖不稳定,可用 `npm run container:worker-smoke -- smoke --local-binary` 让容器内 Cargo 复用本机 Cargo 缓存构建当前二进制,再打入 Debian bookworm smoke runtime 临时镜像;可用 `GENARRATIVE_WORKER_SMOKE_LOCAL_BASE_IMAGE` 覆盖运行时基础镜像;若隔离端口或库数据需要重建,追加 `--force`。完成 queue 链路验证时,还要用队列概览 BFF 和单 job 状态接口确认 job 从 queued/running 收敛,并用对应玩法 session/detail 接口确认业务状态同步完成。 本地只做账号/UI smoke 且需要短信登录时,`SMS_AUTH_PROVIDER` 应显式设为 `mock`,并把 `SMS_AUTH_MOCK_VERIFY_CODE` 设为固定值(当前常用 `123456`),再重启 `npm run dev` 或 `npm run dev:api-server`。如果 `.env.local` 还保留 `SMS_AUTH_PROVIDER=aliyun`,`POST /api/auth/phone/login` 用 mock 验证码会稳定报“验证码错误”,不是前端表单问题。真实短信联调再切回 `aliyun` 并重启。 @@ -307,7 +311,7 @@ dev 服务器上的 Gitea 内网入口固定为 `http://10.2.0.10/GenarrativeAI/ 生产环境变量模板:`deploy/env/api-server.env.example`。真实密钥只放服务器,不提交 Git,不写入文档示例。 -`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`external-generation-controller` 只管理 worker systemd 实例,`all` 仅用于本地或临时 smoke,不隐式启动 controller。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制,生产和容器压测默认保持 `queue`;`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;生产默认由 `genarrative-external-generation-controller.service` 读取 `get_external_generation_queue_stats_and_return`,按 `claimable_pending + running_active + expired_running` 计算目标 worker 数,并对 `genarrative-external-generation-worker@N.service` 精确执行 `systemctl start/stop`。controller 参数模板是 `deploy/env/external-generation-controller.env.example`:默认保底 `MIN_WORKERS=1`、上限 `MAX_WORKERS=8`、每 worker 目标 `TARGET_JOBS_PER_WORKER=2`、`POLL_INTERVAL_MS=10000`、连续 `SCALE_DOWN_IDLE_ROUNDS=6` 轮完全空闲才缩容;缩容每轮只停止最高编号的一个实例,且不主动停止 `@1`。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会安装 worker 模板、controller unit 和两份专属 env 模板,默认 enable 首个 `genarrative-external-generation-worker@1.service` 与 `genarrative-external-generation-controller.service`;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active,同时重启并验活 controller。手动兜底扩容仍可用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`;controller 下轮会按队列压力修正到目标实例数。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`,若不希望重启 controller 可传 `--no-worker-controller`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。当前拼图首关生成只做 lease 崩溃重领,不做业务失败自动重试,避免 worker 退款和重试成功之间产生钱包账本漂移。 +`api-server` 进程角色由 `GENARRATIVE_PROCESS_ROLE` 控制:`api` 只监听 HTTP,`external-generation-worker` 只消费外部生成队列,`external-generation-controller` 只管理 worker systemd 实例,`all` 仅用于本地或临时 smoke,不隐式启动 controller。外部生成策略由 `GENARRATIVE_EXTERNAL_GENERATION_MODE` 控制;生产和容器压测默认保持 `queue`,本地 `npm run dev` 默认保留 `inline` 开发体验,只有显式配置 `queue` 才会落 `external_generation_job`。`inline` 只用于本地或低并发同步排查,HTTP handler 会直接复用 worker executor,完成后返回 `completed`,但不会落 `external_generation_job`,也不能通过增加 worker 进程扩吞吐。外部生成 worker 使用同一发布包和同一套 SpacetimeDB 配置,按实例数和 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_CONCURRENCY` 动态扩缩;生产默认由 `genarrative-external-generation-controller.service` 读取 `get_external_generation_queue_stats_and_return`,按 `claimable_pending + running_active + expired_running` 计算目标 worker 数,并对 `genarrative-external-generation-worker@N.service` 精确执行 `systemctl start/stop`。controller 参数模板是 `deploy/env/external-generation-controller.env.example`:默认保底 `MIN_WORKERS=1`、上限 `MAX_WORKERS=8`、每 worker 目标 `TARGET_JOBS_PER_WORKER=2`、`POLL_INTERVAL_MS=10000`、连续 `SCALE_DOWN_IDLE_ROUNDS=6` 轮完全空闲才缩容;缩容每轮只停止最高编号的一个实例,且不主动停止 `@1`。worker 收到 SIGINT/SIGTERM 后会停止 claim 新任务并等待当前任务完成;若进程被硬杀、机器断电或超过 systemd `TimeoutStopSec`,未完成任务才会在 lease 过期后由其它 worker 重领。每个 worker 实例应设置唯一 `GENARRATIVE_EXTERNAL_GENERATION_WORKER_ID`,默认会用主机名和 pid 兜底;systemd 生产模板 `deploy/systemd/genarrative-external-generation-worker@.service` 会用 `%H-%i` 生成实例 ID,并把 tracking outbox 隔离到 `/var/lib/genarrative/tracking-outbox/%H-%i`。`Genarrative-Server-Provision` 会安装 worker 模板、controller unit 和两份专属 env 模板,默认 enable 首个 `genarrative-external-generation-worker@1.service` 与 `genarrative-external-generation-controller.service`;首次 API deploy 会在默认 worker pattern 下自动 `enable --now genarrative-external-generation-worker@1.service` 并等待 worker active,同时重启并验活 controller。手动兜底扩容仍可用 `systemctl start genarrative-external-generation-worker@2.service` / `@3.service`,缩容用 `systemctl stop genarrative-external-generation-worker@N.service`;controller 下轮会按队列压力修正到目标实例数。worker 专属参数模板是 `deploy/env/external-generation-worker.env.example`,密钥与 SpacetimeDB 连接仍复用 `/etc/genarrative/api-server.env`。API 发布脚本默认会重启并验活 `genarrative-external-generation-worker@*.service` 和 `genarrative-external-generation-controller.service`;若本次只发 HTTP 且不希望滚动 worker,可传 `--no-worker-services`,若不希望重启 controller 可传 `--no-worker-controller`。`GENARRATIVE_EXTERNAL_GENERATION_WORKER_POLL_INTERVAL_MS` 控制空队列轮询间隔,`GENARRATIVE_EXTERNAL_GENERATION_WORKER_LEASE_SECONDS` 控制单次 lease,worker 会约每三分之一 lease、最长 30 秒续租;该值应覆盖一次心跳网络抖动窗口,不需要大于完整外部生成链路耗时。SpacetimeDB 使用自身事务时间计算 claim/renew/complete/fail,完成和失败回写还会校验 `lease_token` 与未过期 lease,避免同一 job 被过期 worker 覆盖。首版 worker 粒度是单动作单 job,不拆阶段 job;当前外部图片生成动作覆盖拼图、跳一跳、拼消消和敲木鱼,纯元信息保存、发布、试玩启动、运行态动作和公开读取继续 inline。当前生成业务失败只做用户重新触发,不做自动业务重试,避免 worker 退款和重试成功之间产生钱包账本漂移。 `Genarrative-Server-Provision` 会安装 systemd 模板和 Nginx 站点模板,不再安装 clang / lld / pkg-config / OpenSSL headers / sccache 等通用构建链依赖。因 VectorEngine 图片上游 POST 已改用 `libcurl`,当前 Linux release 构建出的 `api-server` 运行时需要 `OPENSSL_3.2.0` 符号;Ubuntu 24.04 apt 默认只提供 OpenSSL 3.0.x,不能直接满足该符号版本。Provision 会把 OpenSSL `3.2.0` 独立安装到 `/opt/genarrative/openssl-3.2.0`,校验官方 tarball SHA256,并只通过 `genarrative-api.service` 的 `LD_LIBRARY_PATH=/opt/genarrative/openssl-3.2.0/lib64:/opt/genarrative/openssl-3.2.0/lib` 让 api-server 使用,避免替换系统 OpenSSL 或影响 ssh / nginx / apt。Ubuntu / apt 目标机为完成这一步会安装 `build-essential`、`ca-certificates`、`curl`、`perl`、`tar` 等 OpenSSL 运行时自举工具;这只服务于独立 OpenSSL 运行时安装,不代表 provision 重新承担 api-server 构建职责。Ubuntu / apt 目标机会额外安装 `libnginx-mod-http-brotli-filter` 与 `libnginx-mod-http-brotli-static`,随后由 `scripts/jenkins-server-provision.sh` 通过临时 `nginx -t` 配置探测 Brotli 指令是否可用;该临时配置必须先 `include /etc/nginx/modules-enabled/*.conf`,因为 apt 安装的 Brotli 是动态模块,不会出现在普通 `nginx -V` 编译参数里。探测成功才在渲染后的 `deploy/nginx/genarrative.conf` / `genarrative-dev-http.conf` 中启用 Brotli,避免未安装模块的机器直接写入无效配置。Provision 写入 Genarrative Nginx 站点时会把 `/etc/nginx/sites-enabled/default*` 移到 `/etc/nginx/sites-disabled/`,避免 Debian / Certbot 默认站点继续占用 `genarrative.world` / `www.genarrative.world` 并在 `nginx -T` 中出现 `conflicting server name ... ignored`。如果 `nginx -t` 失败,脚本会恢复写入前的 Genarrative 配置和被移动的默认站点。 diff --git a/packages/shared/src/contracts/externalGeneration.ts b/packages/shared/src/contracts/externalGeneration.ts new file mode 100644 index 00000000..8fe24f6f --- /dev/null +++ b/packages/shared/src/contracts/externalGeneration.ts @@ -0,0 +1,29 @@ +export type ExternalGenerationJobStatus = + | 'queued' + | 'running' + | 'completed' + | 'failed'; + +export interface ExternalGenerationQueueOverview { + pendingCount: number; + runningCount: number; + updatedAtMicros: number; +} + +export interface ExternalGenerationQueueOverviewResponse { + overview: ExternalGenerationQueueOverview; +} + +export interface ExternalGenerationJobStatusRecord { + operationId: string; + status: ExternalGenerationJobStatus; + phaseLabel: string; + phaseDetail: string; + progress: number; + error?: string | null; + updatedAtMicros: number; +} + +export interface ExternalGenerationJobStatusResponse { + job: ExternalGenerationJobStatusRecord; +} diff --git a/packages/shared/src/contracts/jumpHop.ts b/packages/shared/src/contracts/jumpHop.ts index 9379baa0..43ab0146 100644 --- a/packages/shared/src/contracts/jumpHop.ts +++ b/packages/shared/src/contracts/jumpHop.ts @@ -1,3 +1,5 @@ +import type { ExternalGenerationJobStatusRecord } from './externalGeneration'; + export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge'; export type JumpHopStylePreset = @@ -206,6 +208,7 @@ export interface JumpHopActionResponse { actionType: JumpHopActionType; session: JumpHopSessionSnapshotResponse; work: JumpHopWorkProfileResponse | null; + queueState?: ExternalGenerationJobStatusRecord | null; } export interface JumpHopWorkSummaryResponse { diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 501b8cc4..bf0f92ee 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -1,4 +1,5 @@ import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession'; +import type { ExternalGenerationJobStatusRecord } from './externalGeneration'; export type PuzzleAgentSuggestedActionType = | 'request_summary' @@ -41,6 +42,7 @@ export interface PuzzleAgentOperationRecord { phaseDetail: string; progress: number; error?: string | null; + queueState?: ExternalGenerationJobStatusRecord | null; } export type PuzzleAgentActionRequest = diff --git a/packages/shared/src/contracts/puzzleClear.ts b/packages/shared/src/contracts/puzzleClear.ts index 728c93f9..e5e6845f 100644 --- a/packages/shared/src/contracts/puzzleClear.ts +++ b/packages/shared/src/contracts/puzzleClear.ts @@ -1,3 +1,5 @@ +import type { ExternalGenerationJobStatusRecord } from './externalGeneration'; + export type PuzzleClearGenerationStatus = 'draft' | 'generating' | 'ready' | 'failed'; export type PuzzleClearShapeKind = '1x2' | '1x3' | '2x2' | '2x3'; @@ -109,6 +111,7 @@ export interface PuzzleClearActionResponse { actionType: PuzzleClearActionType; session: PuzzleClearSessionSnapshotResponse; work: PuzzleClearWorkProfileResponse | null; + queueState?: ExternalGenerationJobStatusRecord | null; } export interface PuzzleClearWorkSummaryResponse { diff --git a/packages/shared/src/contracts/woodenFish.ts b/packages/shared/src/contracts/woodenFish.ts index 040866f8..033e7a6f 100644 --- a/packages/shared/src/contracts/woodenFish.ts +++ b/packages/shared/src/contracts/woodenFish.ts @@ -1,3 +1,5 @@ +import type { ExternalGenerationJobStatusRecord } from './externalGeneration'; + export type WoodenFishGenerationStatus = | 'draft' | 'generating' @@ -104,6 +106,7 @@ export interface WoodenFishActionResponse { actionType: WoodenFishActionType; session: WoodenFishSessionSnapshotResponse; work: WoodenFishWorkProfileResponse | null; + queueState?: ExternalGenerationJobStatusRecord | null; } export interface WoodenFishWorkSummaryResponse { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index cfffc0a4..cc84c6f1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -8,6 +8,7 @@ export type * from './contracts/creativeAgent'; export type * from './contracts/customWorldAgent'; export * from './contracts/edutainmentBabyDrawing'; export * from './contracts/edutainmentBabyObject'; +export * from './contracts/externalGeneration'; export type * from './contracts/hyper3d'; export * from './contracts/match3dAgent'; export * from './contracts/match3dRuntime'; diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 5e58ea9e..bebae236 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -44,6 +44,7 @@ pub fn build_router(state: AppState) -> Router { .merge(modules::profile::router(state.clone())) .merge(modules::assets::router(state.clone())) .merge(modules::platform::router(state.clone())) + .merge(modules::external_generation::router(state.clone())) .merge(modules::play_flow::router(state.clone())) .route( "/api/profile/recharge/wechat/notify", diff --git a/server-rs/crates/api-server/src/external_generation.rs b/server-rs/crates/api-server/src/external_generation.rs new file mode 100644 index 00000000..345dbe49 --- /dev/null +++ b/server-rs/crates/api-server/src/external_generation.rs @@ -0,0 +1,108 @@ +use axum::{ + Json, + extract::{Extension, Path, State}, + http::StatusCode, + response::Response, +}; +use serde_json::json; +use shared_contracts::external_generation::{ + ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, + ExternalGenerationJobStatusResponse, ExternalGenerationQueueOverview, + ExternalGenerationQueueOverviewResponse, +}; +use spacetime_client::{ + ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, + ExternalGenerationQueueStatsRecord, SpacetimeClientError, +}; + +use crate::{ + api_response::json_success_body, auth::AuthenticatedAccessToken, http_error::AppError, + request_context::RequestContext, state::AppState, +}; + +const EXTERNAL_GENERATION_PROVIDER: &str = "external_generation"; + +pub async fn get_external_generation_queue_overview( + State(state): State, + Extension(request_context): Extension, +) -> Result, Response> { + let stats = state + .spacetime_client() + .get_external_generation_queue_stats() + .await + .map_err(|error| external_generation_error_response(&request_context, error))?; + + Ok(json_success_body( + Some(&request_context), + ExternalGenerationQueueOverviewResponse { + overview: map_external_generation_queue_overview(stats), + }, + )) +} + +pub async fn get_external_generation_job_status( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + Path(job_id): Path, +) -> Result, Response> { + let owner_user_id = authenticated.claims().user_id().to_string(); + let job = state + .spacetime_client() + .get_external_generation_job(ExternalGenerationJobGetRecordInput { + job_id, + owner_user_id, + }) + .await + .map_err(|error| external_generation_error_response(&request_context, error))?; + + Ok(json_success_body( + Some(&request_context), + ExternalGenerationJobStatusResponse { + job: map_external_generation_job_status(job), + }, + )) +} + +fn map_external_generation_queue_overview( + stats: ExternalGenerationQueueStatsRecord, +) -> ExternalGenerationQueueOverview { + ExternalGenerationQueueOverview { + pending_count: stats.pending_count, + running_count: stats.running_active_count, + updated_at_micros: stats.now_micros, + } +} + +fn map_external_generation_job_status( + job: ExternalGenerationJobRecord, +) -> ExternalGenerationJobStatusRecord { + let (status, phase_detail, progress) = match job.status.as_str() { + "completed" => (ExternalGenerationJobStatus::Completed, "生成已完成。", 100), + "running" => (ExternalGenerationJobStatus::Running, "正在生成。", 35), + "failed" => (ExternalGenerationJobStatus::Failed, "生成失败。", 0), + _ => (ExternalGenerationJobStatus::Queued, "排队中。", 8), + }; + + ExternalGenerationJobStatusRecord { + operation_id: job.job_id, + status, + phase_label: job.request_label, + phase_detail: phase_detail.to_string(), + progress, + error: job.last_error_message, + updated_at_micros: job.updated_at_micros, + } +} + +fn external_generation_error_response( + request_context: &RequestContext, + error: SpacetimeClientError, +) -> Response { + AppError::from_status(StatusCode::BAD_GATEWAY) + .with_details(json!({ + "provider": EXTERNAL_GENERATION_PROVIDER, + "message": error.to_string(), + })) + .into_response_with_context(Some(request_context)) +} diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 6e989b10..ec90016a 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -15,14 +15,26 @@ use tokio::{ use tracing::{error, info, warn}; use crate::{ + jump_hop::{ + JUMP_HOP_COMPILE_DRAFT_JOB_KIND, JumpHopCompileDraftWorkerPayload, + execute_jump_hop_compile_draft_worker_job, + }, puzzle::{ ExternalGenerationWriteLeaseGuard, PuzzleCompileDraftWorkerPayload, PuzzleGenerateImagesWorkerPayload, PuzzleGenerateUiBackgroundWorkerPayload, execute_puzzle_compile_draft_worker_job, execute_puzzle_generate_images_worker_job, execute_puzzle_generate_ui_background_worker_job, release_puzzle_compile_background_claim, }, + puzzle_clear::{ + PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND, PuzzleClearCompileDraftWorkerPayload, + execute_puzzle_clear_compile_draft_worker_job, + }, request_context::RequestContext, state::{AppState, PuzzleApiState}, + wooden_fish::{ + WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND, WoodenFishGenerateImageAssetsWorkerPayload, + execute_wooden_fish_generate_image_assets_worker_job, + }, }; pub(crate) const PUZZLE_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_compile_draft"; @@ -395,6 +407,135 @@ async fn process_external_generation_job_once( } } } + JUMP_HOP_COMPILE_DRAFT_JOB_KIND => { + let payload = match serde_json::from_str::( + job.request_payload_json.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let message = format!("跳一跳生成任务参数解析失败:{error}"); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } + }; + let request_context = RequestContext::new( + format!("external-generation-worker-{}", job.job_id), + format!("external-generation-worker {}", job.job_kind), + std::time::Duration::ZERO, + false, + ); + match execute_jump_hop_compile_draft_worker_job(&state, &request_context, payload).await + { + Ok(session) => { + complete_job( + &state, + &worker_id, + &job, + Some( + json!({ + "sessionId": session.session_id, + "status": session.status, + }) + .to_string(), + ), + ) + .await + } + Err(response) => { + let message = response_error_message(response).await; + fail_job(&state, &worker_id, &job, message.clone()).await?; + Err(message) + } + } + } + PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND => { + let payload = match serde_json::from_str::( + job.request_payload_json.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let message = format!("拼消消生成任务参数解析失败:{error}"); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } + }; + let request_context = RequestContext::new( + format!("external-generation-worker-{}", job.job_id), + format!("external-generation-worker {}", job.job_kind), + std::time::Duration::ZERO, + false, + ); + match execute_puzzle_clear_compile_draft_worker_job(&state, &request_context, payload) + .await + { + Ok(session) => { + complete_job( + &state, + &worker_id, + &job, + Some( + json!({ + "sessionId": session.session_id, + "status": session.status, + }) + .to_string(), + ), + ) + .await + } + Err(response) => { + let message = response_error_message(response).await; + fail_job(&state, &worker_id, &job, message.clone()).await?; + Err(message) + } + } + } + WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND => { + let payload = match serde_json::from_str::( + job.request_payload_json.as_str(), + ) { + Ok(payload) => payload, + Err(error) => { + let message = format!("敲木鱼图片生成任务参数解析失败:{error}"); + fail_job(&state, &worker_id, &job, message.clone()).await?; + return Err(message); + } + }; + let request_context = RequestContext::new( + format!("external-generation-worker-{}", job.job_id), + format!("external-generation-worker {}", job.job_kind), + std::time::Duration::ZERO, + false, + ); + match execute_wooden_fish_generate_image_assets_worker_job( + &state, + &request_context, + payload, + ) + .await + { + Ok(session) => { + complete_job( + &state, + &worker_id, + &job, + Some( + json!({ + "sessionId": session.session_id, + "status": session.status, + }) + .to_string(), + ), + ) + .await + } + Err(response) => { + let message = response_error_message(response).await; + fail_job(&state, &worker_id, &job, message.clone()).await?; + Err(message) + } + } + } unknown => { warn!( job_id = job.job_id, @@ -412,6 +553,32 @@ async fn process_external_generation_job_once( } } +async fn response_error_message(response: axum::response::Response) -> String { + use axum::body::to_bytes; + let status = response.status(); + let body_bytes = match to_bytes(response.into_body(), 64 * 1024).await { + Ok(bytes) => bytes, + Err(error) => { + return format!("外部生成任务失败:{status},响应读取失败:{error}"); + } + }; + let body_text = String::from_utf8_lossy(&body_bytes).trim().to_string(); + if body_text.is_empty() { + return format!("外部生成任务失败:{status}"); + } + if let Ok(body_json) = serde_json::from_str::(&body_text) + && let Some(message) = body_json + .get("error") + .and_then(|error| error.get("message")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|message| !message.is_empty()) + { + return message.to_string(); + } + body_text +} + async fn fail_queue_job_after_worker_error( state: &AppState, worker_id: &str, diff --git a/server-rs/crates/api-server/src/jump_hop.rs b/server-rs/crates/api-server/src/jump_hop.rs index 015a510e..8200eab2 100644 --- a/server-rs/crates/api-server/src/jump_hop.rs +++ b/server-rs/crates/api-server/src/jump_hop.rs @@ -9,7 +9,11 @@ use module_assets::{ generate_asset_binding_id, generate_asset_object_id, }; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use shared_contracts::external_generation::{ + ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, +}; use shared_contracts::jump_hop::{ JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, @@ -20,7 +24,9 @@ use shared_contracts::jump_hop::{ JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; -use spacetime_client::SpacetimeClientError; +use spacetime_client::{ + ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, +}; use std::{ collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, @@ -49,6 +55,7 @@ use crate::{ }; const JUMP_HOP_TILE_ITEM_COUNT: usize = 18; +pub(crate) const JUMP_HOP_COMPILE_DRAFT_JOB_KIND: &str = "jump_hop_compile_draft"; const JUMP_HOP_PROVIDER: &str = "jump-hop"; const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; @@ -72,6 +79,14 @@ const JUMP_HOP_BACK_BUTTON_IMAGE_SIZE: &str = "1024*1024"; const JUMP_HOP_BACK_BUTTON_IMAGE_WIDTH: u32 = 1024; const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: u32 = 1024; +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct JumpHopCompileDraftWorkerPayload { + pub session_id: String, + pub owner_user_id: String, + pub payload: JumpHopActionRequest, +} + #[derive(Clone, Debug, PartialEq, Eq)] struct JumpHopTileAtlasSlice { tile_type: JumpHopTileType, @@ -174,6 +189,37 @@ pub async fn execute_jump_hop_action( let owner_user_id = authenticated.claims().user_id().to_string(); let mut payload = payload; let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft); + let should_queue_generation = matches!( + payload.action_type, + JumpHopActionType::CompileDraft | JumpHopActionType::RegenerateTiles + ) && !state.config.external_generation_mode.is_inline(); + if should_queue_generation { + let mut queued_response = state + .spacetime_client() + .mark_jump_hop_generation_queued( + session_id.clone(), + owner_user_id.clone(), + payload.clone(), + ) + .await + .map_err(|error| { + jump_hop_error_response( + &request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + let queue_job = enqueue_jump_hop_compile_draft_job( + &state, + &request_context, + &session_id, + owner_user_id.as_str(), + payload, + ) + .await?; + queued_response.queue_state = Some(map_jump_hop_queue_job_status(queue_job)); + return Ok(json_success_body(Some(&request_context), queued_response)); + } let generation_points_cost = if is_compile_draft { resolve_jump_hop_generation_points_cost(&state).await } else { @@ -246,6 +292,99 @@ pub async fn execute_jump_hop_action( } } +async fn enqueue_jump_hop_compile_draft_job( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + payload: JumpHopActionRequest, +) -> Result { + let job_id = build_prefixed_uuid_id("extgen-"); + let now_micros = current_utc_micros(); + let request_payload_json = serde_json::to_string(&JumpHopCompileDraftWorkerPayload { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + payload, + }) + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "message": format!("跳一跳 worker 任务参数序列化失败:{error}"), + })), + ) + })?; + state + .spacetime_client() + .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { + dedupe_key: format!("jump-hop:compile-draft:{session_id}:{job_id}"), + job_id, + job_kind: JUMP_HOP_COMPILE_DRAFT_JOB_KIND.to_string(), + owner_user_id: owner_user_id.to_string(), + source_module: "jump-hop".to_string(), + source_entity_id: session_id.to_string(), + request_label: "跳一跳草稿生成".to_string(), + request_payload_json, + max_attempts: 1, + available_at_micros: now_micros, + created_at_micros: now_micros, + }) + .await + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + }) +} + +fn map_jump_hop_queue_job_status( + job: ExternalGenerationJobRecord, +) -> ExternalGenerationJobStatusRecord { + ExternalGenerationJobStatusRecord { + operation_id: job.job_id, + status: ExternalGenerationJobStatus::Queued, + phase_label: job.request_label, + phase_detail: "排队中。".to_string(), + progress: 8, + error: job.last_error_message, + updated_at_micros: job.updated_at_micros, + } +} + +pub(crate) async fn execute_jump_hop_compile_draft_worker_job( + state: &AppState, + request_context: &RequestContext, + mut worker_payload: JumpHopCompileDraftWorkerPayload, +) -> Result { + maybe_generate_jump_hop_assets( + state, + request_context, + worker_payload.session_id.as_str(), + worker_payload.owner_user_id.as_str(), + &mut worker_payload.payload, + ) + .await?; + let response = state + .spacetime_client() + .execute_jump_hop_action( + worker_payload.session_id, + worker_payload.owner_user_id, + worker_payload.payload, + ) + .await + .map_err(|error| { + jump_hop_error_response( + request_context, + JUMP_HOP_CREATION_PROVIDER, + map_jump_hop_client_error(error), + ) + })?; + Ok(response.session) +} + async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 { crate::creation_entry_config::resolve_creation_entry_mud_point_cost( state, @@ -1005,15 +1144,8 @@ fn slice_jump_hop_tile_atlas( let y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS; let tile_width = x1.saturating_sub(x0).max(1); let tile_height = y1.saturating_sub(y0).max(1); - let faces = slice_jump_hop_tile_uv_faces( - &source, - x0, - y0, - tile_width, - tile_height, - row, - col, - )?; + let faces = + slice_jump_hop_tile_uv_faces(&source, x0, y0, tile_width, tile_height, row, col)?; slices.push(JumpHopTileAtlasSlice { tile_type: jump_hop_tile_type_by_index(index), source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1), @@ -1043,22 +1175,70 @@ fn slice_jump_hop_tile_uv_faces( Ok(JumpHopTileFaceSlices { top: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Top, 1, 0, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Top, + 1, + 0, )?, front: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Front, 1, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Front, + 1, + 1, )?, right: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Right, 2, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Right, + 2, + 1, )?, back: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Back, 3, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Back, + 3, + 1, )?, left: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Left, 0, 1, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Left, + 0, + 1, )?, bottom: slice_jump_hop_tile_uv_face( - source, uv_x, uv_y, face_side, atlas_row, atlas_col, JumpHopTileFaceKey::Bottom, 1, 2, + source, + uv_x, + uv_y, + face_side, + atlas_row, + atlas_col, + JumpHopTileFaceKey::Bottom, + 1, + 2, )?, }) } @@ -1095,12 +1275,7 @@ fn slice_jump_hop_tile_uv_face( Ok(JumpHopTileFaceSlice { face, - source_atlas_cell: format!( - "row-{}-col-{}/{}", - atlas_row + 1, - atlas_col + 1, - face_label - ), + source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, face_label), bytes: cursor.into_inner(), }) } @@ -1827,7 +2002,9 @@ mod tests { assert!(prompt.contains("18个用于跳一跳地板的立方体主题物体 UV 展开包装图")); assert!(prompt.contains("按三列六行均匀排布")); assert!(prompt.contains("每个大单元格代表一个完整的 1x1x1 立方体方块物体")); - assert!(prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上")); + assert!( + prompt.contains("该单元内的六张面贴图精确贴到 Three.js 标准极小倒角立方体的六个面上") + ); assert!(prompt.contains("cube object UV unwrap atlas / 立方体主题物体六面展开图集")); assert!(prompt.contains("不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满")); assert!(prompt.contains("游戏界面或图标集页面")); @@ -1850,7 +2027,9 @@ mod tests { assert!(prompt.contains("full-bleed opaque square face texture")); assert!(prompt.contains("四角、边缘和中心都要有可识别内容")); assert!(prompt.contains("不留透明、不留空白、不留实底背景")); - assert!(prompt.contains("允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点")); + assert!(prompt.contains( + "允许大面积水果切面、果柄叶片、剥皮条带、籽点、条纹和轮廓图案作为包装身份锚点" + )); assert!(prompt.contains("不要把一个小水果、小叶片、小石头或小物体放在面中央")); assert!(prompt.contains("这不是透视渲染图")); assert!(prompt.contains("不要画摄像机视角、透视块、已烘焙侧壁")); @@ -1868,14 +2047,18 @@ mod tests { assert!(prompt.contains("小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理")); assert!(prompt.contains("English guardrail")); assert!(prompt.contains("one vertical 1024x1536 image")); - assert!(prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas")); + assert!( + prompt.contains("exactly 18 cube object UV unwraps in a 3 columns by 6 rows atlas") + ); assert!(prompt.contains("row1 col2 top")); assert!(prompt.contains("row2 col1 left")); assert!(prompt.contains("row2 col2 front")); assert!(prompt.contains("row2 col3 right")); assert!(prompt.contains("row2 col4 back")); assert!(prompt.contains("row3 col2 bottom")); - assert!(prompt.contains("six different face textures that stitch into one recognizable cubified theme object")); + assert!(prompt.contains( + "six different face textures that stitch into one recognizable cubified theme object" + )); assert!(prompt.contains("no generic flat material")); assert!(prompt.contains("no small centered stickers")); assert!(prompt.contains("every face is full-bleed opaque square texture")); @@ -2022,7 +2205,9 @@ mod tests { "科幻芯片主题的俯视角清爽游戏化立体感平台素材", ); - assert!(prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图")); + assert!( + prompt.contains("画面内容是科幻芯片主题的正交平面清爽游戏化立方体主题身份方块包装贴图") + ); assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材")); assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角")); @@ -2118,12 +2303,10 @@ mod tests { .max(1); let tile_x = atlas_col.saturating_mul(cell_width); let tile_y = atlas_row.saturating_mul(cell_height); - let uv_x = tile_x.saturating_add( - cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2, - ); - let uv_y = tile_y.saturating_add( - cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2, - ); + let uv_x = tile_x + .saturating_add(cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2); + let uv_y = tile_y + .saturating_add(cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2); for y in uv_y + face_row * face_side..uv_y + (face_row + 1) * face_side { for x in uv_x + face_col * face_side..uv_x + (face_col + 1) * face_side { atlas.put_pixel(x, y, color); @@ -2159,14 +2342,8 @@ mod tests { ), "{message}" ); - assert!( - decoded.pixels().any(|pixel| pixel.0 == color), - "{message}" - ); - assert!( - decoded.pixels().all(|pixel| pixel.0[3] == 255), - "{message}" - ); + assert!(decoded.pixels().any(|pixel| pixel.0 == color), "{message}"); + assert!(decoded.pixels().all(|pixel| pixel.0[3] == 255), "{message}"); } #[test] diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 1cb2ee7c..38e0f23b 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -40,6 +40,7 @@ mod edutainment_baby_drawing; mod edutainment_baby_object; mod error_middleware; mod external_api_audit; +mod external_generation; mod external_generation_worker; mod external_generation_worker_controller; pub(crate) mod generated_asset_sheets; diff --git a/server-rs/crates/api-server/src/modules.rs b/server-rs/crates/api-server/src/modules.rs index 88caf30d..f79ec834 100644 --- a/server-rs/crates/api-server/src/modules.rs +++ b/server-rs/crates/api-server/src/modules.rs @@ -5,6 +5,7 @@ pub mod bark_battle; pub mod big_fish; pub mod custom_world; pub mod edutainment; +pub mod external_generation; pub mod health; pub mod internal; pub mod jump_hop; diff --git a/server-rs/crates/api-server/src/modules/external_generation.rs b/server-rs/crates/api-server/src/modules/external_generation.rs new file mode 100644 index 00000000..b65c832d --- /dev/null +++ b/server-rs/crates/api-server/src/modules/external_generation.rs @@ -0,0 +1,26 @@ +use axum::{Router, middleware, routing::get}; + +use crate::{ + auth::require_bearer_auth, + external_generation::{ + get_external_generation_job_status, get_external_generation_queue_overview, + }, + state::AppState, +}; + +pub fn router(state: AppState) -> Router { + Router::new() + .route( + "/api/runtime/external-generation/queue-overview", + get(get_external_generation_queue_overview).route_layer( + middleware::from_fn_with_state(state.clone(), require_bearer_auth), + ), + ) + .route( + "/api/runtime/external-generation/jobs/{job_id}", + get(get_external_generation_job_status).route_layer(middleware::from_fn_with_state( + state.clone(), + require_bearer_auth, + )), + ) +} diff --git a/server-rs/crates/api-server/src/puzzle_clear.rs b/server-rs/crates/api-server/src/puzzle_clear.rs index 1221df47..8c88dd71 100644 --- a/server-rs/crates/api-server/src/puzzle_clear.rs +++ b/server-rs/crates/api-server/src/puzzle_clear.rs @@ -11,7 +11,11 @@ use module_assets::{ generate_asset_binding_id, generate_asset_object_id, }; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use shared_contracts::external_generation::{ + ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, +}; use shared_contracts::puzzle_clear::{ PuzzleClearActionRequest, PuzzleClearActionType, PuzzleClearCardAsset, PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset, @@ -22,7 +26,9 @@ use shared_contracts::puzzle_clear::{ PuzzleClearWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; -use spacetime_client::SpacetimeClientError; +use spacetime_client::{ + ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, +}; use std::{ collections::BTreeMap, time::{SystemTime, UNIX_EPOCH}, @@ -51,6 +57,7 @@ const PUZZLE_CLEAR_CREATION_PROVIDER: &str = "puzzle-clear-creation"; const PUZZLE_CLEAR_RUNTIME_PROVIDER: &str = "puzzle-clear-runtime"; const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear"; const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消"; +pub(crate) const PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND: &str = "puzzle_clear_compile_draft"; const PUZZLE_CLEAR_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/puzzle-clear/runs"; const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256; const PUZZLE_CLEAR_SHEET_COLUMNS: u32 = 4; @@ -76,6 +83,15 @@ const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_CONTRAST_THRESHOLD: f32 = 145.0; const PUZZLE_CLEAR_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0; const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插"; +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct PuzzleClearCompileDraftWorkerPayload { + pub session_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub payload: PuzzleClearActionRequest, +} + pub async fn create_puzzle_clear_session( State(state): State, Extension(request_context): Extension, @@ -160,6 +176,39 @@ pub async fn execute_puzzle_clear_action( .unwrap_or("拼消消玩家") .to_string(); let mut payload = payload; + let should_queue_generation = matches!( + payload.action_type, + PuzzleClearActionType::CompileDraft | PuzzleClearActionType::RegenerateAtlas + ) && !state.config.external_generation_mode.is_inline(); + if should_queue_generation { + let mut queued_response = state + .spacetime_client() + .mark_puzzle_clear_generation_queued( + session_id.clone(), + owner_user_id.clone(), + author_display_name.clone(), + payload.clone(), + ) + .await + .map_err(|error| { + puzzle_clear_error_response( + &request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + map_puzzle_clear_client_error(error), + ) + })?; + let queue_job = enqueue_puzzle_clear_compile_draft_job( + &state, + &request_context, + &session_id, + owner_user_id.as_str(), + author_display_name.as_str(), + payload, + ) + .await?; + queued_response.queue_state = Some(map_puzzle_clear_queue_job_status(queue_job)); + return Ok(json_success_body(Some(&request_context), queued_response)); + } if let Err(response) = maybe_prepare_puzzle_clear_assets_inner( &state, &request_context, @@ -210,6 +259,129 @@ pub async fn execute_puzzle_clear_action( Ok(json_success_body(Some(&request_context), response)) } +async fn enqueue_puzzle_clear_compile_draft_job( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + author_display_name: &str, + payload: PuzzleClearActionRequest, +) -> Result { + let job_id = build_prefixed_uuid_id("extgen-"); + let now_micros = current_utc_micros(); + let request_payload_json = serde_json::to_string(&PuzzleClearCompileDraftWorkerPayload { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + author_display_name: author_display_name.to_string(), + payload, + }) + .map_err(|error| { + puzzle_clear_error_response( + request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "message": format!("拼消消 worker 任务参数序列化失败:{error}"), + })), + ) + })?; + state + .spacetime_client() + .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { + dedupe_key: format!("puzzle-clear:compile-draft:{session_id}:{job_id}"), + job_id, + job_kind: PUZZLE_CLEAR_COMPILE_DRAFT_JOB_KIND.to_string(), + owner_user_id: owner_user_id.to_string(), + source_module: "puzzle-clear".to_string(), + source_entity_id: session_id.to_string(), + request_label: "拼消消草稿生成".to_string(), + request_payload_json, + max_attempts: 1, + available_at_micros: now_micros, + created_at_micros: now_micros, + }) + .await + .map_err(|error| { + puzzle_clear_error_response( + request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + map_puzzle_clear_client_error(error), + ) + }) +} + +fn map_puzzle_clear_queue_job_status( + job: ExternalGenerationJobRecord, +) -> ExternalGenerationJobStatusRecord { + ExternalGenerationJobStatusRecord { + operation_id: job.job_id, + status: ExternalGenerationJobStatus::Queued, + phase_label: job.request_label, + phase_detail: "排队中。".to_string(), + progress: 8, + error: job.last_error_message, + updated_at_micros: job.updated_at_micros, + } +} + +pub(crate) async fn execute_puzzle_clear_compile_draft_worker_job( + state: &AppState, + request_context: &RequestContext, + mut worker_payload: PuzzleClearCompileDraftWorkerPayload, +) -> Result { + if let Err(response) = maybe_prepare_puzzle_clear_assets_inner( + state, + request_context, + worker_payload.session_id.as_str(), + worker_payload.owner_user_id.as_str(), + &mut worker_payload.payload, + ) + .await + { + let (error_message, response) = extract_puzzle_clear_response_error_message(response).await; + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + session_id = worker_payload.session_id, + error = %error_message, + "拼消消 worker 素材生成失败,准备回写 failed 状态" + ); + if let Err(writeback_error) = state + .spacetime_client() + .mark_puzzle_clear_generation_failed( + worker_payload.session_id.clone(), + worker_payload.owner_user_id.clone(), + worker_payload.author_display_name.clone(), + worker_payload.payload.clone(), + ) + .await + { + tracing::warn!( + provider = PUZZLE_CLEAR_CREATION_PROVIDER, + session_id = worker_payload.session_id, + error = %writeback_error, + "拼消消 worker 失败状态回写失败" + ); + } + return Err(response); + } + let response = state + .spacetime_client() + .execute_puzzle_clear_action( + worker_payload.session_id, + worker_payload.owner_user_id, + worker_payload.author_display_name, + worker_payload.payload, + ) + .await + .map_err(|error| { + puzzle_clear_error_response( + request_context, + PUZZLE_CLEAR_CREATION_PROVIDER, + map_puzzle_clear_client_error(error), + ) + })?; + Ok(response.session) +} + pub async fn list_puzzle_clear_works( State(state): State, Extension(request_context): Extension, diff --git a/server-rs/crates/api-server/src/wooden_fish.rs b/server-rs/crates/api-server/src/wooden_fish.rs index a8c46668..9f583dd6 100644 --- a/server-rs/crates/api-server/src/wooden_fish.rs +++ b/server-rs/crates/api-server/src/wooden_fish.rs @@ -14,7 +14,11 @@ use module_assets::{ build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, }; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; +use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; +use shared_contracts::external_generation::{ + ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord, +}; use shared_contracts::wooden_fish::{ WoodenFishActionRequest, WoodenFishAudioAsset, WoodenFishCheckpointRunRequest, WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse, @@ -24,7 +28,9 @@ use shared_contracts::wooden_fish::{ WoodenFishWorkspaceCreateRequest, }; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; -use spacetime_client::SpacetimeClientError; +use spacetime_client::{ + ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError, +}; use crate::generated_image_assets::{ GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, @@ -54,6 +60,8 @@ const WOODEN_FISH_CREATION_PROVIDER: &str = "wooden-fish-creation"; const WOODEN_FISH_RUNTIME_PROVIDER: &str = "wooden-fish-runtime"; const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish"; const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼"; +pub(crate) const WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND: &str = + "wooden_fish_generate_image_assets"; const DEFAULT_HIT_OBJECT_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景"; const DEFAULT_HIT_OBJECT_ASSET_ID: &str = "wooden-fish-default-hit-object"; const DEFAULT_HIT_OBJECT_IMAGE_SRC: &str = "/wooden-fish/default-hit-object.png"; @@ -73,6 +81,15 @@ const DEFAULT_HIT_OBJECT_REFERENCE_BYTES: &[u8] = include_bytes!(concat!( )); const WOODEN_FISH_AUTHOR_FALLBACK_DISPLAY_NAME: &str = "玩家"; +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct WoodenFishGenerateImageAssetsWorkerPayload { + pub session_id: String, + pub owner_user_id: String, + pub author_display_name: String, + pub payload: WoodenFishActionRequest, +} + pub async fn create_wooden_fish_session( State(state): State, Extension(request_context): Extension, @@ -155,6 +172,40 @@ pub async fn execute_wooden_fish_action( payload.action_type, shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft ); + let should_queue_generation = matches!( + payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + | shared_contracts::wooden_fish::WoodenFishActionType::RegenerateHitObject + ) && !state.config.external_generation_mode.is_inline(); + if should_queue_generation { + let mut queued_response = state + .spacetime_client() + .mark_wooden_fish_generation_queued( + session_id.clone(), + owner_user_id.clone(), + author_display_name.clone(), + payload.clone(), + ) + .await + .map_err(|error| { + wooden_fish_error_response( + &request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + })?; + let queue_job = enqueue_wooden_fish_generate_image_assets_job( + &state, + &request_context, + &session_id, + owner_user_id.as_str(), + author_display_name.as_str(), + payload, + ) + .await?; + queued_response.queue_state = Some(map_wooden_fish_queue_job_status(queue_job)); + return Ok(json_success_body(Some(&request_context), queued_response)); + } let generation_points_cost = if is_compile_draft { resolve_wooden_fish_generation_points_cost(&state).await } else { @@ -226,6 +277,70 @@ pub async fn execute_wooden_fish_action( Ok(json_success_body(Some(&request_context), response)) } +async fn enqueue_wooden_fish_generate_image_assets_job( + state: &AppState, + request_context: &RequestContext, + session_id: &str, + owner_user_id: &str, + author_display_name: &str, + payload: WoodenFishActionRequest, +) -> Result { + let job_id = build_prefixed_uuid_id("extgen-"); + let now_micros = current_utc_micros(); + let request_payload_json = serde_json::to_string(&WoodenFishGenerateImageAssetsWorkerPayload { + session_id: session_id.to_string(), + owner_user_id: owner_user_id.to_string(), + author_display_name: author_display_name.to_string(), + payload, + }) + .map_err(|error| { + wooden_fish_error_response( + request_context, + WOODEN_FISH_CREATION_PROVIDER, + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "message": format!("敲木鱼 worker 任务参数序列化失败:{error}"), + })), + ) + })?; + state + .spacetime_client() + .enqueue_external_generation_job(ExternalGenerationJobEnqueueRecordInput { + dedupe_key: format!("wooden-fish:generate-image-assets:{session_id}:{job_id}"), + job_id, + job_kind: WOODEN_FISH_GENERATE_IMAGE_ASSETS_JOB_KIND.to_string(), + owner_user_id: owner_user_id.to_string(), + source_module: "wooden-fish".to_string(), + source_entity_id: session_id.to_string(), + request_label: "敲木鱼图片素材生成".to_string(), + request_payload_json, + max_attempts: 1, + available_at_micros: now_micros, + created_at_micros: now_micros, + }) + .await + .map_err(|error| { + wooden_fish_error_response( + request_context, + WOODEN_FISH_CREATION_PROVIDER, + map_wooden_fish_client_error(error), + ) + }) +} + +fn map_wooden_fish_queue_job_status( + job: ExternalGenerationJobRecord, +) -> ExternalGenerationJobStatusRecord { + ExternalGenerationJobStatusRecord { + operation_id: job.job_id, + status: ExternalGenerationJobStatus::Queued, + phase_label: job.request_label, + phase_detail: "排队中。".to_string(), + progress: 8, + error: job.last_error_message, + updated_at_micros: job.updated_at_micros, + } +} + pub async fn publish_wooden_fish_work( State(state): State, Path(profile_id): Path, @@ -635,6 +750,40 @@ async fn execute_wooden_fish_action_with_generated_assets( }) } +pub(crate) async fn execute_wooden_fish_generate_image_assets_worker_job( + state: &AppState, + request_context: &RequestContext, + mut worker_payload: WoodenFishGenerateImageAssetsWorkerPayload, +) -> Result { + let result = execute_wooden_fish_action_with_generated_assets( + state, + request_context, + worker_payload.session_id.as_str(), + worker_payload.owner_user_id.as_str(), + worker_payload.author_display_name.as_str(), + &mut worker_payload.payload, + ) + .await; + if result.as_ref().err().is_some_and(|response| { + response.status().is_server_error() + && matches!( + worker_payload.payload.action_type, + shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft + ) + }) { + mark_wooden_fish_generation_failed( + state, + request_context, + worker_payload.session_id.as_str(), + worker_payload.owner_user_id.as_str(), + worker_payload.author_display_name.as_str(), + ) + .await; + } + let response = result?; + Ok(response.session) +} + async fn resolve_wooden_fish_generation_points_cost(state: &AppState) -> u64 { crate::creation_entry_config::resolve_creation_entry_mud_point_cost( state, diff --git a/server-rs/crates/shared-contracts/src/external_generation.rs b/server-rs/crates/shared-contracts/src/external_generation.rs new file mode 100644 index 00000000..86de9377 --- /dev/null +++ b/server-rs/crates/shared-contracts/src/external_generation.rs @@ -0,0 +1,42 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ExternalGenerationJobStatus { + Queued, + Running, + Completed, + Failed, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationQueueOverview { + pub pending_count: u32, + pub running_count: u32, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationQueueOverviewResponse { + pub overview: ExternalGenerationQueueOverview, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationJobStatusRecord { + pub operation_id: String, + pub status: ExternalGenerationJobStatus, + pub phase_label: String, + pub phase_detail: String, + pub progress: u8, + pub error: Option, + pub updated_at_micros: i64, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalGenerationJobStatusResponse { + pub job: ExternalGenerationJobStatusRecord, +} diff --git a/server-rs/crates/shared-contracts/src/jump_hop.rs b/server-rs/crates/shared-contracts/src/jump_hop.rs index 3bc62911..d750be62 100644 --- a/server-rs/crates/shared-contracts/src/jump_hop.rs +++ b/server-rs/crates/shared-contracts/src/jump_hop.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::external_generation::ExternalGenerationJobStatusRecord; + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum JumpHopDifficulty { @@ -311,6 +313,8 @@ pub struct JumpHopActionResponse { pub session: JumpHopSessionSnapshotResponse, #[serde(default)] pub work: Option, + #[serde(default)] + pub queue_state: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] diff --git a/server-rs/crates/shared-contracts/src/lib.rs b/server-rs/crates/shared-contracts/src/lib.rs index 4faea2da..8f21eacb 100644 --- a/server-rs/crates/shared-contracts/src/lib.rs +++ b/server-rs/crates/shared-contracts/src/lib.rs @@ -11,6 +11,7 @@ pub mod creation_agent_document_input; pub mod creation_audio; pub mod creation_entry_config; pub mod creative_agent; +pub mod external_generation; pub mod hyper3d; pub mod jump_hop; pub mod llm; diff --git a/server-rs/crates/shared-contracts/src/puzzle_clear.rs b/server-rs/crates/shared-contracts/src/puzzle_clear.rs index 9d2af4f2..615fc5ae 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_clear.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_clear.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::external_generation::ExternalGenerationJobStatusRecord; + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum PuzzleClearGenerationStatus { @@ -141,6 +143,8 @@ pub struct PuzzleClearActionResponse { pub action_type: PuzzleClearActionType, pub session: PuzzleClearSessionSnapshotResponse, pub work: Option, + #[serde(default)] + pub queue_state: Option, } #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/server-rs/crates/shared-contracts/src/wooden_fish.rs b/server-rs/crates/shared-contracts/src/wooden_fish.rs index 422ea650..342d89ca 100644 --- a/server-rs/crates/shared-contracts/src/wooden_fish.rs +++ b/server-rs/crates/shared-contracts/src/wooden_fish.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::external_generation::ExternalGenerationJobStatusRecord; + #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "kebab-case")] pub enum WoodenFishGenerationStatus { @@ -164,6 +166,8 @@ pub struct WoodenFishActionResponse { pub session: WoodenFishSessionSnapshotResponse, #[serde(default)] pub work: Option, + #[serde(default)] + pub queue_state: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/server-rs/crates/spacetime-client/src/external_generation.rs b/server-rs/crates/spacetime-client/src/external_generation.rs index a437bc43..7c2635de 100644 --- a/server-rs/crates/spacetime-client/src/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/external_generation.rs @@ -127,6 +127,31 @@ impl SpacetimeClient { .await } + pub async fn get_external_generation_job( + &self, + input: ExternalGenerationJobGetRecordInput, + ) -> Result { + let procedure_input = input.into(); + + self.call_after_connect( + "get_external_generation_job_and_return", + move |connection, sender| { + connection + .procedures() + .get_external_generation_job_and_return_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(SpacetimeClientError::from_sdk_error) + .and_then(map_external_generation_job_procedure_result); + send_once(&sender, mapped); + }, + ); + }, + ) + .await + } + pub async fn get_external_generation_queue_stats( &self, ) -> Result { diff --git a/server-rs/crates/spacetime-client/src/jump_hop.rs b/server-rs/crates/spacetime-client/src/jump_hop.rs index a5eb7686..7d985593 100644 --- a/server-rs/crates/spacetime-client/src/jump_hop.rs +++ b/server-rs/crates/spacetime-client/src/jump_hop.rs @@ -113,6 +113,55 @@ impl SpacetimeClient { action_type: payload.action_type, session, work, + queue_state: None, + }) + } + + pub async fn mark_jump_hop_generation_queued( + &self, + session_id: String, + owner_user_id: String, + payload: JumpHopActionRequest, + ) -> Result { + let current = self + .get_jump_hop_session(session_id.clone(), owner_user_id.clone()) + .await?; + let action_type = payload.action_type.clone(); + let scope = match action_type { + JumpHopActionType::CompileDraft => JumpHopDraftMergeScope::CompileDraft, + JumpHopActionType::RegenerateTiles => JumpHopDraftMergeScope::RegenerateTiles, + _ => { + return Err(SpacetimeClientError::validation_failed( + "jump-hop queued generation 只支持 compile-draft/regenerate-tiles", + )); + } + }; + let mut base_draft = current.draft.clone(); + if matches!(action_type, JumpHopActionType::RegenerateTiles) + && let Some(draft) = base_draft.as_mut() + { + draft.tile_atlas_asset = None; + draft.tile_assets.clear(); + } + let mut draft = merge_action_into_draft(base_draft, &payload, scope)?; + let profile_id = resolve_jump_hop_profile_id(&draft, &action_type)?; + draft.profile_id = Some(profile_id.clone()); + draft.generation_status = JumpHopGenerationStatus::Generating; + let session = self + .compile_jump_hop_draft(build_generating_compile_input( + ¤t, + &owner_user_id, + &profile_id, + &draft, + current_unix_micros(), + )?) + .await?; + + Ok(JumpHopActionResponse { + action_type, + session, + work: None, + queue_state: None, }) } @@ -804,6 +853,50 @@ fn build_compile_input( }) } +fn build_generating_compile_input( + current: &JumpHopSessionSnapshotResponse, + owner_user_id: &str, + profile_id: &str, + draft: &JumpHopDraftResponse, + now_micros: i64, +) -> Result { + Ok(JumpHopDraftCompileInput { + session_id: current.session_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + author_display_name: "跳一跳玩家".to_string(), + seed_text: draft.work_title.clone(), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: Some(json_string(&draft.theme_tags)?), + theme_text: Some(draft.theme_text.clone()), + difficulty: Some(difficulty_to_str(&draft.difficulty).to_string()), + style_preset: Some(style_to_str(&draft.style_preset).to_string()), + character_prompt: Some(draft.character_prompt.clone()), + tile_prompt: Some(draft.tile_prompt.clone()), + end_mood_prompt: draft.end_mood_prompt.clone(), + character_asset_json: draft + .character_asset + .as_ref() + .map(json_string) + .transpose()?, + tile_atlas_asset_json: draft + .tile_atlas_asset + .as_ref() + .map(json_string) + .transpose()?, + tile_assets_json: Some(json_string(&draft.tile_assets)?), + cover_composite: draft.cover_composite.clone(), + back_button_asset_json: draft + .back_button_asset + .as_ref() + .map(json_string) + .transpose()?, + generation_status: Some("generating".to_string()), + compiled_at_micros: now_micros, + }) +} + fn build_update_input( owner_user_id: &str, profile_id: &str, diff --git a/server-rs/crates/spacetime-client/src/lib.rs b/server-rs/crates/spacetime-client/src/lib.rs index 2394403d..4cf5cf16 100644 --- a/server-rs/crates/spacetime-client/src/lib.rs +++ b/server-rs/crates/spacetime-client/src/lib.rs @@ -32,14 +32,14 @@ pub use mapper::{ CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, - ExternalGenerationJobFailRecordInput, ExternalGenerationJobRecord, - ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord, - JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, - JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, - JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, - JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, - JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, - JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, + ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput, + ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, + ExternalGenerationQueueStatsRecord, JumpHopActionRequest, JumpHopActionResponse, + JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse, + JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse, + JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, + JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, + JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 622098ae..1e6fae4f 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -72,8 +72,8 @@ pub use self::common::{ pub use self::external_generation::{ ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, - ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, - ExternalGenerationQueueStatsRecord, + ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord, + ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord, }; pub use self::jump_hop::{ JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, diff --git a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs index e0dafe26..9e372983 100644 --- a/server-rs/crates/spacetime-client/src/mapper/external_generation.rs +++ b/server-rs/crates/spacetime-client/src/mapper/external_generation.rs @@ -66,6 +66,15 @@ impl From for ExternalGenerationJobFailInp } } +impl From for ExternalGenerationJobGetInput { + fn from(input: ExternalGenerationJobGetRecordInput) -> Self { + Self { + job_id: input.job_id, + owner_user_id: input.owner_user_id, + } + } +} + pub(crate) fn map_external_generation_job_procedure_result( result: ExternalGenerationJobProcedureResult, ) -> Result { @@ -144,6 +153,7 @@ fn map_external_generation_job_snapshot( started_at: snapshot.started_at_micros.map(format_timestamp_micros), completed_at: snapshot.completed_at_micros.map(format_timestamp_micros), updated_at: format_timestamp_micros(snapshot.updated_at_micros), + updated_at_micros: snapshot.updated_at_micros, lease_token: snapshot.lease_token, } } @@ -199,6 +209,12 @@ pub struct ExternalGenerationJobFailRecordInput { pub failed_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalGenerationJobGetRecordInput { + pub job_id: String, + pub owner_user_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ExternalGenerationJobRecord { pub job_id: String, @@ -221,6 +237,7 @@ pub struct ExternalGenerationJobRecord { pub started_at: Option, pub completed_at: Option, pub updated_at: String, + pub updated_at_micros: i64, pub lease_token: Option, } diff --git a/server-rs/crates/spacetime-client/src/module_bindings.rs b/server-rs/crates/spacetime-client/src/module_bindings.rs index 29fa2e03..62e6d506 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings.rs @@ -355,6 +355,7 @@ pub mod external_generation_job_claim_input_type; pub mod external_generation_job_complete_input_type; pub mod external_generation_job_enqueue_input_type; pub mod external_generation_job_fail_input_type; +pub mod external_generation_job_get_input_type; pub mod external_generation_job_procedure_result_type; pub mod external_generation_job_renew_lease_input_type; pub mod external_generation_job_snapshot_type; @@ -388,6 +389,7 @@ pub mod get_custom_world_agent_session_procedure; pub mod get_custom_world_gallery_detail_by_code_procedure; pub mod get_custom_world_gallery_detail_procedure; pub mod get_custom_world_library_detail_procedure; +pub mod get_external_generation_job_and_return_procedure; pub mod get_external_generation_queue_stats_and_return_procedure; pub mod get_jump_hop_agent_session_procedure; pub mod get_jump_hop_leaderboard_procedure; @@ -1489,6 +1491,7 @@ pub use external_generation_job_claim_input_type::ExternalGenerationJobClaimInpu pub use external_generation_job_complete_input_type::ExternalGenerationJobCompleteInput; pub use external_generation_job_enqueue_input_type::ExternalGenerationJobEnqueueInput; pub use external_generation_job_fail_input_type::ExternalGenerationJobFailInput; +pub use external_generation_job_get_input_type::ExternalGenerationJobGetInput; pub use external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; pub use external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; @@ -1522,6 +1525,7 @@ pub use get_custom_world_agent_session_procedure::get_custom_world_agent_session pub use get_custom_world_gallery_detail_by_code_procedure::get_custom_world_gallery_detail_by_code; pub use get_custom_world_gallery_detail_procedure::get_custom_world_gallery_detail; pub use get_custom_world_library_detail_procedure::get_custom_world_library_detail; +pub use get_external_generation_job_and_return_procedure::get_external_generation_job_and_return; pub use get_external_generation_queue_stats_and_return_procedure::get_external_generation_queue_stats_and_return; pub use get_jump_hop_agent_session_procedure::get_jump_hop_agent_session; pub use get_jump_hop_leaderboard_procedure::get_jump_hop_leaderboard; diff --git a/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_get_input_type.rs b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_get_input_type.rs new file mode 100644 index 00000000..9b4bd341 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/external_generation_job_get_input_type.rs @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +pub struct ExternalGenerationJobGetInput { + pub job_id: String, + pub owner_user_id: String, +} + +impl __sdk::InModule for ExternalGenerationJobGetInput { + type Module = super::RemoteModule; +} diff --git a/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_and_return_procedure.rs b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_and_return_procedure.rs new file mode 100644 index 00000000..e2bc98a3 --- /dev/null +++ b/server-rs/crates/spacetime-client/src/module_bindings/get_external_generation_job_and_return_procedure.rs @@ -0,0 +1,59 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +#![allow(unused, clippy::all)] +use spacetimedb_sdk::__codegen::{self as __sdk, __lib, __sats, __ws}; + +use super::external_generation_job_get_input_type::ExternalGenerationJobGetInput; +use super::external_generation_job_procedure_result_type::ExternalGenerationJobProcedureResult; + +#[derive(__lib::ser::Serialize, __lib::de::Deserialize, Clone, PartialEq, Debug)] +#[sats(crate = __lib)] +struct GetExternalGenerationJobAndReturnArgs { + pub input: ExternalGenerationJobGetInput, +} + +impl __sdk::InModule for GetExternalGenerationJobAndReturnArgs { + type Module = super::RemoteModule; +} + +#[allow(non_camel_case_types)] +/// Extension trait for access to the procedure `get_external_generation_job_and_return`. +/// +/// Implemented for [`super::RemoteProcedures`]. +pub trait get_external_generation_job_and_return { + fn get_external_generation_job_and_return(&self, input: ExternalGenerationJobGetInput) { + self.get_external_generation_job_and_return_then(input, |_, _| {}); + } + + fn get_external_generation_job_and_return_then( + &self, + input: ExternalGenerationJobGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ); +} + +impl get_external_generation_job_and_return for super::RemoteProcedures { + fn get_external_generation_job_and_return_then( + &self, + input: ExternalGenerationJobGetInput, + + __callback: impl FnOnce( + &super::ProcedureEventContext, + Result, + ) + Send + + 'static, + ) { + self.imp + .invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>( + "get_external_generation_job_and_return", + GetExternalGenerationJobAndReturnArgs { input }, + __callback, + ); + } +} diff --git a/server-rs/crates/spacetime-client/src/puzzle_clear.rs b/server-rs/crates/spacetime-client/src/puzzle_clear.rs index 8d1dc0ed..ba9876c7 100644 --- a/server-rs/crates/spacetime-client/src/puzzle_clear.rs +++ b/server-rs/crates/spacetime-client/src/puzzle_clear.rs @@ -124,6 +124,51 @@ impl SpacetimeClient { action_type: payload.action_type, session, work, + queue_state: None, + }) + } + + pub async fn mark_puzzle_clear_generation_queued( + &self, + session_id: String, + owner_user_id: String, + author_display_name: String, + payload: PuzzleClearActionRequest, + ) -> Result { + let current = self + .get_puzzle_clear_session(session_id.clone(), owner_user_id.clone()) + .await?; + let action_type = payload.action_type.clone(); + let scope = match action_type { + PuzzleClearActionType::CompileDraft => PuzzleClearDraftMergeScope::CompileDraft, + PuzzleClearActionType::RegenerateAtlas => PuzzleClearDraftMergeScope::RegenerateAtlas, + _ => { + return Err(SpacetimeClientError::validation_failed( + "puzzle-clear queued generation 只支持 compile-draft/regenerate-atlas", + )); + } + }; + let mut draft = merge_action_into_draft(current.draft.clone(), &payload, scope)?; + let profile_id = + resolve_puzzle_clear_profile_id(&draft, &action_type, payload.profile_id.as_deref())?; + draft.profile_id = Some(profile_id.clone()); + draft.generation_status = PuzzleClearGenerationStatus::Generating; + let session = self + .compile_puzzle_clear_draft(build_generating_compile_input( + ¤t, + &owner_user_id, + &author_display_name, + &profile_id, + &draft, + current_unix_micros(), + )?) + .await?; + + Ok(PuzzleClearActionResponse { + action_type, + session, + work: None, + queue_state: None, }) } @@ -647,6 +692,38 @@ fn build_compile_input( }) } +fn build_generating_compile_input( + current: &PuzzleClearSessionSnapshotResponse, + owner_user_id: &str, + author_display_name: &str, + profile_id: &str, + draft: &PuzzleClearDraftResponse, + now_micros: i64, +) -> Result { + Ok(PuzzleClearDraftCompileInput { + session_id: current.session_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + author_display_name: non_empty_str(author_display_name) + .unwrap_or_else(|| "拼消消玩家".to_string()), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_prompt: draft.theme_prompt.clone(), + board_background_prompt: draft.board_background_prompt.clone(), + generate_board_background: draft.generate_board_background, + board_background_asset_json: draft + .board_background_asset + .as_ref() + .map(json_string) + .transpose()?, + atlas_asset_json: draft.atlas_asset.as_ref().map(json_string).transpose()?, + pattern_groups_json: Some(json_string(&draft.pattern_groups)?), + card_assets_json: Some(json_string(&draft.card_assets)?), + generation_status: Some("generating".to_string()), + compiled_at_micros: now_micros, + }) +} + fn build_failed_compile_input( current: &PuzzleClearSessionSnapshotResponse, owner_user_id: &str, diff --git a/server-rs/crates/spacetime-client/src/wooden_fish.rs b/server-rs/crates/spacetime-client/src/wooden_fish.rs index ddc7f867..66ad6fc2 100644 --- a/server-rs/crates/spacetime-client/src/wooden_fish.rs +++ b/server-rs/crates/spacetime-client/src/wooden_fish.rs @@ -119,6 +119,53 @@ impl SpacetimeClient { action_type: payload.action_type, session, work, + queue_state: None, + }) + } + + pub async fn mark_wooden_fish_generation_queued( + &self, + session_id: String, + owner_user_id: String, + author_display_name: String, + payload: WoodenFishActionRequest, + ) -> Result { + let current = self + .get_wooden_fish_session(session_id.clone(), owner_user_id.clone()) + .await?; + let action_type = payload.action_type.clone(); + let scope = match action_type { + WoodenFishActionType::CompileDraft => WoodenFishDraftMergeScope::CompileDraft, + WoodenFishActionType::RegenerateHitObject => { + WoodenFishDraftMergeScope::RegenerateHitObject + } + _ => { + return Err(SpacetimeClientError::validation_failed( + "wooden-fish queued generation 只支持 compile-draft/regenerate-hit-object", + )); + } + }; + let mut draft = merge_action_into_draft(current.draft.clone(), &payload, scope)?; + let profile_id = + resolve_wooden_fish_profile_id(&draft, &action_type, payload.profile_id.as_deref())?; + draft.profile_id = Some(profile_id.clone()); + draft.generation_status = WoodenFishGenerationStatus::Generating; + let session = self + .compile_wooden_fish_draft(build_generating_compile_input( + ¤t, + &owner_user_id, + &author_display_name, + &profile_id, + &draft, + current_unix_micros(), + )?) + .await?; + + Ok(WoodenFishActionResponse { + action_type, + session, + work: None, + queue_state: None, }) } @@ -689,6 +736,52 @@ fn build_compile_input( }) } +fn build_generating_compile_input( + current: &WoodenFishSessionSnapshotResponse, + owner_user_id: &str, + author_display_name: &str, + profile_id: &str, + draft: &WoodenFishDraftResponse, + now_micros: i64, +) -> Result { + Ok(WoodenFishDraftCompileInput { + session_id: current.session_id.clone(), + owner_user_id: owner_user_id.to_string(), + profile_id: profile_id.to_string(), + author_display_name: author_display_name.trim().to_string(), + work_title: draft.work_title.clone(), + work_description: draft.work_description.clone(), + theme_tags_json: Some(json_string(&draft.theme_tags)?), + hit_object_prompt: draft.hit_object_prompt.clone(), + hit_object_reference_image_src: draft.hit_object_reference_image_src.clone(), + hit_sound_prompt: draft.hit_sound_prompt.clone(), + hit_object_asset_json: draft + .hit_object_asset + .as_ref() + .map(json_string) + .transpose()?, + background_asset_json: draft + .background_asset + .as_ref() + .map(json_string) + .transpose()?, + hit_sound_asset_json: draft + .hit_sound_asset + .as_ref() + .map(json_string) + .transpose()?, + back_button_asset_json: draft + .back_button_asset + .as_ref() + .map(json_string) + .transpose()?, + floating_words_json: Some(json_string(&draft.floating_words)?), + cover_image_src: draft.cover_image_src.clone(), + generation_status: Some("generating".to_string()), + compiled_at_micros: now_micros, + }) +} + fn build_failed_compile_input( current: &WoodenFishSessionSnapshotResponse, owner_user_id: &str, diff --git a/server-rs/crates/spacetime-module/src/external_generation.rs b/server-rs/crates/spacetime-module/src/external_generation.rs index 829d9969..0de980e1 100644 --- a/server-rs/crates/spacetime-module/src/external_generation.rs +++ b/server-rs/crates/spacetime-module/src/external_generation.rs @@ -104,6 +104,12 @@ pub struct ExternalGenerationJobFailInput { pub failed_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] +pub struct ExternalGenerationJobGetInput { + pub job_id: String, + pub owner_user_id: String, +} + #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)] pub struct ExternalGenerationJobSnapshot { pub job_id: String, @@ -218,6 +224,17 @@ pub fn fail_external_generation_job_and_return( } } +#[spacetimedb::procedure] +pub fn get_external_generation_job_and_return( + ctx: &mut ProcedureContext, + input: ExternalGenerationJobGetInput, +) -> ExternalGenerationJobProcedureResult { + match ctx.try_with_tx(|tx| get_external_generation_job_tx(tx, input.clone())) { + Ok(job) => single_external_generation_job_result(job), + Err(message) => failed_external_generation_job_result(message), + } +} + #[spacetimedb::procedure] pub fn get_external_generation_queue_stats_and_return( ctx: &mut ProcedureContext, @@ -405,6 +422,28 @@ fn complete_external_generation_job_tx( Ok(map_external_generation_job_row(row)) } +fn get_external_generation_job_tx( + ctx: &ReducerContext, + input: ExternalGenerationJobGetInput, +) -> Result { + validate_required("external_generation_job.job_id", &input.job_id)?; + validate_required( + "external_generation_job.owner_user_id", + &input.owner_user_id, + )?; + let row = ctx + .db + .external_generation_job() + .job_id() + .find(&input.job_id.trim().to_string()) + .ok_or_else(|| "external_generation_job 不存在".to_string())?; + if row.owner_user_id.trim() != input.owner_user_id.trim() { + return Err("external_generation_job 不存在".to_string()); + } + + Ok(map_external_generation_job_row(row)) +} + fn renew_external_generation_job_lease_tx( ctx: &ReducerContext, input: ExternalGenerationJobRenewLeaseInput, diff --git a/server-rs/crates/spacetime-module/src/puzzle_clear.rs b/server-rs/crates/spacetime-module/src/puzzle_clear.rs index ce917767..62b7a42a 100644 --- a/server-rs/crates/spacetime-module/src/puzzle_clear.rs +++ b/server-rs/crates/spacetime-module/src/puzzle_clear.rs @@ -345,6 +345,9 @@ fn compile_puzzle_clear_draft_tx( if input.generation_status.as_deref() == Some(PUZZLE_CLEAR_GENERATION_FAILED) { return mark_puzzle_clear_generation_failed_tx(ctx, input, session); } + if input.generation_status.as_deref() == Some(PUZZLE_CLEAR_GENERATION_GENERATING) { + return mark_puzzle_clear_generation_generating_tx(ctx, input, session); + } let pattern_groups: Vec = input .pattern_groups_json .as_deref() @@ -457,6 +460,71 @@ fn compile_puzzle_clear_draft_tx( ) } +fn mark_puzzle_clear_generation_generating_tx( + ctx: &ReducerContext, + input: PuzzleClearDraftCompileInput, + session: PuzzleClearAgentSessionRow, +) -> Result { + let updated_at = Timestamp::from_micros_since_unix_epoch(input.compiled_at_micros); + let mut draft = if session.draft_json.trim().is_empty() { + None + } else { + parse_json::(&session.draft_json).ok() + } + .unwrap_or_else(|| PuzzleClearDraftSnapshot { + template_id: PUZZLE_CLEAR_TEMPLATE_ID.to_string(), + template_name: PUZZLE_CLEAR_TEMPLATE_NAME.to_string(), + profile_id: Some(input.profile_id.clone()), + work_title: clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME), + work_description: input.work_description.trim().to_string(), + theme_prompt: clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME), + generate_board_background: input.generate_board_background, + board_background_asset: None, + board_background_prompt: clean_string(&input.board_background_prompt, &input.theme_prompt), + card_back_image_src: Some(PUZZLE_CLEAR_CARD_BACK_IMAGE_SRC.to_string()), + atlas_asset: None, + pattern_groups: Vec::new(), + card_assets: Vec::new(), + generation_status: PUZZLE_CLEAR_GENERATION_GENERATING.to_string(), + }); + draft.profile_id = Some(input.profile_id.clone()); + draft.work_title = clean_string(&input.work_title, PUZZLE_CLEAR_TEMPLATE_NAME); + draft.work_description = input.work_description.trim().to_string(); + draft.theme_prompt = clean_string(&input.theme_prompt, PUZZLE_CLEAR_TEMPLATE_NAME); + draft.generate_board_background = input.generate_board_background; + draft.board_background_prompt = + clean_string(&input.board_background_prompt, &input.theme_prompt); + if let Some(board_background_asset) = input + .board_background_asset_json + .as_deref() + .map(parse_json) + .transpose()? + { + draft.board_background_asset = Some(board_background_asset); + } + draft.generation_status = PUZZLE_CLEAR_GENERATION_GENERATING.to_string(); + + replace_session( + ctx, + &session, + PuzzleClearAgentSessionRow { + status: PUZZLE_CLEAR_GENERATION_GENERATING.to_string(), + draft_json: to_json_string(&draft), + published_profile_id: input.profile_id, + updated_at, + ..clone_session(&session) + }, + ); + + get_puzzle_clear_agent_session_tx( + ctx, + PuzzleClearAgentSessionGetInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + }, + ) +} + fn mark_puzzle_clear_generation_failed_tx( ctx: &ReducerContext, input: PuzzleClearDraftCompileInput, diff --git a/src/components/CustomWorldGenerationView.tsx b/src/components/CustomWorldGenerationView.tsx index 9c786a9c..886172b4 100644 --- a/src/components/CustomWorldGenerationView.tsx +++ b/src/components/CustomWorldGenerationView.tsx @@ -31,8 +31,16 @@ interface CustomWorldGenerationViewProps { idleBadgeLabel?: string; structuredEmptyText?: string; hideBatchModule?: boolean; + queueStatus?: ExternalGenerationQueueStatus | null; } +export type ExternalGenerationQueueStatus = { + currentStatus?: 'queued' | 'running' | 'completed' | 'failed' | null; + currentProgress?: number | null; + pendingCount?: number | null; + runningCount?: number | null; +}; + function formatDuration(ms: number) { const safeMs = Math.max(0, Math.round(ms)); const totalSeconds = Math.ceil(safeMs / 1000); @@ -85,6 +93,49 @@ function getStepStatusLabel(step: { status: string }) { return '待处理'; } +function resolveQueueStatusLabel( + status: ExternalGenerationQueueStatus['currentStatus'], +) { + if (status === 'queued') { + return '排队中'; + } + + if (status === 'running') { + return '生成中'; + } + + if (status === 'failed') { + return '生成失败'; + } + + if (status === 'completed') { + return '已完成'; + } + + return null; +} + +function hasQueueStatus(status: ExternalGenerationQueueStatus | null | undefined) { + return Boolean( + status && + (status.currentStatus || + typeof status.pendingCount === 'number' || + typeof status.runningCount === 'number'), + ); +} + +function formatQueueCount(value: number | null | undefined) { + return Math.max(0, Math.round(value ?? 0)).toString(); +} + +function formatQueueProgress(value: number | null | undefined) { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return null; + } + + return `${Math.max(0, Math.min(100, Math.round(value)))}%`; +} + function resolveCurrentGenerationStep( progress: CustomWorldGenerationProgress | null, ) { @@ -111,6 +162,7 @@ export function CustomWorldGenerationView({ activeBadgeLabel = '世界建设中', idleBadgeLabel = '等待操作', hideBatchModule = false, + queueStatus = null, }: CustomWorldGenerationViewProps) { void hideBatchModule; const progressValue = getProgressPercentage(progress); @@ -131,6 +183,11 @@ export function CustomWorldGenerationView({ : '校准中'; const elapsedText = progress != null ? formatDuration(progress.elapsedMs) : '启动中'; + const queueStatusLabel = resolveQueueStatusLabel( + queueStatus?.currentStatus ?? null, + ); + const queueProgressText = formatQueueProgress(queueStatus?.currentProgress); + const shouldShowQueueStatus = hasQueueStatus(queueStatus); return (
@@ -167,6 +224,21 @@ export function CustomWorldGenerationView({ />
+ {shouldShowQueueStatus ? ( +
+ {queueStatusLabel ? ( + + {queueProgressText + ? `${queueStatusLabel} ${queueProgressText}` + : queueStatusLabel} + + ) : null} + 排队 {formatQueueCount(queueStatus?.pendingCount)} + + 生成 {formatQueueCount(queueStatus?.runningCount)} +
+ ) : null} +
{!isGenerating ? ( { @@ -57,3 +58,34 @@ describe('resolveMiniGameGenerationViewBusy', () => { ); }); }); + +describe('buildExternalGenerationQueueStatus', () => { + test('合并队列概览和当前任务状态', () => { + expect( + buildExternalGenerationQueueStatus( + { + pendingCount: 7, + runningCount: 3, + updatedAtMicros: 1_781_222_400_000_000, + }, + { + operationId: 'extgen-1', + status: 'running', + phaseLabel: '正在生成。', + phaseDetail: '正在生成。', + progress: 35, + updatedAtMicros: 1_781_222_400_000_000, + }, + ), + ).toEqual({ + currentStatus: 'running', + currentProgress: 35, + pendingCount: 7, + runningCount: 3, + }); + }); + + test('没有队列或任务信息时不显示状态条', () => { + expect(buildExternalGenerationQueueStatus(null, null)).toBeNull(); + }); +}); diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 4a51480c..875665e2 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -37,6 +37,10 @@ import type { BabyObjectMatchDraft, CreateBabyObjectMatchDraftRequest, } from '../../../packages/shared/src/contracts/edutainmentBabyObject'; +import type { + ExternalGenerationJobStatusRecord, + ExternalGenerationQueueOverview, +} from '../../../packages/shared/src/contracts/externalGeneration'; import type { JumpHopJumpRequest, JumpHopWorkSummaryResponse, @@ -172,6 +176,7 @@ import { streamCreativeAgentMessage, streamCreativeDraftEdit, } from '../../services/creative-agent'; +import { getExternalGenerationQueueOverview } from '../../services/external-generation'; import { readCustomWorldAgentUiState, shouldRestoreCustomWorldAgentUiState, @@ -454,6 +459,7 @@ import { resolveWoodenFishCreationUrlRestoreStage, } from './platformCreationUrlStateModel'; import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow'; +import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel'; import { buildPlatformErrorDialogDismissKey, buildPlatformTaskCompletionDialogDismissKey, @@ -717,6 +723,20 @@ export function resolveMiniGameGenerationViewBusy( return isBusy || isMiniGameDraftGenerating(state ?? null); } +function isExternalGenerationQueueStage(selectionStage: SelectionStage) { + return ( + selectionStage === 'puzzle-generating' || + selectionStage === 'big-fish-generating' || + selectionStage === 'square-hole-generating' || + selectionStage === 'match3d-generating' || + selectionStage === 'baby-object-match-generating' || + selectionStage === 'jump-hop-generating' || + selectionStage === 'puzzle-clear-generating' || + selectionStage === 'wooden-fish-generating' || + selectionStage === 'visual-novel-generating' + ); +} + type PuzzleDetailReturnTarget = { tab: PlatformHomeTab; }; @@ -1747,9 +1767,17 @@ export function PlatformEntryFlowShellImpl({ const [isStartingRecommendEntry, setIsStartingRecommendEntry] = useState(false); const recommendRuntimeStartRequestRef = useRef(0); - const [, setPuzzleOperation] = useState( + const [puzzleOperation, setPuzzleOperation] = useState( null, ); + const [externalGenerationQueueOverview, setExternalGenerationQueueOverview] = + useState(null); + const [jumpHopQueueState, setJumpHopQueueState] = + useState(null); + const [puzzleClearQueueState, setPuzzleClearQueueState] = + useState(null); + const [woodenFishQueueState, setWoodenFishQueueState] = + useState(null); const [puzzleWorks, setPuzzleWorks] = useState([]); const [puzzleGalleryEntries, setPuzzleGalleryEntries] = useState< PuzzleWorkSummary[] @@ -4753,6 +4781,82 @@ export function PlatformEntryFlowShellImpl({ isWoodenFishBusy, woodenFishGenerationState, ); + const shouldShowExternalGenerationQueueStatus = + isExternalGenerationQueueStage(selectionStage); + useEffect(() => { + if (!shouldShowExternalGenerationQueueStatus) { + setExternalGenerationQueueOverview(null); + return; + } + + let disposed = false; + let controller: AbortController | null = null; + + const refreshQueueOverview = () => { + controller?.abort(); + controller = new AbortController(); + getExternalGenerationQueueOverview(controller.signal) + .then((response) => { + if (!disposed) { + setExternalGenerationQueueOverview(response.overview); + } + }) + .catch(() => { + if (!disposed) { + setExternalGenerationQueueOverview(null); + } + }); + }; + + refreshQueueOverview(); + const intervalId = window.setInterval(refreshQueueOverview, 4000); + + return () => { + disposed = true; + controller?.abort(); + window.clearInterval(intervalId); + }; + }, [shouldShowExternalGenerationQueueStatus]); + const puzzleExternalGenerationQueueStatus = useMemo( + () => + buildExternalGenerationQueueStatus( + externalGenerationQueueOverview, + puzzleOperation?.queueState ?? null, + ), + [externalGenerationQueueOverview, puzzleOperation], + ); + const jumpHopExternalGenerationQueueStatus = useMemo( + () => + buildExternalGenerationQueueStatus( + externalGenerationQueueOverview, + jumpHopQueueState, + ), + [externalGenerationQueueOverview, jumpHopQueueState], + ); + const puzzleClearExternalGenerationQueueStatus = useMemo( + () => + buildExternalGenerationQueueStatus( + externalGenerationQueueOverview, + puzzleClearQueueState, + ), + [externalGenerationQueueOverview, puzzleClearQueueState], + ); + const woodenFishExternalGenerationQueueStatus = useMemo( + () => + buildExternalGenerationQueueStatus( + externalGenerationQueueOverview, + woodenFishQueueState, + ), + [externalGenerationQueueOverview, woodenFishQueueState], + ); + const externalGenerationQueueStatus = useMemo( + () => + buildExternalGenerationQueueStatus( + externalGenerationQueueOverview, + null, + ), + [externalGenerationQueueOverview], + ); const platformBootstrapErrorForDisplay = isCreationEntryDisabledErrorMessage( platformBootstrap.platformError, ) @@ -7469,6 +7573,7 @@ export function PlatformEntryFlowShellImpl({ setJumpHopRun(null); setJumpHopRuntimeRequestOptions(null); setJumpHopGenerationState(generationState); + setJumpHopQueueState(null); setIsJumpHopBusy(true); setSelectionStage('jump-hop-generating'); markDraftGenerating('jump-hop', [ @@ -7485,6 +7590,19 @@ export function PlatformEntryFlowShellImpl({ draft: created.session.draft, }), ); + if (response.queueState && response.session.status === 'generating') { + setJumpHopQueueState(response.queueState); + setJumpHopSession(response.session); + setJumpHopWork(response.work ?? null); + writeCreationUrlState( + buildJumpHopCreationUrlState({ + session: response.session, + work: response.work, + }), + ); + return; + } + setJumpHopQueueState(null); const readyState = createReadyJumpHopGenerationState(generationState); setJumpHopSession(response.session); setJumpHopWork(response.work ?? null); @@ -7521,6 +7639,7 @@ export function PlatformEntryFlowShellImpl({ '生成跳一跳草稿失败。', ); setJumpHopError(errorMessage); + setJumpHopQueueState(null); setJumpHopGenerationState( resolveFinishedMiniGameDraftGenerationState( generationState, @@ -7590,6 +7709,7 @@ export function PlatformEntryFlowShellImpl({ const generationState = createMiniGameDraftGenerationState('jump-hop'); setJumpHopError(null); setJumpHopGenerationState(generationState); + setJumpHopQueueState(null); setIsJumpHopBusy(true); setSelectionStage('jump-hop-generating'); try { @@ -7599,6 +7719,19 @@ export function PlatformEntryFlowShellImpl({ draft: jumpHopSession.draft, }), ); + if (response.queueState && response.session.status === 'generating') { + setJumpHopQueueState(response.queueState); + setJumpHopSession(response.session); + setJumpHopWork(response.work ?? jumpHopWork); + writeCreationUrlState( + buildJumpHopCreationUrlState({ + session: response.session, + work: response.work ?? jumpHopWork, + }), + ); + return; + } + setJumpHopQueueState(null); setJumpHopSession(response.session); setJumpHopWork(response.work ?? jumpHopWork); writeCreationUrlState( @@ -7617,6 +7750,7 @@ export function PlatformEntryFlowShellImpl({ '重新生成跳一跳地块失败。', ); setJumpHopError(errorMessage); + setJumpHopQueueState(null); setJumpHopGenerationState( resolveFinishedMiniGameDraftGenerationState( generationState, @@ -7918,6 +8052,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleClearWork(null); setPuzzleClearRun(null); setPuzzleClearGenerationState(generationState); + setPuzzleClearQueueState(null); setIsPuzzleClearBusy(true); markDraftGenerating('puzzle-clear', [created.session.sessionId]); markPendingDraftGenerating('puzzle-clear', created.session.sessionId); @@ -7946,6 +8081,19 @@ export function PlatformEntryFlowShellImpl({ created.session.draft?.boardBackgroundAsset, }, ); + if (response.queueState && response.session.status === 'generating') { + setPuzzleClearQueueState(response.queueState); + setPuzzleClearSession(response.session); + setPuzzleClearWork(response.work ?? null); + writeCreationUrlState( + buildPuzzleClearCreationUrlState({ + session: response.session, + work: response.work, + }), + ); + return; + } + setPuzzleClearQueueState(null); setPuzzleClearSession(response.session); setPuzzleClearWork(response.work ?? null); writeCreationUrlState( @@ -7990,6 +8138,7 @@ export function PlatformEntryFlowShellImpl({ '生成拼消消草稿失败。', ); setPuzzleClearError(errorMessage); + setPuzzleClearQueueState(null); setPuzzleClearGenerationState( resolveFinishedMiniGameDraftGenerationState( generationState, @@ -8071,6 +8220,7 @@ export function PlatformEntryFlowShellImpl({ const generationState = createMiniGameDraftGenerationState('puzzle-clear'); setPuzzleClearError(null); setPuzzleClearGenerationState(generationState); + setPuzzleClearQueueState(null); setIsPuzzleClearBusy(true); selectionStageRef.current = 'puzzle-clear-generating'; setSelectionStage('puzzle-clear-generating'); @@ -8092,6 +8242,19 @@ export function PlatformEntryFlowShellImpl({ boardBackgroundAsset: puzzleClearSession.draft?.boardBackgroundAsset, }, ); + if (response.queueState && response.session.status === 'generating') { + setPuzzleClearQueueState(response.queueState); + setPuzzleClearSession(response.session); + setPuzzleClearWork(response.work ?? puzzleClearWork); + writeCreationUrlState( + buildPuzzleClearCreationUrlState({ + session: response.session, + work: response.work ?? puzzleClearWork, + }), + ); + return; + } + setPuzzleClearQueueState(null); setPuzzleClearSession(response.session); setPuzzleClearWork(response.work ?? puzzleClearWork); writeCreationUrlState( @@ -8110,6 +8273,7 @@ export function PlatformEntryFlowShellImpl({ '重新生成拼消消图集失败。', ); setPuzzleClearError(errorMessage); + setPuzzleClearQueueState(null); setPuzzleClearGenerationState( resolveFinishedMiniGameDraftGenerationState(generationState, 'failed', { error: errorMessage, @@ -8420,6 +8584,7 @@ export function PlatformEntryFlowShellImpl({ setWoodenFishWork(null); setWoodenFishRun(null); setWoodenFishGenerationState(generationState); + setWoodenFishQueueState(null); setIsWoodenFishBusy(true); setSelectionStage('wooden-fish-generating'); markDraftGenerating('wooden-fish', [created.session.sessionId]); @@ -8439,6 +8604,19 @@ export function PlatformEntryFlowShellImpl({ draft: created.session.draft, }), ); + if (response.queueState && response.session.status === 'generating') { + setWoodenFishQueueState(response.queueState); + setWoodenFishSession(response.session); + setWoodenFishWork(response.work ?? null); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ + session: response.session, + work: response.work, + }), + ); + return; + } + setWoodenFishQueueState(null); setWoodenFishSession(response.session); setWoodenFishWork(response.work ?? null); writeCreationUrlState( @@ -8483,6 +8661,7 @@ export function PlatformEntryFlowShellImpl({ '生成敲木鱼草稿失败。', ); setWoodenFishError(errorMessage); + setWoodenFishQueueState(null); setWoodenFishGenerationState( resolveFinishedMiniGameDraftGenerationState( generationState, @@ -8568,6 +8747,7 @@ export function PlatformEntryFlowShellImpl({ ); setWoodenFishError(null); setWoodenFishGenerationState(generationState); + setWoodenFishQueueState(null); setIsWoodenFishBusy(true); setSelectionStage('wooden-fish-generating'); try { @@ -8577,6 +8757,19 @@ export function PlatformEntryFlowShellImpl({ draft: woodenFishSession.draft, }), ); + if (response.queueState && response.session.status === 'generating') { + setWoodenFishQueueState(response.queueState); + setWoodenFishSession(response.session); + setWoodenFishWork(response.work ?? woodenFishWork); + writeCreationUrlState( + buildWoodenFishCreationUrlState({ + session: response.session, + work: response.work ?? woodenFishWork, + }), + ); + return; + } + setWoodenFishQueueState(null); setWoodenFishSession(response.session); setWoodenFishWork(response.work ?? woodenFishWork); writeCreationUrlState( @@ -8595,6 +8788,7 @@ export function PlatformEntryFlowShellImpl({ '重新生成敲击物图案失败。', ); setWoodenFishError(errorMessage); + setWoodenFishQueueState(null); setWoodenFishGenerationState( resolveFinishedMiniGameDraftGenerationState( generationState, @@ -15382,6 +15576,7 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="草稿生成中" pausedBadgeLabel="草稿生成已暂停" idleBadgeLabel="等待返回工作区" + queueStatus={jumpHopExternalGenerationQueueStatus} /> @@ -15526,6 +15721,7 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('match3d-agent-workspace'); }} onRetry={retryMatch3DDraftGeneration} + queueStatus={puzzleClearExternalGenerationQueueStatus} hideBatchModule /> @@ -15796,6 +15992,7 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="草稿生成中" pausedBadgeLabel="草稿生成已暂停" idleBadgeLabel="等待返回工作区" + queueStatus={woodenFishExternalGenerationQueueStatus} /> @@ -15996,6 +16193,7 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="图片生成中" pausedBadgeLabel="图片生成已暂停" idleBadgeLabel="等待返回结果页" + queueStatus={externalGenerationQueueStatus} /> @@ -16200,6 +16398,7 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('jump-hop-workspace'); }} onRetry={retryJumpHopDraftGeneration} + queueStatus={externalGenerationQueueStatus} /> @@ -16348,6 +16547,7 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="素材生成中" pausedBadgeLabel="素材生成已暂停" idleBadgeLabel="等待返回工作区" + queueStatus={externalGenerationQueueStatus} /> @@ -16477,6 +16677,7 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('wooden-fish-workspace'); }} onRetry={retryWoodenFishDraftGeneration} + queueStatus={externalGenerationQueueStatus} /> @@ -16670,6 +16871,7 @@ export function PlatformEntryFlowShellImpl({ setSelectionStage('puzzle-agent-workspace'); }} onRetry={retryPuzzleDraftGeneration} + queueStatus={puzzleExternalGenerationQueueStatus} hideBatchModule /> @@ -16796,6 +16998,7 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="草稿生成中" pausedBadgeLabel="草稿生成已暂停" idleBadgeLabel="等待返回工作区" + queueStatus={externalGenerationQueueStatus} /> @@ -17039,6 +17242,7 @@ export function PlatformEntryFlowShellImpl({ activeBadgeLabel="草稿编译中" pausedBadgeLabel="草稿生成已暂停" idleBadgeLabel="等待返回工作区" + queueStatus={externalGenerationQueueStatus} /> diff --git a/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts b/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts new file mode 100644 index 00000000..7238377b --- /dev/null +++ b/src/components/platform-entry/platformExternalGenerationQueueStatusModel.ts @@ -0,0 +1,21 @@ +import type { + ExternalGenerationJobStatusRecord, + ExternalGenerationQueueOverview, +} from '../../../packages/shared/src/contracts/externalGeneration'; +import type { ExternalGenerationQueueStatus } from '../CustomWorldGenerationView'; + +export function buildExternalGenerationQueueStatus( + overview: ExternalGenerationQueueOverview | null, + job: ExternalGenerationJobStatusRecord | null, +): ExternalGenerationQueueStatus | null { + if (!overview && !job) { + return null; + } + + return { + currentStatus: job?.status ?? null, + currentProgress: job?.progress ?? null, + pendingCount: overview?.pendingCount ?? null, + runningCount: overview?.runningCount ?? null, + }; +} diff --git a/src/components/unified-creation/UnifiedGenerationPage.test.tsx b/src/components/unified-creation/UnifiedGenerationPage.test.tsx index adbc94dc..c41c656d 100644 --- a/src/components/unified-creation/UnifiedGenerationPage.test.tsx +++ b/src/components/unified-creation/UnifiedGenerationPage.test.tsx @@ -70,4 +70,28 @@ describe('UnifiedGenerationPage', () => { expect(screen.queryByText('当前跳一跳信息')).toBeNull(); expect(screen.queryByText('云端糖果塔')).toBeNull(); }); + + test('显示外部生成队列状态', () => { + render( + {}} + onEditSetting={() => {}} + onRetry={() => {}} + />, + ); + + expect(screen.getByText('排队中 18%')).toBeTruthy(); + expect(screen.getByText('排队 6')).toBeTruthy(); + expect(screen.getByText('生成 2')).toBeTruthy(); + }); }); diff --git a/src/components/unified-creation/UnifiedGenerationPage.tsx b/src/components/unified-creation/UnifiedGenerationPage.tsx index b943f549..04ddf4f5 100644 --- a/src/components/unified-creation/UnifiedGenerationPage.tsx +++ b/src/components/unified-creation/UnifiedGenerationPage.tsx @@ -1,6 +1,9 @@ import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime'; import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress'; -import { CustomWorldGenerationView } from '../CustomWorldGenerationView'; +import { + CustomWorldGenerationView, + type ExternalGenerationQueueStatus, +} from '../CustomWorldGenerationView'; import type { UnifiedGenerationPlayId } from './unifiedGenerationCopy'; import { getUnifiedGenerationCopy } from './unifiedGenerationCopy'; @@ -15,6 +18,7 @@ type UnifiedGenerationPageProps = { onEditSetting: () => void; onRetry: () => void; hideBatchModule?: boolean; + queueStatus?: ExternalGenerationQueueStatus | null; }; export function UnifiedGenerationPage({ @@ -28,6 +32,7 @@ export function UnifiedGenerationPage({ onEditSetting, onRetry, hideBatchModule = false, + queueStatus = null, }: UnifiedGenerationPageProps) { const copy = getUnifiedGenerationCopy(playId); @@ -51,6 +56,7 @@ export function UnifiedGenerationPage({ pausedBadgeLabel="素材生成已暂停" idleBadgeLabel="等待返回工作区" hideBatchModule={hideBatchModule} + queueStatus={queueStatus} /> ); } diff --git a/src/services/external-generation/externalGenerationClient.ts b/src/services/external-generation/externalGenerationClient.ts new file mode 100644 index 00000000..f9187548 --- /dev/null +++ b/src/services/external-generation/externalGenerationClient.ts @@ -0,0 +1,49 @@ +import type { + ExternalGenerationJobStatusResponse, + ExternalGenerationQueueOverviewResponse, +} from '../../../packages/shared/src/contracts/externalGeneration'; +import { BACKGROUND_AUTH_REQUEST_OPTIONS, requestJson } from '../apiClient'; + +const EXTERNAL_GENERATION_API_BASE = '/api/runtime/external-generation'; +const EXTERNAL_GENERATION_READ_OPTIONS = { + ...BACKGROUND_AUTH_REQUEST_OPTIONS, + retry: { + maxRetries: 1, + baseDelayMs: 200, + maxDelayMs: 600, + }, +} as const; + +export async function getExternalGenerationQueueOverview( + signal?: AbortSignal, +) { + return requestJson( + `${EXTERNAL_GENERATION_API_BASE}/queue-overview`, + { + method: 'GET', + signal, + }, + '读取生成队列状态失败', + EXTERNAL_GENERATION_READ_OPTIONS, + ); +} + +export async function getExternalGenerationJobStatus( + jobId: string, + signal?: AbortSignal, +) { + return requestJson( + `${EXTERNAL_GENERATION_API_BASE}/jobs/${encodeURIComponent(jobId)}`, + { + method: 'GET', + signal, + }, + '读取生成任务状态失败', + EXTERNAL_GENERATION_READ_OPTIONS, + ); +} + +export const externalGenerationClient = { + getQueueOverview: getExternalGenerationQueueOverview, + getJobStatus: getExternalGenerationJobStatus, +}; diff --git a/src/services/external-generation/index.ts b/src/services/external-generation/index.ts new file mode 100644 index 00000000..83c70bad --- /dev/null +++ b/src/services/external-generation/index.ts @@ -0,0 +1,5 @@ +export { + externalGenerationClient, + getExternalGenerationJobStatus, + getExternalGenerationQueueOverview, +} from './externalGenerationClient';