扩展外部生成Worker队列

新增外部生成队列概览和单任务状态契约

将跳一跳、拼消消、敲木鱼图片生成动作接入worker队列

前端生成等待页展示当前任务和队列数量

更新外部生成worker运维文档和团队决策记录
This commit is contained in:
2026-06-12 23:15:55 +08:00
parent 3bccfd1a83
commit 951caac32d
43 changed files with 1913 additions and 67 deletions

View File

@@ -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 桌面面板 ## 2026-06-11 本地服务器管理入口采用 SSH alias + egui 桌面面板
- 背景release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。 - 背景release / dev 等服务器的日常巡检已有 systemd、健康巡检 timer 和 HTTP 探测口径,但开发者本地仍需要在多个 SSH alias 间手工切换命令并重复执行启停操作。

View File

@@ -1,6 +1,6 @@
# 外部生成 Worker 化方案 # 外部生成 Worker 化方案
更新时间:`2026-06-07` 更新时间:`2026-06-12`
## 背景 ## 背景
@@ -13,7 +13,9 @@
- 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。 - 多个 worker 进程通过 SpacetimeDB 任务表抢占任务,依赖 lease 超时恢复,支持按进程数和单进程并发动态缩扩容。
- 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。 - 本地或小流量同步排查可显式启用 `inline` 模式,由 HTTP handler 复用同一 worker executor 同步执行并返回 `completed`;该模式不创建队列任务,也不具备 worker 横向扩容能力。
- SpacetimeDB reducer / procedure 只做任务状态流转,不做网络、文件系统或外部 provider I/O。 - 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”。例如草稿编译、结果页单槽重生、图集重生都各自入一个 jobjob 内部可以串行或并行调用 provider、OSS、SpacetimeDB 写回,但不再拆成“提示词 / 生图 / 切图 / 去背景 / 持久化 / 回写”等阶段 job。阶段进度只作为 `request_payload_json` / 业务 session 的展示状态,不作为队列调度单位。
- 不调用外部图片 / 音频 / LLM provider 的动作继续 inline 执行,不为了统一排队而进入 `external_generation_job`
## Module 与 Interface ## Module 与 Interface
@@ -25,9 +27,19 @@
- `complete_external_generation_job_and_return`worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed` - `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` - `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_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。 这个 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` 新增私有表 `external_generation_job`
@@ -107,6 +119,8 @@ controller 配置:
## 已接入的拼图纵切 ## 已接入的拼图纵切
### 拼图
`compile_puzzle_draft` `compile_puzzle_draft`
1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。 1. HTTP handler 保存拼图表单草稿;`queue` 模式下 `queued/running` 的持久事实源是 `external_generation_job`,不把 HTTP 进程变成外部生成执行者。
@@ -129,7 +143,23 @@ controller 配置:
2. worker 执行原结果页 UI 背景链路归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。 2. worker 执行原结果页 UI 背景链路归一化提示词、VectorEngine 生成、OSS 持久化和 `save_puzzle_ui_background` 写回。
3. 成功后目标关卡写入 `uiBackgroundPrompt/uiBackgroundImageSrc/uiBackgroundImageObjectKey`;失败后复用 `mark_puzzle_level_generation_failed` 标记目标关卡 `failed`,并在失败态写回成功后才终结队列 job让前端轮询能收敛。 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-*` 请求只创建一个 jobworker 内部负责该动作所需的 provider 调用、素材处理、OSS 持久化、失败态写回和业务成功写回。非外部图片生成动作,例如纯元信息保存、标签编辑、发布、试玩启动、运行态动作、删除和公开 read model 读取,继续 inline 执行。
每个玩法迁移时必须同时接入业务写回 lease guardworker 路径带 `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:<api-port>/healthz curl -f http://127.0.0.1:<api-port>/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 与手动兜底示例: systemd 生产 controller 与手动兜底示例:

View File

@@ -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` 配置默认关闭该开关。 开发态 `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` 并重启。 本地只做账号/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不写入文档示例。 生产环境变量模板:`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` 控制单次 leaseworker 会约每三分之一 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` 控制单次 leaseworker 会约每三分之一 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 配置和被移动的默认站点。 `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 配置和被移动的默认站点。

View File

@@ -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;
}

View File

@@ -1,3 +1,5 @@
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge'; export type JumpHopDifficulty = 'easy' | 'standard' | 'advanced' | 'challenge';
export type JumpHopStylePreset = export type JumpHopStylePreset =
@@ -206,6 +208,7 @@ export interface JumpHopActionResponse {
actionType: JumpHopActionType; actionType: JumpHopActionType;
session: JumpHopSessionSnapshotResponse; session: JumpHopSessionSnapshotResponse;
work: JumpHopWorkProfileResponse | null; work: JumpHopWorkProfileResponse | null;
queueState?: ExternalGenerationJobStatusRecord | null;
} }
export interface JumpHopWorkSummaryResponse { export interface JumpHopWorkSummaryResponse {

View File

@@ -1,4 +1,5 @@
import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession'; import type { PuzzleAgentSessionSnapshot } from './puzzleAgentSession';
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type PuzzleAgentSuggestedActionType = export type PuzzleAgentSuggestedActionType =
| 'request_summary' | 'request_summary'
@@ -41,6 +42,7 @@ export interface PuzzleAgentOperationRecord {
phaseDetail: string; phaseDetail: string;
progress: number; progress: number;
error?: string | null; error?: string | null;
queueState?: ExternalGenerationJobStatusRecord | null;
} }
export type PuzzleAgentActionRequest = export type PuzzleAgentActionRequest =

View File

@@ -1,3 +1,5 @@
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type PuzzleClearGenerationStatus = 'draft' | 'generating' | 'ready' | 'failed'; export type PuzzleClearGenerationStatus = 'draft' | 'generating' | 'ready' | 'failed';
export type PuzzleClearShapeKind = '1x2' | '1x3' | '2x2' | '2x3'; export type PuzzleClearShapeKind = '1x2' | '1x3' | '2x2' | '2x3';
@@ -109,6 +111,7 @@ export interface PuzzleClearActionResponse {
actionType: PuzzleClearActionType; actionType: PuzzleClearActionType;
session: PuzzleClearSessionSnapshotResponse; session: PuzzleClearSessionSnapshotResponse;
work: PuzzleClearWorkProfileResponse | null; work: PuzzleClearWorkProfileResponse | null;
queueState?: ExternalGenerationJobStatusRecord | null;
} }
export interface PuzzleClearWorkSummaryResponse { export interface PuzzleClearWorkSummaryResponse {

View File

@@ -1,3 +1,5 @@
import type { ExternalGenerationJobStatusRecord } from './externalGeneration';
export type WoodenFishGenerationStatus = export type WoodenFishGenerationStatus =
| 'draft' | 'draft'
| 'generating' | 'generating'
@@ -104,6 +106,7 @@ export interface WoodenFishActionResponse {
actionType: WoodenFishActionType; actionType: WoodenFishActionType;
session: WoodenFishSessionSnapshotResponse; session: WoodenFishSessionSnapshotResponse;
work: WoodenFishWorkProfileResponse | null; work: WoodenFishWorkProfileResponse | null;
queueState?: ExternalGenerationJobStatusRecord | null;
} }
export interface WoodenFishWorkSummaryResponse { export interface WoodenFishWorkSummaryResponse {

View File

@@ -8,6 +8,7 @@ export type * from './contracts/creativeAgent';
export type * from './contracts/customWorldAgent'; export type * from './contracts/customWorldAgent';
export * from './contracts/edutainmentBabyDrawing'; export * from './contracts/edutainmentBabyDrawing';
export * from './contracts/edutainmentBabyObject'; export * from './contracts/edutainmentBabyObject';
export * from './contracts/externalGeneration';
export type * from './contracts/hyper3d'; export type * from './contracts/hyper3d';
export * from './contracts/match3dAgent'; export * from './contracts/match3dAgent';
export * from './contracts/match3dRuntime'; export * from './contracts/match3dRuntime';

View File

@@ -44,6 +44,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::profile::router(state.clone())) .merge(modules::profile::router(state.clone()))
.merge(modules::assets::router(state.clone())) .merge(modules::assets::router(state.clone()))
.merge(modules::platform::router(state.clone())) .merge(modules::platform::router(state.clone()))
.merge(modules::external_generation::router(state.clone()))
.merge(modules::play_flow::router(state.clone())) .merge(modules::play_flow::router(state.clone()))
.route( .route(
"/api/profile/recharge/wechat/notify", "/api/profile/recharge/wechat/notify",

View File

@@ -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<AppState>,
Extension(request_context): Extension<RequestContext>,
) -> Result<Json<serde_json::Value>, 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<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
Path(job_id): Path<String>,
) -> Result<Json<serde_json::Value>, 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))
}

View File

@@ -15,14 +15,26 @@ use tokio::{
use tracing::{error, info, warn}; use tracing::{error, info, warn};
use crate::{ use crate::{
jump_hop::{
JUMP_HOP_COMPILE_DRAFT_JOB_KIND, JumpHopCompileDraftWorkerPayload,
execute_jump_hop_compile_draft_worker_job,
},
puzzle::{ puzzle::{
ExternalGenerationWriteLeaseGuard, PuzzleCompileDraftWorkerPayload, ExternalGenerationWriteLeaseGuard, PuzzleCompileDraftWorkerPayload,
PuzzleGenerateImagesWorkerPayload, PuzzleGenerateUiBackgroundWorkerPayload, PuzzleGenerateImagesWorkerPayload, PuzzleGenerateUiBackgroundWorkerPayload,
execute_puzzle_compile_draft_worker_job, execute_puzzle_generate_images_worker_job, execute_puzzle_compile_draft_worker_job, execute_puzzle_generate_images_worker_job,
execute_puzzle_generate_ui_background_worker_job, release_puzzle_compile_background_claim, 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, request_context::RequestContext,
state::{AppState, PuzzleApiState}, 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"; 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::<JumpHopCompileDraftWorkerPayload>(
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::<PuzzleClearCompileDraftWorkerPayload>(
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::<WoodenFishGenerateImageAssetsWorkerPayload>(
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 => { unknown => {
warn!( warn!(
job_id = job.job_id, 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::<serde_json::Value>(&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( async fn fail_queue_job_after_worker_error(
state: &AppState, state: &AppState,
worker_id: &str, worker_id: &str,

View File

@@ -9,7 +9,11 @@ use module_assets::{
generate_asset_binding_id, generate_asset_object_id, generate_asset_binding_id, generate_asset_object_id,
}; };
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_contracts::jump_hop::{ use shared_contracts::jump_hop::{
JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse, JumpHopActionRequest, JumpHopActionType, JumpHopCharacterAsset, JumpHopDraftResponse,
JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopGalleryDetailResponse, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse,
@@ -20,7 +24,9 @@ use shared_contracts::jump_hop::{
JumpHopWorksResponse, JumpHopWorkspaceCreateRequest, JumpHopWorksResponse, JumpHopWorkspaceCreateRequest,
}; };
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError; use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
};
use std::{ use std::{
collections::BTreeMap, collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH}, time::{SystemTime, UNIX_EPOCH},
@@ -49,6 +55,7 @@ use crate::{
}; };
const JUMP_HOP_TILE_ITEM_COUNT: usize = 18; 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_PROVIDER: &str = "jump-hop";
const JUMP_HOP_CREATION_PROVIDER: &str = "jump-hop-creation"; 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_WIDTH: u32 = 1024;
const JUMP_HOP_BACK_BUTTON_IMAGE_HEIGHT: 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)] #[derive(Clone, Debug, PartialEq, Eq)]
struct JumpHopTileAtlasSlice { struct JumpHopTileAtlasSlice {
tile_type: JumpHopTileType, 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 owner_user_id = authenticated.claims().user_id().to_string();
let mut payload = payload; let mut payload = payload;
let is_compile_draft = matches!(payload.action_type, JumpHopActionType::CompileDraft); 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 { let generation_points_cost = if is_compile_draft {
resolve_jump_hop_generation_points_cost(&state).await resolve_jump_hop_generation_points_cost(&state).await
} else { } 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<ExternalGenerationJobRecord, Response> {
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<JumpHopSessionSnapshotResponse, Response> {
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 { async fn resolve_jump_hop_generation_points_cost(state: &AppState) -> u64 {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost( crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state, 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 y1 = (row.saturating_add(1)).saturating_mul(height) / JUMP_HOP_TILE_ATLAS_ROWS;
let tile_width = x1.saturating_sub(x0).max(1); let tile_width = x1.saturating_sub(x0).max(1);
let tile_height = y1.saturating_sub(y0).max(1); let tile_height = y1.saturating_sub(y0).max(1);
let faces = slice_jump_hop_tile_uv_faces( let faces =
&source, slice_jump_hop_tile_uv_faces(&source, x0, y0, tile_width, tile_height, row, col)?;
x0,
y0,
tile_width,
tile_height,
row,
col,
)?;
slices.push(JumpHopTileAtlasSlice { slices.push(JumpHopTileAtlasSlice {
tile_type: jump_hop_tile_type_by_index(index), tile_type: jump_hop_tile_type_by_index(index),
source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1), source_atlas_cell: format!("row-{}-col-{}", row + 1, col + 1),
@@ -1043,22 +1175,70 @@ fn slice_jump_hop_tile_uv_faces(
Ok(JumpHopTileFaceSlices { Ok(JumpHopTileFaceSlices {
top: slice_jump_hop_tile_uv_face( 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( 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( 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( 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( 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( 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 { Ok(JumpHopTileFaceSlice {
face, face,
source_atlas_cell: format!( source_atlas_cell: format!("row-{}-col-{}/{}", atlas_row + 1, atlas_col + 1, face_label),
"row-{}-col-{}/{}",
atlas_row + 1,
atlas_col + 1,
face_label
),
bytes: cursor.into_inner(), bytes: cursor.into_inner(),
}) })
} }
@@ -1827,7 +2002,9 @@ mod tests {
assert!(prompt.contains("18个用于跳一跳地板的立方体主题物体 UV 展开包装图")); assert!(prompt.contains("18个用于跳一跳地板的立方体主题物体 UV 展开包装图"));
assert!(prompt.contains("按三列六行均匀排布")); assert!(prompt.contains("按三列六行均匀排布"));
assert!(prompt.contains("每个大单元格代表一个完整的 1x1x1 立方体方块物体")); 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("cube object UV unwrap atlas / 立方体主题物体六面展开图集"));
assert!(prompt.contains("不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满")); assert!(prompt.contains("不是单纯平铺材质、不是抽象纹理、不是只把主题颜色铺满"));
assert!(prompt.contains("游戏界面或图标集页面")); assert!(prompt.contains("游戏界面或图标集页面"));
@@ -1850,7 +2027,9 @@ mod tests {
assert!(prompt.contains("full-bleed opaque square face texture")); 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("不要把一个小水果、小叶片、小石头或小物体放在面中央")); 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("小贴纸图标、小物体居中、纯果皮材质、纯果肉纹理"));
assert!(prompt.contains("English guardrail")); assert!(prompt.contains("English guardrail"));
assert!(prompt.contains("one vertical 1024x1536 image")); 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("row1 col2 top"));
assert!(prompt.contains("row2 col1 left")); assert!(prompt.contains("row2 col1 left"));
assert!(prompt.contains("row2 col2 front")); assert!(prompt.contains("row2 col2 front"));
assert!(prompt.contains("row2 col3 right")); assert!(prompt.contains("row2 col3 right"));
assert!(prompt.contains("row2 col4 back")); assert!(prompt.contains("row2 col4 back"));
assert!(prompt.contains("row3 col2 bottom")); 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 generic flat material"));
assert!(prompt.contains("no small centered stickers")); assert!(prompt.contains("no small centered stickers"));
assert!(prompt.contains("every face is full-bleed opaque square texture")); 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("画面内容是科幻芯片主题的俯视角清爽游戏化立体感平台素材"));
assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角")); assert!(!prompt.contains("画面内容是科幻芯片主题的俯视角"));
@@ -2118,12 +2303,10 @@ mod tests {
.max(1); .max(1);
let tile_x = atlas_col.saturating_mul(cell_width); let tile_x = atlas_col.saturating_mul(cell_width);
let tile_y = atlas_row.saturating_mul(cell_height); let tile_y = atlas_row.saturating_mul(cell_height);
let uv_x = tile_x.saturating_add( let uv_x = tile_x
cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2, .saturating_add(cell_width.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_COLS) / 2);
); let uv_y = tile_y
let uv_y = tile_y.saturating_add( .saturating_add(cell_height.saturating_sub(face_side * JUMP_HOP_TILE_UV_FACE_ROWS) / 2);
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 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 { for x in uv_x + face_col * face_side..uv_x + (face_col + 1) * face_side {
atlas.put_pixel(x, y, color); atlas.put_pixel(x, y, color);
@@ -2159,14 +2342,8 @@ mod tests {
), ),
"{message}" "{message}"
); );
assert!( assert!(decoded.pixels().any(|pixel| pixel.0 == color), "{message}");
decoded.pixels().any(|pixel| pixel.0 == color), assert!(decoded.pixels().all(|pixel| pixel.0[3] == 255), "{message}");
"{message}"
);
assert!(
decoded.pixels().all(|pixel| pixel.0[3] == 255),
"{message}"
);
} }
#[test] #[test]

View File

@@ -40,6 +40,7 @@ mod edutainment_baby_drawing;
mod edutainment_baby_object; mod edutainment_baby_object;
mod error_middleware; mod error_middleware;
mod external_api_audit; mod external_api_audit;
mod external_generation;
mod external_generation_worker; mod external_generation_worker;
mod external_generation_worker_controller; mod external_generation_worker_controller;
pub(crate) mod generated_asset_sheets; pub(crate) mod generated_asset_sheets;

View File

@@ -5,6 +5,7 @@ pub mod bark_battle;
pub mod big_fish; pub mod big_fish;
pub mod custom_world; pub mod custom_world;
pub mod edutainment; pub mod edutainment;
pub mod external_generation;
pub mod health; pub mod health;
pub mod internal; pub mod internal;
pub mod jump_hop; pub mod jump_hop;

View File

@@ -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<AppState> {
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,
)),
)
}

View File

@@ -11,7 +11,11 @@ use module_assets::{
generate_asset_binding_id, generate_asset_object_id, generate_asset_binding_id, generate_asset_object_id,
}; };
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_contracts::puzzle_clear::{ use shared_contracts::puzzle_clear::{
PuzzleClearActionRequest, PuzzleClearActionType, PuzzleClearCardAsset, PuzzleClearActionRequest, PuzzleClearActionType, PuzzleClearCardAsset,
PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset, PuzzleClearDraftResponse, PuzzleClearGenerationStatus, PuzzleClearImageAsset,
@@ -22,7 +26,9 @@ use shared_contracts::puzzle_clear::{
PuzzleClearWorkspaceCreateRequest, PuzzleClearWorkspaceCreateRequest,
}; };
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros};
use spacetime_client::SpacetimeClientError; use spacetime_client::{
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobRecord, SpacetimeClientError,
};
use std::{ use std::{
collections::BTreeMap, collections::BTreeMap,
time::{SystemTime, UNIX_EPOCH}, 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_RUNTIME_PROVIDER: &str = "puzzle-clear-runtime";
const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear"; const PUZZLE_CLEAR_TEMPLATE_ID: &str = "puzzle-clear";
const PUZZLE_CLEAR_TEMPLATE_NAME: &str = "拼消消"; 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_RUNTIME_RUNS_ROUTE: &str = "/api/runtime/puzzle-clear/runs";
const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256; const PUZZLE_CLEAR_ATLAS_CELL_SIZE: u32 = 256;
const PUZZLE_CLEAR_SHEET_COLUMNS: u32 = 4; 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_SHEET_INTERNAL_SEAM_SIDE_TEXTURE_MAX: f32 = 36.0;
const PUZZLE_CLEAR_ATLAS_NEGATIVE_PROMPT: &str = "文字、Logo、水印、按钮、UI 字、网格线、编号、标签、边框、外轮廓框、白色描边、白色贴纸边、圆角框、阴影框、分隔线、裁切参考线、单格内部拼接线、内部竖切、内部横切、照片拼贴、相册拼贴、多场景拼贴、双联图、三联图、画中画、单格双图、单格多图、低清晰度、纯色背景、空白背景、白底商品图、孤立主体、单体素材、素材表、图标、贴纸、同品种重复、同一物体多角度、重复同款小图、主体跨格、主体贴边、拼贴、重影、不同图案互相穿插"; 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( pub async fn create_puzzle_clear_session(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -160,6 +176,39 @@ pub async fn execute_puzzle_clear_action(
.unwrap_or("拼消消玩家") .unwrap_or("拼消消玩家")
.to_string(); .to_string();
let mut payload = payload; 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( if let Err(response) = maybe_prepare_puzzle_clear_assets_inner(
&state, &state,
&request_context, &request_context,
@@ -210,6 +259,129 @@ pub async fn execute_puzzle_clear_action(
Ok(json_success_body(Some(&request_context), response)) 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<ExternalGenerationJobRecord, Response> {
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<PuzzleClearSessionSnapshotResponse, Response> {
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( pub async fn list_puzzle_clear_works(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,

View File

@@ -14,7 +14,11 @@ use module_assets::{
build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id,
}; };
use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess}; use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::external_generation::{
ExternalGenerationJobStatus, ExternalGenerationJobStatusRecord,
};
use shared_contracts::wooden_fish::{ use shared_contracts::wooden_fish::{
WoodenFishActionRequest, WoodenFishAudioAsset, WoodenFishCheckpointRunRequest, WoodenFishActionRequest, WoodenFishAudioAsset, WoodenFishCheckpointRunRequest,
WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse, WoodenFishDraftResponse, WoodenFishFinishRunRequest, WoodenFishGalleryDetailResponse,
@@ -24,7 +28,9 @@ use shared_contracts::wooden_fish::{
WoodenFishWorkspaceCreateRequest, WoodenFishWorkspaceCreateRequest,
}; };
use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; 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::{ use crate::generated_image_assets::{
GeneratedImageAssetAdapter, GeneratedImageAssetDataUrl, 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_RUNTIME_PROVIDER: &str = "wooden-fish-runtime";
const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish"; const WOODEN_FISH_TEMPLATE_ID: &str = "wooden-fish";
const WOODEN_FISH_TEMPLATE_NAME: &str = "敲木鱼"; 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_PROMPT: &str = "默认敲击物图案,圆润木质质感,透明背景";
const DEFAULT_HIT_OBJECT_ASSET_ID: &str = "wooden-fish-default-hit-object"; 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"; 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 = "玩家"; 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( pub async fn create_wooden_fish_session(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -155,6 +172,40 @@ pub async fn execute_wooden_fish_action(
payload.action_type, payload.action_type,
shared_contracts::wooden_fish::WoodenFishActionType::CompileDraft 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 { let generation_points_cost = if is_compile_draft {
resolve_wooden_fish_generation_points_cost(&state).await resolve_wooden_fish_generation_points_cost(&state).await
} else { } else {
@@ -226,6 +277,70 @@ pub async fn execute_wooden_fish_action(
Ok(json_success_body(Some(&request_context), response)) 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<ExternalGenerationJobRecord, Response> {
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( pub async fn publish_wooden_fish_work(
State(state): State<AppState>, State(state): State<AppState>,
Path(profile_id): Path<String>, Path(profile_id): Path<String>,
@@ -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<WoodenFishSessionSnapshotResponse, Response> {
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 { async fn resolve_wooden_fish_generation_points_cost(state: &AppState) -> u64 {
crate::creation_entry_config::resolve_creation_entry_mud_point_cost( crate::creation_entry_config::resolve_creation_entry_mud_point_cost(
state, state,

View File

@@ -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<String>,
pub updated_at_micros: i64,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalGenerationJobStatusResponse {
pub job: ExternalGenerationJobStatusRecord,
}

View File

@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::external_generation::ExternalGenerationJobStatusRecord;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum JumpHopDifficulty { pub enum JumpHopDifficulty {
@@ -311,6 +313,8 @@ pub struct JumpHopActionResponse {
pub session: JumpHopSessionSnapshotResponse, pub session: JumpHopSessionSnapshotResponse,
#[serde(default)] #[serde(default)]
pub work: Option<JumpHopWorkProfileResponse>, pub work: Option<JumpHopWorkProfileResponse>,
#[serde(default)]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]

View File

@@ -11,6 +11,7 @@ pub mod creation_agent_document_input;
pub mod creation_audio; pub mod creation_audio;
pub mod creation_entry_config; pub mod creation_entry_config;
pub mod creative_agent; pub mod creative_agent;
pub mod external_generation;
pub mod hyper3d; pub mod hyper3d;
pub mod jump_hop; pub mod jump_hop;
pub mod llm; pub mod llm;

View File

@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::external_generation::ExternalGenerationJobStatusRecord;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PuzzleClearGenerationStatus { pub enum PuzzleClearGenerationStatus {
@@ -141,6 +143,8 @@ pub struct PuzzleClearActionResponse {
pub action_type: PuzzleClearActionType, pub action_type: PuzzleClearActionType,
pub session: PuzzleClearSessionSnapshotResponse, pub session: PuzzleClearSessionSnapshotResponse,
pub work: Option<PuzzleClearWorkProfileResponse>, pub work: Option<PuzzleClearWorkProfileResponse>,
#[serde(default)]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
} }
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]

View File

@@ -1,5 +1,7 @@
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::external_generation::ExternalGenerationJobStatusRecord;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "kebab-case")]
pub enum WoodenFishGenerationStatus { pub enum WoodenFishGenerationStatus {
@@ -164,6 +166,8 @@ pub struct WoodenFishActionResponse {
pub session: WoodenFishSessionSnapshotResponse, pub session: WoodenFishSessionSnapshotResponse,
#[serde(default)] #[serde(default)]
pub work: Option<WoodenFishWorkProfileResponse>, pub work: Option<WoodenFishWorkProfileResponse>,
#[serde(default)]
pub queue_state: Option<ExternalGenerationJobStatusRecord>,
} }
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]

View File

@@ -127,6 +127,31 @@ impl SpacetimeClient {
.await .await
} }
pub async fn get_external_generation_job(
&self,
input: ExternalGenerationJobGetRecordInput,
) -> Result<ExternalGenerationJobRecord, SpacetimeClientError> {
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( pub async fn get_external_generation_queue_stats(
&self, &self,
) -> Result<ExternalGenerationQueueStatsRecord, SpacetimeClientError> { ) -> Result<ExternalGenerationQueueStatsRecord, SpacetimeClientError> {

View File

@@ -113,6 +113,55 @@ impl SpacetimeClient {
action_type: payload.action_type, action_type: payload.action_type,
session, session,
work, work,
queue_state: None,
})
}
pub async fn mark_jump_hop_generation_queued(
&self,
session_id: String,
owner_user_id: String,
payload: JumpHopActionRequest,
) -> Result<JumpHopActionResponse, SpacetimeClientError> {
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(
&current,
&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<JumpHopDraftCompileInput, SpacetimeClientError> {
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( fn build_update_input(
owner_user_id: &str, owner_user_id: &str,
profile_id: &str, profile_id: &str,

View File

@@ -32,14 +32,14 @@ pub use mapper::{
CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord,
CustomWorldWorkSummaryRecord, ExternalGenerationJobClaimRecordInput, CustomWorldWorkSummaryRecord, ExternalGenerationJobClaimRecordInput,
ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobEnqueueRecordInput,
ExternalGenerationJobFailRecordInput, ExternalGenerationJobRecord, ExternalGenerationJobFailRecordInput, ExternalGenerationJobGetRecordInput,
ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord, ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput,
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, ExternalGenerationQueueStatsRecord, JumpHopActionRequest, JumpHopActionResponse,
JumpHopDifficulty, JumpHopDraftResponse, JumpHopGalleryCardResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopDifficulty, JumpHopDraftResponse,
JumpHopGalleryDetailResponse, JumpHopGalleryResponse, JumpHopGenerationStatus, JumpHopGalleryCardResponse, JumpHopGalleryDetailResponse, JumpHopGalleryResponse,
JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult, JumpHopLastJump, JumpHopPath, JumpHopGenerationStatus, JumpHopJumpRequest, JumpHopJumpResponse, JumpHopJumpResult,
JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse, JumpHopRunStatus, JumpHopLastJump, JumpHopPath, JumpHopPlatform, JumpHopRestartRunRequest, JumpHopRunResponse,
JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse, JumpHopRunStatus, JumpHopRuntimeRunSnapshotResponse, JumpHopScoring, JumpHopSessionResponse,
JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset, JumpHopSessionSnapshotResponse, JumpHopStartRunRequest, JumpHopStylePreset, JumpHopTileAsset,
JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse, JumpHopTileType, JumpHopWorkDetailResponse, JumpHopWorkMutationResponse,
JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse, JumpHopWorkProfileResponse, JumpHopWorkSummaryResponse, JumpHopWorksResponse,

View File

@@ -72,8 +72,8 @@ pub use self::common::{
pub use self::external_generation::{ pub use self::external_generation::{
ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput, ExternalGenerationJobClaimRecordInput, ExternalGenerationJobCompleteRecordInput,
ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput, ExternalGenerationJobEnqueueRecordInput, ExternalGenerationJobFailRecordInput,
ExternalGenerationJobRecord, ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationJobGetRecordInput, ExternalGenerationJobRecord,
ExternalGenerationQueueStatsRecord, ExternalGenerationJobRenewLeaseRecordInput, ExternalGenerationQueueStatsRecord,
}; };
pub use self::jump_hop::{ pub use self::jump_hop::{
JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset, JumpHopActionRequest, JumpHopActionResponse, JumpHopActionType, JumpHopCharacterAsset,

View File

@@ -66,6 +66,15 @@ impl From<ExternalGenerationJobFailRecordInput> for ExternalGenerationJobFailInp
} }
} }
impl From<ExternalGenerationJobGetRecordInput> 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( pub(crate) fn map_external_generation_job_procedure_result(
result: ExternalGenerationJobProcedureResult, result: ExternalGenerationJobProcedureResult,
) -> Result<ExternalGenerationJobRecord, SpacetimeClientError> { ) -> Result<ExternalGenerationJobRecord, SpacetimeClientError> {
@@ -144,6 +153,7 @@ fn map_external_generation_job_snapshot(
started_at: snapshot.started_at_micros.map(format_timestamp_micros), started_at: snapshot.started_at_micros.map(format_timestamp_micros),
completed_at: snapshot.completed_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: format_timestamp_micros(snapshot.updated_at_micros),
updated_at_micros: snapshot.updated_at_micros,
lease_token: snapshot.lease_token, lease_token: snapshot.lease_token,
} }
} }
@@ -199,6 +209,12 @@ pub struct ExternalGenerationJobFailRecordInput {
pub failed_at_micros: i64, 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)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ExternalGenerationJobRecord { pub struct ExternalGenerationJobRecord {
pub job_id: String, pub job_id: String,
@@ -221,6 +237,7 @@ pub struct ExternalGenerationJobRecord {
pub started_at: Option<String>, pub started_at: Option<String>,
pub completed_at: Option<String>, pub completed_at: Option<String>,
pub updated_at: String, pub updated_at: String,
pub updated_at_micros: i64,
pub lease_token: Option<String>, pub lease_token: Option<String>,
} }

View File

@@ -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_complete_input_type;
pub mod external_generation_job_enqueue_input_type; pub mod external_generation_job_enqueue_input_type;
pub mod external_generation_job_fail_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_procedure_result_type;
pub mod external_generation_job_renew_lease_input_type; pub mod external_generation_job_renew_lease_input_type;
pub mod external_generation_job_snapshot_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_by_code_procedure;
pub mod get_custom_world_gallery_detail_procedure; pub mod get_custom_world_gallery_detail_procedure;
pub mod get_custom_world_library_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_external_generation_queue_stats_and_return_procedure;
pub mod get_jump_hop_agent_session_procedure; pub mod get_jump_hop_agent_session_procedure;
pub mod get_jump_hop_leaderboard_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_complete_input_type::ExternalGenerationJobCompleteInput;
pub use external_generation_job_enqueue_input_type::ExternalGenerationJobEnqueueInput; pub use external_generation_job_enqueue_input_type::ExternalGenerationJobEnqueueInput;
pub use external_generation_job_fail_input_type::ExternalGenerationJobFailInput; 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_procedure_result_type::ExternalGenerationJobProcedureResult;
pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput; pub use external_generation_job_renew_lease_input_type::ExternalGenerationJobRenewLeaseInput;
pub use external_generation_job_snapshot_type::ExternalGenerationJobSnapshot; 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_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_gallery_detail_procedure::get_custom_world_gallery_detail;
pub use get_custom_world_library_detail_procedure::get_custom_world_library_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_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_agent_session_procedure::get_jump_hop_agent_session;
pub use get_jump_hop_leaderboard_procedure::get_jump_hop_leaderboard; pub use get_jump_hop_leaderboard_procedure::get_jump_hop_leaderboard;

View File

@@ -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;
}

View File

@@ -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<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + 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<ExternalGenerationJobProcedureResult, __sdk::InternalError>,
) + Send
+ 'static,
) {
self.imp
.invoke_procedure_with_callback::<_, ExternalGenerationJobProcedureResult>(
"get_external_generation_job_and_return",
GetExternalGenerationJobAndReturnArgs { input },
__callback,
);
}
}

View File

@@ -124,6 +124,51 @@ impl SpacetimeClient {
action_type: payload.action_type, action_type: payload.action_type,
session, session,
work, 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<PuzzleClearActionResponse, SpacetimeClientError> {
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(
&current,
&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<PuzzleClearDraftCompileInput, SpacetimeClientError> {
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( fn build_failed_compile_input(
current: &PuzzleClearSessionSnapshotResponse, current: &PuzzleClearSessionSnapshotResponse,
owner_user_id: &str, owner_user_id: &str,

View File

@@ -119,6 +119,53 @@ impl SpacetimeClient {
action_type: payload.action_type, action_type: payload.action_type,
session, session,
work, 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<WoodenFishActionResponse, SpacetimeClientError> {
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(
&current,
&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<WoodenFishDraftCompileInput, SpacetimeClientError> {
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( fn build_failed_compile_input(
current: &WoodenFishSessionSnapshotResponse, current: &WoodenFishSessionSnapshotResponse,
owner_user_id: &str, owner_user_id: &str,

View File

@@ -104,6 +104,12 @@ pub struct ExternalGenerationJobFailInput {
pub failed_at_micros: i64, 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)] #[derive(Clone, Debug, PartialEq, Eq, SpacetimeType)]
pub struct ExternalGenerationJobSnapshot { pub struct ExternalGenerationJobSnapshot {
pub job_id: String, 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] #[spacetimedb::procedure]
pub fn get_external_generation_queue_stats_and_return( pub fn get_external_generation_queue_stats_and_return(
ctx: &mut ProcedureContext, ctx: &mut ProcedureContext,
@@ -405,6 +422,28 @@ fn complete_external_generation_job_tx(
Ok(map_external_generation_job_row(row)) Ok(map_external_generation_job_row(row))
} }
fn get_external_generation_job_tx(
ctx: &ReducerContext,
input: ExternalGenerationJobGetInput,
) -> Result<ExternalGenerationJobSnapshot, String> {
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( fn renew_external_generation_job_lease_tx(
ctx: &ReducerContext, ctx: &ReducerContext,
input: ExternalGenerationJobRenewLeaseInput, input: ExternalGenerationJobRenewLeaseInput,

View File

@@ -345,6 +345,9 @@ fn compile_puzzle_clear_draft_tx(
if input.generation_status.as_deref() == Some(PUZZLE_CLEAR_GENERATION_FAILED) { if input.generation_status.as_deref() == Some(PUZZLE_CLEAR_GENERATION_FAILED) {
return mark_puzzle_clear_generation_failed_tx(ctx, input, session); 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<PuzzleClearPatternGroupSnapshot> = input let pattern_groups: Vec<PuzzleClearPatternGroupSnapshot> = input
.pattern_groups_json .pattern_groups_json
.as_deref() .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<PuzzleClearAgentSessionSnapshot, String> {
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::<PuzzleClearDraftSnapshot>(&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( fn mark_puzzle_clear_generation_failed_tx(
ctx: &ReducerContext, ctx: &ReducerContext,
input: PuzzleClearDraftCompileInput, input: PuzzleClearDraftCompileInput,

View File

@@ -31,8 +31,16 @@ interface CustomWorldGenerationViewProps {
idleBadgeLabel?: string; idleBadgeLabel?: string;
structuredEmptyText?: string; structuredEmptyText?: string;
hideBatchModule?: boolean; 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) { function formatDuration(ms: number) {
const safeMs = Math.max(0, Math.round(ms)); const safeMs = Math.max(0, Math.round(ms));
const totalSeconds = Math.ceil(safeMs / 1000); const totalSeconds = Math.ceil(safeMs / 1000);
@@ -85,6 +93,49 @@ function getStepStatusLabel(step: { status: string }) {
return '待处理'; 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( function resolveCurrentGenerationStep(
progress: CustomWorldGenerationProgress | null, progress: CustomWorldGenerationProgress | null,
) { ) {
@@ -111,6 +162,7 @@ export function CustomWorldGenerationView({
activeBadgeLabel = '世界建设中', activeBadgeLabel = '世界建设中',
idleBadgeLabel = '等待操作', idleBadgeLabel = '等待操作',
hideBatchModule = false, hideBatchModule = false,
queueStatus = null,
}: CustomWorldGenerationViewProps) { }: CustomWorldGenerationViewProps) {
void hideBatchModule; void hideBatchModule;
const progressValue = getProgressPercentage(progress); const progressValue = getProgressPercentage(progress);
@@ -131,6 +183,11 @@ export function CustomWorldGenerationView({
: '校准中'; : '校准中';
const elapsedText = const elapsedText =
progress != null ? formatDuration(progress.elapsedMs) : '启动中'; progress != null ? formatDuration(progress.elapsedMs) : '启动中';
const queueStatusLabel = resolveQueueStatusLabel(
queueStatus?.currentStatus ?? null,
);
const queueProgressText = formatQueueProgress(queueStatus?.currentProgress);
const shouldShowQueueStatus = hasQueueStatus(queueStatus);
return ( return (
<div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5"> <div className="relative isolate z-[1] -mx-3 -my-3 flex h-[calc(100%+1.5rem)] min-h-0 flex-col overflow-hidden bg-transparent px-4 pb-[max(1.25rem,env(safe-area-inset-bottom))] pt-4 text-[#3d1f10] sm:mx-0 sm:my-0 sm:h-full sm:rounded-[2rem] sm:px-5 sm:pt-5">
@@ -167,6 +224,21 @@ export function CustomWorldGenerationView({
/> />
</div> </div>
{shouldShowQueueStatus ? (
<div className="mt-3 flex flex-wrap items-center gap-2 rounded-[1.25rem] border border-white/70 bg-white/72 px-3 py-2 text-xs font-semibold text-[#6b3a1d] shadow-[0_14px_34px_rgba(121,70,33,0.10)] backdrop-blur-md sm:px-4">
{queueStatusLabel ? (
<span className="rounded-full bg-[#fff4dc] px-2.5 py-1 text-[#8a4c1e]">
{queueProgressText
? `${queueStatusLabel} ${queueProgressText}`
: queueStatusLabel}
</span>
) : null}
<span> {formatQueueCount(queueStatus?.pendingCount)}</span>
<span className="h-1 w-1 rounded-full bg-[#d4a15d]" />
<span> {formatQueueCount(queueStatus?.runningCount)}</span>
</div>
) : null}
<div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end"> <div className="mt-4 flex flex-col gap-3 sm:flex-row sm:flex-wrap sm:justify-end">
{!isGenerating ? ( {!isGenerating ? (
<PlatformActionButton <PlatformActionButton

View File

@@ -1,10 +1,11 @@
import { describe, expect, test } from 'vitest'; import { describe, expect, test } from 'vitest';
import {
resolveMiniGameGenerationViewBusy,
resolveMiniGameGenerationProgressTickState,
} from './PlatformEntryFlowShellImpl';
import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress'; import { createMiniGameDraftGenerationState } from '../../services/miniGameDraftGenerationProgress';
import {
resolveMiniGameGenerationProgressTickState,
resolveMiniGameGenerationViewBusy,
} from './PlatformEntryFlowShellImpl';
import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel';
import { resolveFinishedMiniGameDraftGenerationState } from './platformMiniGameDraftGenerationStateModel'; import { resolveFinishedMiniGameDraftGenerationState } from './platformMiniGameDraftGenerationStateModel';
describe('resolveMiniGameGenerationProgressTickState', () => { describe('resolveMiniGameGenerationProgressTickState', () => {
@@ -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();
});
});

View File

@@ -37,6 +37,10 @@ import type {
BabyObjectMatchDraft, BabyObjectMatchDraft,
CreateBabyObjectMatchDraftRequest, CreateBabyObjectMatchDraftRequest,
} from '../../../packages/shared/src/contracts/edutainmentBabyObject'; } from '../../../packages/shared/src/contracts/edutainmentBabyObject';
import type {
ExternalGenerationJobStatusRecord,
ExternalGenerationQueueOverview,
} from '../../../packages/shared/src/contracts/externalGeneration';
import type { import type {
JumpHopJumpRequest, JumpHopJumpRequest,
JumpHopWorkSummaryResponse, JumpHopWorkSummaryResponse,
@@ -172,6 +176,7 @@ import {
streamCreativeAgentMessage, streamCreativeAgentMessage,
streamCreativeDraftEdit, streamCreativeDraftEdit,
} from '../../services/creative-agent'; } from '../../services/creative-agent';
import { getExternalGenerationQueueOverview } from '../../services/external-generation';
import { import {
readCustomWorldAgentUiState, readCustomWorldAgentUiState,
shouldRestoreCustomWorldAgentUiState, shouldRestoreCustomWorldAgentUiState,
@@ -454,6 +459,7 @@ import {
resolveWoodenFishCreationUrlRestoreStage, resolveWoodenFishCreationUrlRestoreStage,
} from './platformCreationUrlStateModel'; } from './platformCreationUrlStateModel';
import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow'; import { resolvePlatformCreationWorkDeleteConfirmationModel } from './platformCreationWorkDeleteFlow';
import { buildExternalGenerationQueueStatus } from './platformExternalGenerationQueueStatusModel';
import { import {
buildPlatformErrorDialogDismissKey, buildPlatformErrorDialogDismissKey,
buildPlatformTaskCompletionDialogDismissKey, buildPlatformTaskCompletionDialogDismissKey,
@@ -717,6 +723,20 @@ export function resolveMiniGameGenerationViewBusy(
return isBusy || isMiniGameDraftGenerating(state ?? null); 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 = { type PuzzleDetailReturnTarget = {
tab: PlatformHomeTab; tab: PlatformHomeTab;
}; };
@@ -1747,9 +1767,17 @@ export function PlatformEntryFlowShellImpl({
const [isStartingRecommendEntry, setIsStartingRecommendEntry] = const [isStartingRecommendEntry, setIsStartingRecommendEntry] =
useState(false); useState(false);
const recommendRuntimeStartRequestRef = useRef(0); const recommendRuntimeStartRequestRef = useRef(0);
const [, setPuzzleOperation] = useState<PuzzleAgentOperationRecord | null>( const [puzzleOperation, setPuzzleOperation] = useState<PuzzleAgentOperationRecord | null>(
null, null,
); );
const [externalGenerationQueueOverview, setExternalGenerationQueueOverview] =
useState<ExternalGenerationQueueOverview | null>(null);
const [jumpHopQueueState, setJumpHopQueueState] =
useState<ExternalGenerationJobStatusRecord | null>(null);
const [puzzleClearQueueState, setPuzzleClearQueueState] =
useState<ExternalGenerationJobStatusRecord | null>(null);
const [woodenFishQueueState, setWoodenFishQueueState] =
useState<ExternalGenerationJobStatusRecord | null>(null);
const [puzzleWorks, setPuzzleWorks] = useState<PuzzleWorkSummary[]>([]); const [puzzleWorks, setPuzzleWorks] = useState<PuzzleWorkSummary[]>([]);
const [puzzleGalleryEntries, setPuzzleGalleryEntries] = useState< const [puzzleGalleryEntries, setPuzzleGalleryEntries] = useState<
PuzzleWorkSummary[] PuzzleWorkSummary[]
@@ -4753,6 +4781,82 @@ export function PlatformEntryFlowShellImpl({
isWoodenFishBusy, isWoodenFishBusy,
woodenFishGenerationState, 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( const platformBootstrapErrorForDisplay = isCreationEntryDisabledErrorMessage(
platformBootstrap.platformError, platformBootstrap.platformError,
) )
@@ -7469,6 +7573,7 @@ export function PlatformEntryFlowShellImpl({
setJumpHopRun(null); setJumpHopRun(null);
setJumpHopRuntimeRequestOptions(null); setJumpHopRuntimeRequestOptions(null);
setJumpHopGenerationState(generationState); setJumpHopGenerationState(generationState);
setJumpHopQueueState(null);
setIsJumpHopBusy(true); setIsJumpHopBusy(true);
setSelectionStage('jump-hop-generating'); setSelectionStage('jump-hop-generating');
markDraftGenerating('jump-hop', [ markDraftGenerating('jump-hop', [
@@ -7485,6 +7590,19 @@ export function PlatformEntryFlowShellImpl({
draft: created.session.draft, 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); const readyState = createReadyJumpHopGenerationState(generationState);
setJumpHopSession(response.session); setJumpHopSession(response.session);
setJumpHopWork(response.work ?? null); setJumpHopWork(response.work ?? null);
@@ -7521,6 +7639,7 @@ export function PlatformEntryFlowShellImpl({
'生成跳一跳草稿失败。', '生成跳一跳草稿失败。',
); );
setJumpHopError(errorMessage); setJumpHopError(errorMessage);
setJumpHopQueueState(null);
setJumpHopGenerationState( setJumpHopGenerationState(
resolveFinishedMiniGameDraftGenerationState( resolveFinishedMiniGameDraftGenerationState(
generationState, generationState,
@@ -7590,6 +7709,7 @@ export function PlatformEntryFlowShellImpl({
const generationState = createMiniGameDraftGenerationState('jump-hop'); const generationState = createMiniGameDraftGenerationState('jump-hop');
setJumpHopError(null); setJumpHopError(null);
setJumpHopGenerationState(generationState); setJumpHopGenerationState(generationState);
setJumpHopQueueState(null);
setIsJumpHopBusy(true); setIsJumpHopBusy(true);
setSelectionStage('jump-hop-generating'); setSelectionStage('jump-hop-generating');
try { try {
@@ -7599,6 +7719,19 @@ export function PlatformEntryFlowShellImpl({
draft: jumpHopSession.draft, 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); setJumpHopSession(response.session);
setJumpHopWork(response.work ?? jumpHopWork); setJumpHopWork(response.work ?? jumpHopWork);
writeCreationUrlState( writeCreationUrlState(
@@ -7617,6 +7750,7 @@ export function PlatformEntryFlowShellImpl({
'重新生成跳一跳地块失败。', '重新生成跳一跳地块失败。',
); );
setJumpHopError(errorMessage); setJumpHopError(errorMessage);
setJumpHopQueueState(null);
setJumpHopGenerationState( setJumpHopGenerationState(
resolveFinishedMiniGameDraftGenerationState( resolveFinishedMiniGameDraftGenerationState(
generationState, generationState,
@@ -7918,6 +8052,7 @@ export function PlatformEntryFlowShellImpl({
setPuzzleClearWork(null); setPuzzleClearWork(null);
setPuzzleClearRun(null); setPuzzleClearRun(null);
setPuzzleClearGenerationState(generationState); setPuzzleClearGenerationState(generationState);
setPuzzleClearQueueState(null);
setIsPuzzleClearBusy(true); setIsPuzzleClearBusy(true);
markDraftGenerating('puzzle-clear', [created.session.sessionId]); markDraftGenerating('puzzle-clear', [created.session.sessionId]);
markPendingDraftGenerating('puzzle-clear', created.session.sessionId); markPendingDraftGenerating('puzzle-clear', created.session.sessionId);
@@ -7946,6 +8081,19 @@ export function PlatformEntryFlowShellImpl({
created.session.draft?.boardBackgroundAsset, 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); setPuzzleClearSession(response.session);
setPuzzleClearWork(response.work ?? null); setPuzzleClearWork(response.work ?? null);
writeCreationUrlState( writeCreationUrlState(
@@ -7990,6 +8138,7 @@ export function PlatformEntryFlowShellImpl({
'生成拼消消草稿失败。', '生成拼消消草稿失败。',
); );
setPuzzleClearError(errorMessage); setPuzzleClearError(errorMessage);
setPuzzleClearQueueState(null);
setPuzzleClearGenerationState( setPuzzleClearGenerationState(
resolveFinishedMiniGameDraftGenerationState( resolveFinishedMiniGameDraftGenerationState(
generationState, generationState,
@@ -8071,6 +8220,7 @@ export function PlatformEntryFlowShellImpl({
const generationState = createMiniGameDraftGenerationState('puzzle-clear'); const generationState = createMiniGameDraftGenerationState('puzzle-clear');
setPuzzleClearError(null); setPuzzleClearError(null);
setPuzzleClearGenerationState(generationState); setPuzzleClearGenerationState(generationState);
setPuzzleClearQueueState(null);
setIsPuzzleClearBusy(true); setIsPuzzleClearBusy(true);
selectionStageRef.current = 'puzzle-clear-generating'; selectionStageRef.current = 'puzzle-clear-generating';
setSelectionStage('puzzle-clear-generating'); setSelectionStage('puzzle-clear-generating');
@@ -8092,6 +8242,19 @@ export function PlatformEntryFlowShellImpl({
boardBackgroundAsset: puzzleClearSession.draft?.boardBackgroundAsset, 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); setPuzzleClearSession(response.session);
setPuzzleClearWork(response.work ?? puzzleClearWork); setPuzzleClearWork(response.work ?? puzzleClearWork);
writeCreationUrlState( writeCreationUrlState(
@@ -8110,6 +8273,7 @@ export function PlatformEntryFlowShellImpl({
'重新生成拼消消图集失败。', '重新生成拼消消图集失败。',
); );
setPuzzleClearError(errorMessage); setPuzzleClearError(errorMessage);
setPuzzleClearQueueState(null);
setPuzzleClearGenerationState( setPuzzleClearGenerationState(
resolveFinishedMiniGameDraftGenerationState(generationState, 'failed', { resolveFinishedMiniGameDraftGenerationState(generationState, 'failed', {
error: errorMessage, error: errorMessage,
@@ -8420,6 +8584,7 @@ export function PlatformEntryFlowShellImpl({
setWoodenFishWork(null); setWoodenFishWork(null);
setWoodenFishRun(null); setWoodenFishRun(null);
setWoodenFishGenerationState(generationState); setWoodenFishGenerationState(generationState);
setWoodenFishQueueState(null);
setIsWoodenFishBusy(true); setIsWoodenFishBusy(true);
setSelectionStage('wooden-fish-generating'); setSelectionStage('wooden-fish-generating');
markDraftGenerating('wooden-fish', [created.session.sessionId]); markDraftGenerating('wooden-fish', [created.session.sessionId]);
@@ -8439,6 +8604,19 @@ export function PlatformEntryFlowShellImpl({
draft: created.session.draft, 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); setWoodenFishSession(response.session);
setWoodenFishWork(response.work ?? null); setWoodenFishWork(response.work ?? null);
writeCreationUrlState( writeCreationUrlState(
@@ -8483,6 +8661,7 @@ export function PlatformEntryFlowShellImpl({
'生成敲木鱼草稿失败。', '生成敲木鱼草稿失败。',
); );
setWoodenFishError(errorMessage); setWoodenFishError(errorMessage);
setWoodenFishQueueState(null);
setWoodenFishGenerationState( setWoodenFishGenerationState(
resolveFinishedMiniGameDraftGenerationState( resolveFinishedMiniGameDraftGenerationState(
generationState, generationState,
@@ -8568,6 +8747,7 @@ export function PlatformEntryFlowShellImpl({
); );
setWoodenFishError(null); setWoodenFishError(null);
setWoodenFishGenerationState(generationState); setWoodenFishGenerationState(generationState);
setWoodenFishQueueState(null);
setIsWoodenFishBusy(true); setIsWoodenFishBusy(true);
setSelectionStage('wooden-fish-generating'); setSelectionStage('wooden-fish-generating');
try { try {
@@ -8577,6 +8757,19 @@ export function PlatformEntryFlowShellImpl({
draft: woodenFishSession.draft, 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); setWoodenFishSession(response.session);
setWoodenFishWork(response.work ?? woodenFishWork); setWoodenFishWork(response.work ?? woodenFishWork);
writeCreationUrlState( writeCreationUrlState(
@@ -8595,6 +8788,7 @@ export function PlatformEntryFlowShellImpl({
'重新生成敲击物图案失败。', '重新生成敲击物图案失败。',
); );
setWoodenFishError(errorMessage); setWoodenFishError(errorMessage);
setWoodenFishQueueState(null);
setWoodenFishGenerationState( setWoodenFishGenerationState(
resolveFinishedMiniGameDraftGenerationState( resolveFinishedMiniGameDraftGenerationState(
generationState, generationState,
@@ -15382,6 +15576,7 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="草稿生成中" activeBadgeLabel="草稿生成中"
pausedBadgeLabel="草稿生成已暂停" pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区" idleBadgeLabel="等待返回工作区"
queueStatus={jumpHopExternalGenerationQueueStatus}
/> />
</Suspense> </Suspense>
</motion.div> </motion.div>
@@ -15526,6 +15721,7 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('match3d-agent-workspace'); setSelectionStage('match3d-agent-workspace');
}} }}
onRetry={retryMatch3DDraftGeneration} onRetry={retryMatch3DDraftGeneration}
queueStatus={puzzleClearExternalGenerationQueueStatus}
hideBatchModule hideBatchModule
/> />
</Suspense> </Suspense>
@@ -15796,6 +15992,7 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="草稿生成中" activeBadgeLabel="草稿生成中"
pausedBadgeLabel="草稿生成已暂停" pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区" idleBadgeLabel="等待返回工作区"
queueStatus={woodenFishExternalGenerationQueueStatus}
/> />
</Suspense> </Suspense>
</motion.div> </motion.div>
@@ -15996,6 +16193,7 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="图片生成中" activeBadgeLabel="图片生成中"
pausedBadgeLabel="图片生成已暂停" pausedBadgeLabel="图片生成已暂停"
idleBadgeLabel="等待返回结果页" idleBadgeLabel="等待返回结果页"
queueStatus={externalGenerationQueueStatus}
/> />
</Suspense> </Suspense>
</motion.div> </motion.div>
@@ -16200,6 +16398,7 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('jump-hop-workspace'); setSelectionStage('jump-hop-workspace');
}} }}
onRetry={retryJumpHopDraftGeneration} onRetry={retryJumpHopDraftGeneration}
queueStatus={externalGenerationQueueStatus}
/> />
</Suspense> </Suspense>
</motion.div> </motion.div>
@@ -16348,6 +16547,7 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="素材生成中" activeBadgeLabel="素材生成中"
pausedBadgeLabel="素材生成已暂停" pausedBadgeLabel="素材生成已暂停"
idleBadgeLabel="等待返回工作区" idleBadgeLabel="等待返回工作区"
queueStatus={externalGenerationQueueStatus}
/> />
</Suspense> </Suspense>
</motion.div> </motion.div>
@@ -16477,6 +16677,7 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('wooden-fish-workspace'); setSelectionStage('wooden-fish-workspace');
}} }}
onRetry={retryWoodenFishDraftGeneration} onRetry={retryWoodenFishDraftGeneration}
queueStatus={externalGenerationQueueStatus}
/> />
</Suspense> </Suspense>
</motion.div> </motion.div>
@@ -16670,6 +16871,7 @@ export function PlatformEntryFlowShellImpl({
setSelectionStage('puzzle-agent-workspace'); setSelectionStage('puzzle-agent-workspace');
}} }}
onRetry={retryPuzzleDraftGeneration} onRetry={retryPuzzleDraftGeneration}
queueStatus={puzzleExternalGenerationQueueStatus}
hideBatchModule hideBatchModule
/> />
</Suspense> </Suspense>
@@ -16796,6 +16998,7 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="草稿生成中" activeBadgeLabel="草稿生成中"
pausedBadgeLabel="草稿生成已暂停" pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区" idleBadgeLabel="等待返回工作区"
queueStatus={externalGenerationQueueStatus}
/> />
</Suspense> </Suspense>
</motion.div> </motion.div>
@@ -17039,6 +17242,7 @@ export function PlatformEntryFlowShellImpl({
activeBadgeLabel="草稿编译中" activeBadgeLabel="草稿编译中"
pausedBadgeLabel="草稿生成已暂停" pausedBadgeLabel="草稿生成已暂停"
idleBadgeLabel="等待返回工作区" idleBadgeLabel="等待返回工作区"
queueStatus={externalGenerationQueueStatus}
/> />
</Suspense> </Suspense>
</motion.div> </motion.div>

View File

@@ -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,
};
}

View File

@@ -70,4 +70,28 @@ describe('UnifiedGenerationPage', () => {
expect(screen.queryByText('当前跳一跳信息')).toBeNull(); expect(screen.queryByText('当前跳一跳信息')).toBeNull();
expect(screen.queryByText('云端糖果塔')).toBeNull(); expect(screen.queryByText('云端糖果塔')).toBeNull();
}); });
test('显示外部生成队列状态', () => {
render(
<UnifiedGenerationPage
playId="puzzle"
settingText="一只发光的纸船"
progress={createProgress()}
isGenerating
queueStatus={{
currentStatus: 'queued',
currentProgress: 18,
pendingCount: 6,
runningCount: 2,
}}
onBack={() => {}}
onEditSetting={() => {}}
onRetry={() => {}}
/>,
);
expect(screen.getByText('排队中 18%')).toBeTruthy();
expect(screen.getByText('排队 6')).toBeTruthy();
expect(screen.getByText('生成 2')).toBeTruthy();
});
}); });

View File

@@ -1,6 +1,9 @@
import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime'; import type { CustomWorldGenerationProgress } from '../../../packages/shared/src/contracts/runtime';
import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress'; import type { CustomWorldStructuredAnchorEntry } from '../../services/customWorldAgentGenerationProgress';
import { CustomWorldGenerationView } from '../CustomWorldGenerationView'; import {
CustomWorldGenerationView,
type ExternalGenerationQueueStatus,
} from '../CustomWorldGenerationView';
import type { UnifiedGenerationPlayId } from './unifiedGenerationCopy'; import type { UnifiedGenerationPlayId } from './unifiedGenerationCopy';
import { getUnifiedGenerationCopy } from './unifiedGenerationCopy'; import { getUnifiedGenerationCopy } from './unifiedGenerationCopy';
@@ -15,6 +18,7 @@ type UnifiedGenerationPageProps = {
onEditSetting: () => void; onEditSetting: () => void;
onRetry: () => void; onRetry: () => void;
hideBatchModule?: boolean; hideBatchModule?: boolean;
queueStatus?: ExternalGenerationQueueStatus | null;
}; };
export function UnifiedGenerationPage({ export function UnifiedGenerationPage({
@@ -28,6 +32,7 @@ export function UnifiedGenerationPage({
onEditSetting, onEditSetting,
onRetry, onRetry,
hideBatchModule = false, hideBatchModule = false,
queueStatus = null,
}: UnifiedGenerationPageProps) { }: UnifiedGenerationPageProps) {
const copy = getUnifiedGenerationCopy(playId); const copy = getUnifiedGenerationCopy(playId);
@@ -51,6 +56,7 @@ export function UnifiedGenerationPage({
pausedBadgeLabel="素材生成已暂停" pausedBadgeLabel="素材生成已暂停"
idleBadgeLabel="等待返回工作区" idleBadgeLabel="等待返回工作区"
hideBatchModule={hideBatchModule} hideBatchModule={hideBatchModule}
queueStatus={queueStatus}
/> />
); );
} }

View File

@@ -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<ExternalGenerationQueueOverviewResponse>(
`${EXTERNAL_GENERATION_API_BASE}/queue-overview`,
{
method: 'GET',
signal,
},
'读取生成队列状态失败',
EXTERNAL_GENERATION_READ_OPTIONS,
);
}
export async function getExternalGenerationJobStatus(
jobId: string,
signal?: AbortSignal,
) {
return requestJson<ExternalGenerationJobStatusResponse>(
`${EXTERNAL_GENERATION_API_BASE}/jobs/${encodeURIComponent(jobId)}`,
{
method: 'GET',
signal,
},
'读取生成任务状态失败',
EXTERNAL_GENERATION_READ_OPTIONS,
);
}
export const externalGenerationClient = {
getQueueOverview: getExternalGenerationQueueOverview,
getJobStatus: getExternalGenerationJobStatus,
};

View File

@@ -0,0 +1,5 @@
export {
externalGenerationClient,
getExternalGenerationJobStatus,
getExternalGenerationQueueOverview,
} from './externalGenerationClient';