diff --git a/.codex/skills/spacetimedb-cli/SKILL.md b/.codex/skills/spacetimedb-cli/SKILL.md index 0aa0d2ac..3bd0d454 100644 --- a/.codex/skills/spacetimedb-cli/SKILL.md +++ b/.codex/skills/spacetimedb-cli/SKILL.md @@ -203,6 +203,8 @@ spacetime server ping ```bash # Clear data and republish spacetime publish my-db --clear-database --yes +# Clear data and republish only when conflict +spacetime publish my-db --clear-database=on-conflict --yes ``` ### "Build failed" diff --git a/.env.local b/.env.local index 593661a6..fe798ce3 100644 --- a/.env.local +++ b/.env.local @@ -52,3 +52,7 @@ ALIYUN_OSS_ACCESS_KEY_SECRET="XblWGE6CO1WLnSBdMRVpL6lut4GSoS" GENARRATIVE_BACKEND_STACK="rust" RUST_SERVER_TARGET="http://127.0.0.1:3100" GENARRATIVE_API_TARGET="http://127.0.0.1:3100" + +GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com" +GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr" +GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiIwMUsyN05YUjBaQkRUVEVCNlFQQjFXNzU2MiIsImlzcyI6Imh0dHBzOi8vYXV0aC5zcGFjZXRpbWVkYi5jb20iLCJhdWQiOiJzcGFjZXRpbWVkYiIsImlhdCI6MTc3NzAzNjI2MSwiZXhwIjoxODQwMTA4MjYxfQ.XosLKR-y85dv4yRN-INJMNSWhz4VtXaDvypvyzNAwFdmLMC3IKG6HfmSBHwLOjO3JVkQBTKodivYe6_sDOFNsCMGdP5nwMubYlmxWaOk41WBldd3JFA7ag8OpikYBkWp-4n59c8wLn-LWiOUWBw_g5vaCbzZs3pP51amw9o-DUEog53fGjoS3ij8oVIg_8AZDxoSmqVvT6K-2wIpstj7bM674nks-qbhMuAjdM9l1HURw_uip5iWEIB4hQZtzlOtHe49wvhN3lvgoM9r4YJS7emDDBwFTopQF-cSPKyh_tFfpH7jUIb3RiqGutQV37c3veNnUVxmYNvqB561eR4mQw" diff --git a/docs/experience/RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md b/docs/experience/RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md index 9c2c7136..c514683b 100644 --- a/docs/experience/RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md +++ b/docs/experience/RPG_DRAFT_IMAGE_PARALLEL_GENERATION_2026-04-24.md @@ -21,7 +21,7 @@ RPG 草稿生成进入底稿素材阶段后,角色主形象与场景幕背景 - 背景分支使用 `JoinSet` 把 `sceneChapterBlueprints[*].acts[*]` 的每一幕背景任务一次性投递,返回后写入 `backgroundImageSrc`、`backgroundAssetId`、`generatedScenePrompt`、`generatedSceneModel`。 - `merge_generated_act_backgrounds` 只把背景图字段合并回角色分支副本,再进入后续草稿卡编译和 SpacetimeDB 写入。 - 幕背景 prompt 同时兼容 `backgroundPromptText`、`scenePromptText`、`visualPromptText`、`promptText`、`imagePromptText`、`backgroundPrompt`、`visualPrompt`,避免 LLM 输出字段别名导致整批背景图被误判缺失。 -- 每个角色主形象、每一幕背景图都必须独立自动重试,单项最多尝试 3 次。任一单项超过 3 次仍失败时,后台任务必须把 operation 标记为 `failed` 并停止写入草稿卡,避免生成“缺主图 / 缺背景图”的可进入世界档案。 +- 每个角色主形象、每一幕背景图都必须独立自动重试,单项最多尝试 3 次。任一单项超过 3 次仍失败时,后台任务必须把 operation 标记为 `failed` 并停止写入草稿卡,避免生成“缺主图 / 缺背景图”的可进入世界档案。\r\n- 图片任务仍然一次性投递,保证角色与幕背景两类任务不回退到串行编排;但真正请求上游生图服务时必须共用并发闸门,当前同一底稿最多同时发起 2 个上游请求,降低 DashScope 瞬时 502 / 限流导致整批失败的概率。\r\n- 幕背景图失败文案必须带第几章、第几幕和幕标题,不能只显示“第1幕 / 第2幕 / 第3幕”,否则多章节同名幕会被用户误认为同一失败项重复上报。 - 中止前必须持久化已经成功生成的部分底稿到会话 `draftProfile`,不能因为某个角色或某一幕失败而丢掉其它已生成的 `imageSrc / generatedVisualAssetId / backgroundImageSrc / backgroundAssetId`。 - 前端 `CharacterAnimator` 对带 `generatedVisualAssetId` 但尚无 `animationMap` 的自定义角色,所有状态优先渲染生成主图;只有真正发布了动作集后才按动作帧播放,避免运行或战斗状态回落到模板 sprite。 @@ -30,3 +30,4 @@ RPG 草稿生成进入底稿素材阶段后,角色主形象与场景幕背景 如果后续图片供应商出现强限流,再在网关层做队列或供应商侧限流;不要在 RPG 底稿编排层恢复逐张串行,否则会重新退化成多张图片总耗时累加。 + diff --git a/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md index 1c850c3b..d92dbf73 100644 --- a/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_AGENT_FIRST_BIG_FISH_GAME_CREATION_AND_GAMEPLAY_PRD_2026-04-22.md @@ -17,6 +17,9 @@ 7. 发布为可运行玩法 8. 进入竖屏全屏实时玩法运行态 +补充说明: +当前工程实现中,运行态页面需要直接消费结果页已经生成的等级主图、动作图与场地背景图,不能只在结果页预览资产、进入玩法后退回圆点占位表现。 + 本稿必须满足两个硬要求: 1. 不能沿用 RPG 创作链里的旧命名和旧数据口径,把实时吞噬玩法硬塞进 `rpg` 或旧 `customWorld` 语义里。 @@ -189,6 +192,9 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事: 3. 会总结,不只会追问 4. 会补缺,不会平均盘问 5. 进度基于真实锚点完成度,而不是机械轮次 +6. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。 + - 该动作只向 Agent 发送“请补充剩余关键字。”,由后端 Agent 根据当前锚点补齐缺失关键词。 + - 前端不得自行推断成长阶梯、风险节奏或视觉母题,也不得直接改写锚点状态。 ## 7.3 大鱼吃小鱼玩法的 4 个最小高杠杆锚点 @@ -945,6 +951,12 @@ Agent 在这一玩法里不负责实时玩法裁决,它只负责 3 件事: 5. `src/services/big-fish-*` - 前端 client、adapter、view model +后端字段边界要求: + +1. LLM 输出、HTTP 响应和前端交互契约可以使用 `camelCase`,例如 `nextAnchorPack`、`gameplayPromise`、`ecologyVisualTheme`。 +2. 写入 SpacetimeDB 的 `anchor_pack_json`、`draft_json`、`asset_coverage_json`、`snapshot_json` 必须序列化 Rust 领域结构,字段名保持 `snake_case`。 +3. `api-server` 负责在 LLM/HTTP 边界显式翻译字段名,不能把前端响应层 JSON 直接透传为 SpacetimeDB 持久化 JSON。 + --- ## 22. 平台内脚本命名规范 diff --git a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md index 1b5e3141..0421243e 100644 --- a/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md +++ b/docs/prd/AI_NATIVE_PUZZLE_CREATOR_AND_GAMEPLAY_SYSTEM_PRD_2026-04-22.md @@ -91,6 +91,16 @@ 10. 游戏画面必须显示作者信息和关卡名。 11. 前端只负责表现和交互输入,逻辑、数据、关卡裁决、推荐计算、状态存储全部放到 `server-rs` 后端,由 `Axum + SpacetimeDB + OSS` 方案承接。 +### 第一版单机例外说明 2026-04-24 + +为了先把拼图玩法跑通,第一版运行态采用单机本地版本,作为上面总原则的阶段性例外: + +1. Agent 会话、结果页草稿、正式候选图生成、封面确认、发布、作品读取,仍然全部走 Rust 后端。 +2. 进入拼图玩法后的 `run` 只在前端本地内存中存在。 +3. 交换、拖动、通关判断不写回后端。 +4. 关闭玩法后不保留本次运行态,不做断点续玩。 +5. 后续如果要做跨端续玩、多端同步或排行榜,再把运行态真相源收回后端。 + --- ## 4. 明确不做 @@ -191,7 +201,10 @@ 1. 优先接住创作者的画面灵感,而不是立刻列问卷。 2. 每轮只追问当前最影响图片生成质量的 `1` 个问题。 3. 当创作者已经说出足够信息时,优先总结,不重复追问。 -4. 在进入结果页前,至少确认: +4. 当会话至少完成 `2` 轮后,工作区必须提供 `补充剩余关键字` 快捷动作。 + - 该动作只向 Agent 发送“请补充剩余关键字。”,不在前端补数据、不伪造锚点状态。 + - Agent 收到后应优先补齐仍为 `待补充` / 空值的锚点关键词,并保持每次回复清爽直接。 +5. 在进入结果页前,至少确认: - 一句题材承诺 - 一个主要视觉主体 - 一组气质描述 @@ -294,6 +307,12 @@ interface PuzzleAnchorPack { 2. 创作者选择 `1` 张作为正式图 3. 正式图确定后,写回作品主图 +后端落地契约: + +1. `api-server` 写入 SpacetimeDB 的候选图 JSON 必须使用 `module-puzzle::PuzzleGeneratedImageCandidate` 持久化结构。 +2. 持久化字段名保持 Rust 侧 `snake_case`,例如 `candidate_id`、`image_src`、`asset_id`、`actual_prompt`、`source_type`。 +3. 面向前端的 HTTP 响应仍由 `shared-contracts` 单独映射为 `camelCase`,不能把响应层字段名直接写入 SpacetimeDB JSON。 + ## 7.6 拼图图片资产要求 拼图图片的正式资产要求: diff --git a/docs/technical/CREATION_AGENT_RESULT_PAGE_SSE_ABORT_FIX_2026-04-24.md b/docs/technical/CREATION_AGENT_RESULT_PAGE_SSE_ABORT_FIX_2026-04-24.md new file mode 100644 index 00000000..b7e86d3a --- /dev/null +++ b/docs/technical/CREATION_AGENT_RESULT_PAGE_SSE_ABORT_FIX_2026-04-24.md @@ -0,0 +1,31 @@ +# 创作 Agent 结果页 SSE 断开修复 + +日期:`2026-04-24` + +## 1. 问题 + +RPG 世界共创草稿进入生成结果页后,前端仍可能保留上一条聊天消息的 `/messages/stream` 连接。该连接继续接收 `reply_delta` 时,会让结果页阶段仍表现为“聊天还在连着 SSE”。 + +## 2. 原因 + +当前聊天流式请求由 `streamRpgCreationMessage` 发起,底层使用 `fetch` 读取 SSE `ReadableStream`。旧实现只在请求自然结束后清理 `isStreamingAgentReply`,没有在以下 UI 生命周期主动中止网络流: + +1. 从 `agent-workspace` 跳到 `custom-world-result`。 +2. 清空或切换 `activeAgentSessionId`。 +3. 当前入口组件卸载。 + +因此,结果页虽然不再展示聊天工作区,但浏览器侧仍可能持有未完成的流读取器。 + +## 3. 修复设计 + +1. `TextStreamOptions` 增加 `signal?: AbortSignal`,让所有创作 Agent 流式读取都具备统一取消入口。 +2. RPG 共创 `/messages/stream` 的 `fetch` 透传该 `signal`。 +3. `useRpgCreationSessionController` 持有当前聊天流的 `AbortController`。 +4. 当 `selectionStage` 离开 `agent-workspace` / `custom-world-generating`,立即 `abort()` 当前聊天 SSE,并清空临时流式文本。 +5. session 切换、未登录清理、组件卸载时同样中止旧 SSE,避免慢响应回写旧工作区状态。 + +## 4. 验收 + +1. 聊天中触发草稿生成并进入结果页后,浏览器 Network 中旧 `/messages/stream` 请求应变为 canceled/aborted 或结束。 +2. 结果页不再继续追加聊天 `reply_delta`。 +3. 回到 Agent 工作区后,新的聊天消息会创建新的 SSE,不复用已中止连接。 diff --git a/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md b/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md index 18c58825..0186d5bc 100644 --- a/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md +++ b/docs/technical/CUSTOM_WORLD_DRAFT_FOUNDATION_RS_NODE_WORKFLOW_ALIGNMENT_2026-04-24.md @@ -83,3 +83,27 @@ cargo test -p api-server custom_world_foundation_draft -- --nocapture ``` 结果:后端检查通过;`custom_world_foundation_draft` 相关测试 `3 passed`。 + +## 2026-04-24 `spacetime-client` facade 补齐 + +合并 `draft_foundation` 进度链路后,`spacetime-module` 和生成绑定中已经存在 `upsert_custom_world_agent_operation_progress` procedure,但手写 `spacetime-client` facade 尚未导出对应 record input 与调用方法,导致 `api-server` 编译时报: + +1. `CustomWorldAgentOperationProgressRecordInput` 未导出。 +2. `SpacetimeClient::upsert_custom_world_agent_operation_progress` 不存在。 + +本次补齐边界: + +1. `spacetime-client::mapper` 新增 `CustomWorldAgentOperationProgressRecordInput`。 +2. `spacetime-client::custom_world` 新增 `upsert_custom_world_agent_operation_progress(...)`,负责把字符串形式的 operation type/status 翻译为 SpacetimeDB 生成枚举后调用 procedure。 +3. `spacetime-client::module_bindings::mod` 补入已生成的 progress input/procedure 索引,避免 procedure 文件存在但 `RemoteProcedures` 扩展 trait 未进入作用域。 +4. `api-server` 只依赖 facade,不直接碰生成绑定,保持 HTTP 层与 SpacetimeDB 生成类型隔离。 + +补充验证: + +```bash +cargo fmt -p spacetime-client -p api-server +cargo check -p api-server --bin api-server +npm run check:encoding +``` + +结果:`api-server` 编译通过,编码检查通过;剩余 warning 为既有 dead code。 diff --git a/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md b/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md index 66d92ed6..9d0e4879 100644 --- a/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md +++ b/docs/technical/PUZZLE_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-22.md @@ -196,10 +196,10 @@ Rust DTO 只承载对前端公开的 HTTP contract,不直接泄露 `module-puz ## 6. 结果页图片生成策略 -本轮不引入新的真实图像模型编排,而是复用 `api-server` 里已有的占位资产写盘模式: +本轮后续已经接入 `api-server` 统一资产链路:拼图候选图由 `api-server` 调用图像服务生成,再以 OSS 对象作为持久化真值,SpacetimeDB 只保存候选图 URL、assetId 与 prompt snapshot。 1. 每次生成 2 张候选图。 -2. 候选图通过 `api-server` 写入 `public/generated-puzzle-covers/...`。 +2. 候选图通过 `api-server` 写入 OSS,兼容展示路径统一为 `/generated-puzzle-assets/...`,禁止再落到仓库 `public/` 目录。 3. Axum 把候选图 URL、assetId、prompt snapshot 回写到 Spacetime session draft。 4. 创作者在结果页选择其中 1 张作为正式图。 @@ -207,7 +207,7 @@ Rust DTO 只承载对前端公开的 HTTP contract,不直接泄露 `module-puz 1. 结果页图片生成、重生、应用正式图完整可用。 2. 发布链有正式图片可校验。 -3. 不额外扩到模型供应商集成。 +3. 不再依赖本地 `public/` 占位目录,避免开发工作区混入运行时生成文件。 ### 6.1 发布前编辑真相补充 diff --git a/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md b/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md new file mode 100644 index 00000000..c2a78538 --- /dev/null +++ b/docs/technical/PUZZLE_SINGLE_PLAYER_AND_REAL_IMAGE_PLAN_2026-04-24.md @@ -0,0 +1,68 @@ +# 拼图玩法单机运行态与真实图片生成方案 2026-04-24 + +## 1. 本次收口目标 + +这次收口只做两件事: + +1. 拼图结果页中的候选图生成不再返回本地 SVG 占位图,而是接入 Rust `api-server` 现有的真实外部生图链。 +2. 拼图第一版运行态改为单机本地运行,不再把交换、拖动、通关进度和下一关状态保存到后端。 + +## 2. 第一版单机范围 + +第一版“单机版本”的准确定义如下: + +1. 玩家从拼图广场或作品详情进入玩法时,前端基于作品详情在本地构造一次 `PuzzleRunSnapshot`。 +2. 交换拼图块、拖动拼图块、关卡是否拼完,全部由前端本地计算。 +3. 本地运行态不调用 `/api/runtime/puzzle/runs/*` 写回当前过程状态。 +4. 关闭玩法后,这次运行态直接失效,不做断点续玩,不做跨端同步。 +5. 后端仍然负责: + - Agent 会话 + - 结果页草稿编译 + - 正式候选图生成 + - 封面确认 + - 作品发布 + - 作品列表 / 详情 / 广场读取 + +这意味着第一版拼图玩法是“创作后端化、游玩本地化”的结构,而不是“所有状态都走后端”的结构。 + +## 3. 真实图片生成链 + +拼图正式候选图统一复用当前仓库已经跑通的 Rust 资产主链: + +1. `api-server` 根据拼图草稿的关卡名和结果页 prompt 组装正式文生图 prompt。 +2. 调用 DashScope 文生图接口创建异步任务。 +3. 轮询任务直到拿到正式图片地址。 +4. 下载图片二进制。 +5. 上传到私有 OSS。 +6. 在 `module-assets` / `spacetime-client` 的资产真相链中确认对象并绑定到拼图实体。 +7. 对前端返回 `/generated-puzzle-assets/*` 兼容路径,而不是本地 `svg` 占位路径。 + +## 4. 路径与边界 + +### 4.1 候选图输出路径 + +拼图正式候选图统一使用: + +`/generated-puzzle-assets/*` + +不能继续写到仓库本地 `public/generated-puzzle-covers/*`。 + +### 4.2 运行态边界 + +第一版单机运行态保留现有 DTO 结构,目的是不重做界面层。 + +但 DTO 的来源变化为: + +1. 进入玩法时从作品详情构造本地 `run` +2. 交换 / 拖动 / 通关时由前端工具函数返回新的 `run` +3. 当前不依赖后端 `start/swap/drag/next-level` 接口完成主链 + +## 5. 当前实现判断标准 + +当下面结果成立时,视为这一轮目标达成: + +1. `generate_puzzle_images` 返回的 `imageSrc` 不再是本地 `svg` 占位图。 +2. 返回路径切到 `/generated-puzzle-assets/*`。 +3. 未配置 DashScope 或 OSS 时,接口明确返回 provider 级错误,而不是静默回退占位图。 +4. 玩家进入拼图玩法后,即使后端运行态接口不可用,也能在本地完成交换与拖动。 +5. 关闭玩法后不保留当前 run 进度。 diff --git a/docs/technical/SPACETIMEDB_MAINCLOUD_PUBLISH_2026-04-24.md b/docs/technical/SPACETIMEDB_MAINCLOUD_PUBLISH_2026-04-24.md new file mode 100644 index 00000000..2abfcf22 --- /dev/null +++ b/docs/technical/SPACETIMEDB_MAINCLOUD_PUBLISH_2026-04-24.md @@ -0,0 +1,59 @@ +# SpacetimeDB Maincloud 发布与 api-server 适配方案 + +## 目标 + +新增一条明确的 npm 命令链,用于把 `server-rs/crates/spacetime-module` 发布到 SpacetimeDB Maincloud,并让 `api-server` 可以使用同一套 Maincloud 数据库配置启动。 + +## 环境变量约定 + +Maincloud 发布不复用本地 `spacetime.local.json`,避免误把本地开发库名发布到云端。需要显式提供: + +| 变量 | 用途 | +| --- | --- | +| `GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE` | Maincloud 数据库名,发布脚本优先读取 | +| `GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL` | Maincloud 服务地址,默认 `https://maincloud.spacetimedb.com` | +| `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN` | `api-server` 连接 Maincloud 时使用的 token | + +兼容 `api-server` 现有变量: + +| 变量 | 用途 | +| --- | --- | +| `GENARRATIVE_SPACETIME_SERVER_URL` | `api-server` 实际连接地址 | +| `GENARRATIVE_SPACETIME_DATABASE` | `api-server` 实际连接数据库 | +| `GENARRATIVE_SPACETIME_TOKEN` | `api-server` 实际连接 token | + +## npm 命令 + +```bash +npm run spacetime:publish:maincloud +``` + +执行内容: + +1. 使用 `cargo build -p spacetime-module --target wasm32-unknown-unknown --release` 构建 wasm。 +2. 使用 `spacetime publish --server maincloud --bin-path --yes` 发布到 Maincloud。 +3. 输出 `api-server` 需要的 Maincloud 环境变量,便于部署进程复用。 + +如需 schema 冲突时清库发布: + +```bash +npm run spacetime:publish:maincloud -- --clear-database +``` + +## api-server 启动 + +```bash +npm run api-server:maincloud +``` + +执行内容: + +1. 从 `.env` 与 `.env.local` 读取默认环境。 +2. 将 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 映射为 `api-server` 已支持的 `GENARRATIVE_SPACETIME_*`。 +3. 启动 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`。 + +## 设计约束 + +- Maincloud 数据库名必须显式配置,不能默认读取本地 `spacetime.local.json`。 +- 发布脚本只处理 SpacetimeDB 模块发布,不启动本地 SpacetimeDB。 +- `api-server` 继续通过 `SpacetimeClientConfig` 的 `server_url / database / token` 连接数据库,不在前端增加逻辑。 diff --git a/package.json b/package.json index 59469601..a4d6c994 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "dev": "node scripts/dev-node.mjs", "dev:rust": "node scripts/run-bash-script.mjs scripts/dev-rust-stack.sh", "dev:rust:logs": "node scripts/run-bash-script.mjs scripts/spacetime-logs-local.sh", - "dev:web": "node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0", + "dev:web": "node scripts/dev-web-rust.mjs", "dev:node": "node scripts/dev-node.mjs", + "spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh", + "api-server:maincloud": "node scripts/api-server-maincloud.mjs", "deploy:rust:remote": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "build:rust:ubuntu": "node scripts/run-bash-script.mjs scripts/deploy-rust-remote.sh", "serve:caddy": "node scripts/run-caddy-dev.mjs", diff --git a/scripts/api-server-maincloud.mjs b/scripts/api-server-maincloud.mjs new file mode 100644 index 00000000..09688655 --- /dev/null +++ b/scripts/api-server-maincloud.mjs @@ -0,0 +1,86 @@ +import {spawn} from 'node:child_process'; +import {existsSync, readFileSync} from 'node:fs'; +import {resolve} from 'node:path'; + +const repoRoot = process.cwd(); + +function loadEnvFile(path, target) { + if (!existsSync(path)) { + return; + } + + const rawText = readFileSync(path, 'utf8'); + for (const rawLine of rawText.split(/\r?\n/u)) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } + + const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/u); + if (!match) { + continue; + } + + const [, key, rawValue] = match; + if (target[key] !== undefined) { + continue; + } + + target[key] = rawValue.replace(/^['"]|['"]$/gu, ''); + } +} + +const mergedEnv = {...process.env}; +loadEnvFile(resolve(repoRoot, '.env'), mergedEnv); +loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv); + +mergedEnv.GENARRATIVE_API_HOST = mergedEnv.GENARRATIVE_API_HOST || '127.0.0.1'; +mergedEnv.GENARRATIVE_API_PORT = mergedEnv.GENARRATIVE_API_PORT || '3100'; +mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL = + mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL || + mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || + 'https://maincloud.spacetimedb.com'; +mergedEnv.GENARRATIVE_SPACETIME_DATABASE = + mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE || + mergedEnv.GENARRATIVE_SPACETIME_DATABASE || + ''; +mergedEnv.GENARRATIVE_SPACETIME_TOKEN = + mergedEnv.GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN || + mergedEnv.GENARRATIVE_SPACETIME_TOKEN || + ''; + +if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) { + console.error( + '[api-server:maincloud] 缺少 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE 或 GENARRATIVE_SPACETIME_DATABASE。', + ); + process.exit(1); +} + +console.log( + `[api-server:maincloud] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`, +); + +const child = spawn( + 'cargo', + ['run', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml'], + { + cwd: repoRoot, + env: mergedEnv, + stdio: 'inherit', + shell: process.platform === 'win32', + }, +); + +child.on('error', (error) => { + console.error(`[api-server:maincloud] 启动 cargo 失败: ${error.message}`); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + console.error(`[api-server:maincloud] api-server 被信号终止: ${signal}`); + process.exit(1); + } + + process.exit(code ?? 0); +}); diff --git a/scripts/dev-web-rust.mjs b/scripts/dev-web-rust.mjs new file mode 100644 index 00000000..5c76eb2b --- /dev/null +++ b/scripts/dev-web-rust.mjs @@ -0,0 +1,40 @@ +import {spawn} from 'node:child_process'; + +const mergedEnv = { + ...process.env, + GENARRATIVE_BACKEND_STACK: process.env.GENARRATIVE_BACKEND_STACK || 'rust', + RUST_SERVER_TARGET: + process.env.RUST_SERVER_TARGET || + process.env.GENARRATIVE_API_TARGET || + `http://127.0.0.1:${process.env.GENARRATIVE_API_PORT || '3100'}`, +}; + +mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET = + process.env.GENARRATIVE_RUNTIME_SERVER_TARGET || mergedEnv.RUST_SERVER_TARGET; + +console.log(`[dev:web] backend=rust target=${mergedEnv.GENARRATIVE_RUNTIME_SERVER_TARGET}`); + +const child = spawn( + 'node', + ['scripts/vite-cli.mjs', '--port=3000', '--host=0.0.0.0'], + { + cwd: process.cwd(), + env: mergedEnv, + stdio: 'inherit', + shell: process.platform === 'win32', + }, +); + +child.on('error', (error) => { + console.error(`[dev:web] 启动 Vite 失败: ${error.message}`); + process.exit(1); +}); + +child.on('exit', (code, signal) => { + if (signal) { + console.error(`[dev:web] Vite 被信号终止: ${signal}`); + process.exit(1); + } + + process.exit(code ?? 0); +}); diff --git a/scripts/spacetime-publish-maincloud.sh b/scripts/spacetime-publish-maincloud.sh new file mode 100644 index 00000000..805f67d9 --- /dev/null +++ b/scripts/spacetime-publish-maincloud.sh @@ -0,0 +1,125 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +SERVER_RS_DIR="${REPO_ROOT}/server-rs" +MODULE_PATH="${SERVER_RS_DIR}/target/wasm32-unknown-unknown/release/spacetime_module.wasm" +SPACETIME_SERVER_ALIAS="maincloud" +CLEAR_DATABASE=0 + +load_env_file() { + local env_file="$1" + local line key value + + if [[ ! -f "${env_file}" ]]; then + return + fi + + while IFS= read -r line || [[ -n "${line}" ]]; do + line="${line%$'\r'}" + line="${line#$'\xef\xbb\xbf'}" + [[ -z "${line}" || "${line}" == \#* ]] && continue + [[ "${line}" =~ ^([A-Za-z_][A-Za-z0-9_]*)=(.*)$ ]] || continue + key="${BASH_REMATCH[1]}" + value="${BASH_REMATCH[2]}" + value="${value%\"}" + value="${value#\"}" + value="${value%\'}" + value="${value#\'}" + if [[ -z "${!key+x}" ]]; then + export "${key}=${value}" + fi + done <"${env_file}" +} + +usage() { + cat <<'EOF' +用法: + npm run spacetime:publish:maincloud + npm run spacetime:publish:maincloud -- --database + npm run spacetime:publish:maincloud -- --clear-database + +说明: + 发布 server-rs/crates/spacetime-module 到 SpacetimeDB Maincloud。 + 数据库名优先读取 --database,其次读取 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE。 +EOF +} + +load_env_file "${REPO_ROOT}/.env" +load_env_file "${REPO_ROOT}/.env.local" + +SPACETIME_DATABASE="${GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE:-}" +SPACETIME_SERVER_URL="${GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL:-https://maincloud.spacetimedb.com}" + +while [[ $# -gt 0 ]]; do + case "$1" in + -h|--help) + usage + exit 0 + ;; + --database) + SPACETIME_DATABASE="${2:?缺少 --database 的值}" + shift 2 + ;; + --server-url) + SPACETIME_SERVER_URL="${2:?缺少 --server-url 的值}" + shift 2 + ;; + --clear-database) + CLEAR_DATABASE=1 + shift + ;; + *) + echo "[spacetime:maincloud] 未知参数: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if [[ -z "${SPACETIME_DATABASE}" ]]; then + echo "[spacetime:maincloud] 缺少 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE。" >&2 + echo "[spacetime:maincloud] 请在 .env.local 中配置,或通过 --database 传入。" >&2 + exit 1 +fi + +if ! command -v cargo >/dev/null 2>&1; then + echo "[spacetime:maincloud] 缺少 cargo 命令。" >&2 + exit 1 +fi + +if ! command -v spacetime >/dev/null 2>&1; then + echo "[spacetime:maincloud] 缺少 spacetime CLI,请先安装并登录 Maincloud。" >&2 + exit 1 +fi + +echo "[spacetime:maincloud] 构建 spacetime-module wasm" +cargo build \ + --manifest-path "${SERVER_RS_DIR}/Cargo.toml" \ + -p spacetime-module \ + --target wasm32-unknown-unknown \ + --release + +PUBLISH_ARGS=( + publish + "${SPACETIME_DATABASE}" + --server "${SPACETIME_SERVER_ALIAS}" + --bin-path "${MODULE_PATH}" + --yes +) + +if [[ "${CLEAR_DATABASE}" -eq 1 ]]; then + # Maincloud 清库只在 schema 冲突时触发,避免无冲突升级误删线上数据。 + PUBLISH_ARGS+=(-c=on-conflict) +fi + +echo "[spacetime:maincloud] 发布 SpacetimeDB wasm: ${SPACETIME_DATABASE} -> ${SPACETIME_SERVER_ALIAS}" +spacetime "${PUBLISH_ARGS[@]}" + +cat < Vec fn parse_big_fish_model_output( parsed: &JsonValue, ) -> Result { - serde_json::from_value::(parsed.clone()) - .map_err(|_| BigFishAgentTurnError::new("大鱼吃小鱼模型结果缺少必要内容,请稍后重试。")) + let reply_text = parsed + .get("replyText") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少有效回复,请稍后重试。"))? + .to_string(); + let progress_percent = parsed + .get("progressPercent") + .and_then(JsonValue::as_u64) + .map(|value| value.min(100) as u32) + .unwrap_or(0); + let next_anchor_pack_value = parsed + .get("nextAnchorPack") + .cloned() + .ok_or_else(|| BigFishAgentTurnError::new("大鱼吃小鱼聊天结果缺少 nextAnchorPack。"))?; + let next_anchor_pack = parse_big_fish_model_anchor_pack(&next_anchor_pack_value)?; + Ok(BigFishAgentModelOutput { + reply_text, + progress_percent, + next_anchor_pack, + }) +} + +fn parse_big_fish_model_anchor_pack( + value: &JsonValue, +) -> Result { + Ok(BigFishAnchorPack { + // LLM 与 HTTP 契约使用 camelCase;SpacetimeDB 持久化结构保持 Rust snake_case,边界处必须显式翻译。 + gameplay_promise: parse_big_fish_model_anchor_item(value, "gameplayPromise")?, + ecology_visual_theme: parse_big_fish_model_anchor_item(value, "ecologyVisualTheme")?, + growth_ladder: parse_big_fish_model_anchor_item(value, "growthLadder")?, + risk_tempo: parse_big_fish_model_anchor_item(value, "riskTempo")?, + }) +} + +fn parse_big_fish_model_anchor_item( + pack: &JsonValue, + field_name: &str, +) -> Result { + let value = pack.get(field_name).ok_or_else(|| { + BigFishAgentTurnError::new(format!("大鱼吃小鱼 anchor pack 缺少 {field_name}。")) + })?; + let key = value + .get("key") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .unwrap_or(field_name) + .to_string(); + let label = value + .get("label") + .and_then(JsonValue::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + .unwrap_or_else(|| default_big_fish_anchor_label(field_name)) + .to_string(); + let item_value = value + .get("value") + .and_then(JsonValue::as_str) + .map(str::trim) + .unwrap_or_default() + .to_string(); + let status = value + .get("status") + .and_then(JsonValue::as_str) + .map(parse_big_fish_anchor_status) + .unwrap_or(BigFishAnchorStatus::Missing); + + Ok(module_big_fish::BigFishAnchorItem { + key, + label, + value: item_value, + status, + }) +} + +fn default_big_fish_anchor_label(field_name: &str) -> &'static str { + match field_name { + "gameplayPromise" => "玩法承诺", + "ecologyVisualTheme" => "生态视觉主题", + "growthLadder" => "成长阶梯", + "riskTempo" => "风险节奏", + _ => "大鱼锚点", + } } fn serialize_record_anchor_pack(anchor_pack: &spacetime_client::BigFishAnchorPackRecord) -> String { - serde_json::to_string_pretty(&json!({ - "gameplayPromise": { - "key": anchor_pack.gameplay_promise.key, - "label": anchor_pack.gameplay_promise.label, - "value": anchor_pack.gameplay_promise.value, - "status": anchor_pack.gameplay_promise.status, - }, - "ecologyVisualTheme": { - "key": anchor_pack.ecology_visual_theme.key, - "label": anchor_pack.ecology_visual_theme.label, - "value": anchor_pack.ecology_visual_theme.value, - "status": anchor_pack.ecology_visual_theme.status, - }, - "growthLadder": { - "key": anchor_pack.growth_ladder.key, - "label": anchor_pack.growth_ladder.label, - "value": anchor_pack.growth_ladder.value, - "status": anchor_pack.growth_ladder.status, - }, - "riskTempo": { - "key": anchor_pack.risk_tempo.key, - "label": anchor_pack.risk_tempo.label, - "value": anchor_pack.risk_tempo.value, - "status": anchor_pack.risk_tempo.status, - }, - })) + serde_json::to_string_pretty(&map_big_fish_record_anchor_pack(anchor_pack)) .unwrap_or_else(|_| "{}".to_string()) } +fn map_big_fish_record_anchor_pack( + record: &spacetime_client::BigFishAnchorPackRecord, +) -> BigFishAnchorPack { + BigFishAnchorPack { + gameplay_promise: map_big_fish_record_anchor_item(&record.gameplay_promise), + ecology_visual_theme: map_big_fish_record_anchor_item(&record.ecology_visual_theme), + growth_ladder: map_big_fish_record_anchor_item(&record.growth_ladder), + risk_tempo: map_big_fish_record_anchor_item(&record.risk_tempo), + } +} + +fn map_big_fish_record_anchor_item( + record: &spacetime_client::BigFishAnchorItemRecord, +) -> module_big_fish::BigFishAnchorItem { + module_big_fish::BigFishAnchorItem { + key: record.key.clone(), + label: record.label.clone(), + value: record.value.clone(), + status: parse_big_fish_anchor_status(record.status.as_str()), + } +} + +fn parse_big_fish_anchor_status(value: &str) -> BigFishAnchorStatus { + match value { + "confirmed" => BigFishAnchorStatus::Confirmed, + "locked" => BigFishAnchorStatus::Locked, + "inferred" => BigFishAnchorStatus::Inferred, + _ => BigFishAnchorStatus::Missing, + } +} + fn parse_json_response_text(text: &str) -> Result { if let Ok(value) = serde_json::from_str::(text) { return Ok(value); diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index f80ff5f5..2804401d 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -394,19 +394,26 @@ impl AppConfig { config.oss_success_action_status = oss_success_action_status; } - if let Some(spacetime_server_url) = - read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"]) + if let Some(spacetime_server_url) = read_first_non_empty_env(&[ + "GENARRATIVE_SPACETIME_SERVER_URL", + "GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL", + ]) { config.spacetime_server_url = spacetime_server_url; } - if let Some(spacetime_database) = - read_first_non_empty_env(&["GENARRATIVE_SPACETIME_DATABASE"]) + if let Some(spacetime_database) = read_first_non_empty_env(&[ + "GENARRATIVE_SPACETIME_DATABASE", + "GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE", + ]) { config.spacetime_database = spacetime_database; } - config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]); + config.spacetime_token = read_first_non_empty_env(&[ + "GENARRATIVE_SPACETIME_TOKEN", + "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN", + ]); if let Some(spacetime_pool_size) = read_first_positive_u32_env(&["GENARRATIVE_SPACETIME_POOL_SIZE"]) { diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 42f391ba..365dfcc0 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -38,7 +38,8 @@ use spacetime_client::{ CustomWorldResultPreviewBlockerRecord, CustomWorldSupportedActionRecord, CustomWorldWorkSummaryRecord, SpacetimeClientError, }; -use std::convert::Infallible; +use std::{collections::BTreeSet, convert::Infallible, sync::Arc}; +use tokio::sync::Semaphore; use tokio::task::JoinSet; use tracing::info; @@ -65,6 +66,7 @@ use crate::{ }; const DRAFT_ASSET_GENERATION_MAX_ATTEMPTS: u32 = 3; +const DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS: usize = 2; pub async fn get_custom_world_library( State(state): State, @@ -1226,9 +1228,12 @@ fn spawn_custom_world_draft_foundation_job( } }; + let image_generation_limiter = Arc::new(Semaphore::new( + DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS, + )); let role_visual_profile_input = draft_profile_value.clone(); let act_background_profile_input = draft_profile_value.clone(); - // 角色主形象与幕背景图互不依赖,必须并行发起,避免底稿阶段串行等待两类图片。 + // 角色主形象与幕背景图互不依赖,必须并行发起;上游生图请求统一限流,避免同批草稿瞬时打满供应商接口。 let (role_visual_result, act_background_result) = tokio::join!( async { let mut profile = role_visual_profile_input; @@ -1238,6 +1243,7 @@ fn spawn_custom_world_draft_foundation_job( &owner_user_id, &operation_id, &mut profile, + image_generation_limiter.clone(), ) .await .map(|_| profile) @@ -1250,6 +1256,7 @@ fn spawn_custom_world_draft_foundation_job( &owner_user_id, &operation_id, &mut profile, + image_generation_limiter.clone(), ) .await .map(|_| profile) @@ -1386,6 +1393,7 @@ async fn generate_draft_foundation_role_visuals( owner_user_id: &str, operation_id: &str, draft_profile: &mut Value, + image_generation_limiter: Arc, ) -> Result<(), String> { let Some(profile_object) = draft_profile.as_object_mut() else { return Err("foundation draft JSON 必须是 object".to_string()); @@ -1439,17 +1447,25 @@ async fn generate_draft_foundation_role_visuals( for role_ref in role_generation_refs { let task_state = (*state).clone(); let task_owner_user_id = owner_user_id.to_string(); + let task_limiter = image_generation_limiter.clone(); generation_tasks.spawn(async move { let mut last_error = None; for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { - match generate_character_primary_visual_for_profile( - &task_state, - task_owner_user_id.as_str(), - role_ref.role_id.as_str(), - role_ref.prompt.as_str(), - Some(role_ref.name.as_str()), - ) - .await + let generation_result = { + let _permit = task_limiter + .acquire() + .await + .map_err(|error| format!("图片生成并发控制失效:{error}"))?; + generate_character_primary_visual_for_profile( + &task_state, + task_owner_user_id.as_str(), + role_ref.role_id.as_str(), + role_ref.prompt.as_str(), + Some(role_ref.name.as_str()), + ) + .await + }; + match generation_result { Ok(generated) => { return Ok::<_, String>((role_ref.key, role_ref.index, generated)); @@ -1499,7 +1515,7 @@ async fn generate_draft_foundation_role_visuals( } } if !errors.is_empty() { - return Err(errors.join(";")); + return Err(join_unique_error_messages(errors)); } Ok(()) } @@ -1510,6 +1526,7 @@ async fn generate_draft_foundation_act_backgrounds( owner_user_id: &str, operation_id: &str, draft_profile: &mut Value, + image_generation_limiter: Arc, ) -> Result<(), String> { let world_name = json_text_from_value(draft_profile, "name").unwrap_or_else(|| "未命名世界".to_string()); @@ -1536,20 +1553,28 @@ async fn generate_draft_foundation_act_backgrounds( let task_owner_user_id = owner_user_id.to_string(); let task_profile_id = profile_id.clone(); let task_world_name = world_name.clone(); + let task_limiter = image_generation_limiter.clone(); generation_tasks.spawn(async move { let mut last_error = None; for attempt in 1..=DRAFT_ASSET_GENERATION_MAX_ATTEMPTS { - match generate_custom_world_scene_image_for_profile( - &task_state, - task_owner_user_id.as_str(), - task_profile_id.as_deref(), - task_world_name.as_str(), - act_ref.scene_id.as_str(), - act_ref.title.as_str(), - act_ref.summary.as_str(), - act_ref.prompt.as_str(), - ) - .await + let generation_result = { + let _permit = task_limiter + .acquire() + .await + .map_err(|error| format!("图片生成并发控制失效:{error}"))?; + generate_custom_world_scene_image_for_profile( + &task_state, + task_owner_user_id.as_str(), + task_profile_id.as_deref(), + task_world_name.as_str(), + act_ref.scene_id.as_str(), + act_ref.title.as_str(), + act_ref.summary.as_str(), + act_ref.prompt.as_str(), + ) + .await + }; + match generation_result { Ok(generated) => { return Ok::<_, String>(( @@ -1571,7 +1596,9 @@ async fn generate_draft_foundation_act_backgrounds( } Err(format!( - "幕「{}」背景图连续生成 {} 次失败:{}", + "第{}章第{}幕「{}」背景图连续生成 {} 次失败:{}", + act_ref.chapter_index + 1, + act_ref.act_index + 1, act_ref.title, DRAFT_ASSET_GENERATION_MAX_ATTEMPTS, last_error.unwrap_or_else(|| "未知错误".to_string()) @@ -1617,11 +1644,23 @@ async fn generate_draft_foundation_act_backgrounds( } } if !errors.is_empty() { - return Err(errors.join(";")); + return Err(join_unique_error_messages(errors)); } Ok(()) } +fn join_unique_error_messages(messages: Vec) -> String { + // 并行图片任务可能从同一个上游故障返回完全相同的业务错误;用户侧只需要看到去重后的失败项。 + messages + .into_iter() + .map(|message| message.trim().to_string()) + .filter(|message| !message.is_empty()) + .collect::>() + .into_iter() + .collect::>() + .join(";") +} + struct RoleVisualGenerationRef { key: String, index: usize, diff --git a/server-rs/crates/api-server/src/custom_world_agent_entities.rs b/server-rs/crates/api-server/src/custom_world_agent_entities.rs index 0a97f16a..932eb888 100644 --- a/server-rs/crates/api-server/src/custom_world_agent_entities.rs +++ b/server-rs/crates/api-server/src/custom_world_agent_entities.rs @@ -488,6 +488,7 @@ fn create_stable_id(prefix: &str, name: &str, index: usize) -> String { #[cfg(test)] mod tests { use super::*; + use serde_json::json; #[test] fn character_expansion_prompt_keeps_node_contract_text() { diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 421e3f22..83b01497 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -1,7 +1,6 @@ use std::{ - env, fs, - path::{Path, PathBuf}, - time::{SystemTime, UNIX_EPOCH}, + collections::BTreeMap, + time::{Duration, Instant, SystemTime, UNIX_EPOCH}, }; use axum::{ @@ -13,7 +12,13 @@ use axum::{ sse::{Event, Sse}, }, }; -use serde_json::{Value, json}; +use module_assets::{ + AssetObjectAccessPolicy, AssetObjectFieldError, build_asset_entity_binding_input, + build_asset_object_upsert_input, generate_asset_binding_id, generate_asset_object_id, +}; +use module_puzzle::PuzzleGeneratedImageCandidate; +use platform_oss::{LegacyAssetPrefix, OssHeadObjectRequest, OssObjectAccess, OssPutObjectRequest}; +use serde_json::{Map, Value, json}; use shared_contracts::{ puzzle_agent::{ CreatePuzzleAgentSessionRequest, ExecutePuzzleAgentActionRequest, @@ -50,6 +55,7 @@ use spacetime_client::{ SpacetimeClientError, }; use std::convert::Infallible; +use tokio::time::sleep; use crate::{ ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, @@ -68,6 +74,10 @@ const PUZZLE_AGENT_API_BASE_PROVIDER: &str = "puzzle-agent"; const PUZZLE_WORKS_PROVIDER: &str = "puzzle-works"; const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery"; const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime"; +const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash"; +const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; +const PUZZLE_DEFAULT_NEGATIVE_PROMPT: &str = + "低清晰度,低质量,文字水印,畸形构图,过度模糊,重复肢体,画面脏污"; pub async fn create_puzzle_agent_session( State(state): State, @@ -451,29 +461,22 @@ pub async fn execute_puzzle_agent_action( .filter(|value| !value.trim().is_empty()) .unwrap_or_else(|| draft.summary.clone()); let candidate_count = payload.candidate_count.unwrap_or(2).clamp(1, 2); - let candidates = build_placeholder_puzzle_candidates( + let candidates = generate_puzzle_image_candidates( + &state, + owner_user_id.as_str(), &session.session_id, &draft.level_name, &prompt, candidate_count, ) + .await .map_err(SpacetimeClientError::Runtime); match candidates { Ok(candidates) => { let candidates_json = serde_json::to_string( &candidates .iter() - .map(|candidate| { - json!({ - "candidateId": candidate.candidate_id, - "imageSrc": candidate.image_src, - "assetId": candidate.asset_id, - "prompt": candidate.prompt, - "actualPrompt": candidate.actual_prompt, - "sourceType": candidate.source_type, - "selected": candidate.selected, - }) - }) + .map(to_puzzle_generated_image_candidate) .collect::>(), ) .map_err(|error| { @@ -1429,27 +1432,47 @@ fn puzzle_sse_error_event_message(message: String) -> Event { Event::default().event("error").data(payload) } -fn build_placeholder_puzzle_candidates( +async fn generate_puzzle_image_candidates( + state: &AppState, + owner_user_id: &str, session_id: &str, level_name: &str, prompt: &str, candidate_count: u32, ) -> Result, String> { let count = candidate_count.clamp(1, 2); - let mut items = Vec::with_capacity(count as usize); + let settings = + require_puzzle_dashscope_settings(state).map_err(|error| error.message().to_string())?; + let http_client = build_puzzle_dashscope_http_client(&settings) + .map_err(|error| error.message().to_string())?; + let generated = create_puzzle_text_to_image_generation( + &http_client, + &settings, + build_puzzle_image_prompt(level_name, prompt).as_str(), + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + "1024*1024", + count, + ) + .await + .map_err(|error| error.message().to_string())?; + let mut items = Vec::with_capacity(generated.images.len()); - for index in 0..count { - let asset = save_placeholder_puzzle_asset( + for (index, image) in generated.images.into_iter().enumerate() { + let candidate_id = format!("{session_id}-candidate-{}", index + 1); + let asset = persist_puzzle_generated_asset( + state, + owner_user_id, session_id, level_name, - &format!("candidate-{}", index + 1), - "cover", - "1536*1536", - Some(prompt), + candidate_id.as_str(), + generated.task_id.as_str(), + image, + current_utc_micros(), ) + .await .map_err(|error| error.message().to_string())?; items.push(PuzzleGeneratedImageCandidateResponse { - candidate_id: format!("{session_id}-candidate-{}", index + 1), + candidate_id, image_src: asset.image_src, asset_id: asset.asset_id, prompt: prompt.to_string(), @@ -1473,98 +1496,467 @@ fn build_placeholder_puzzle_candidates( .collect()) } +struct PuzzleDashScopeSettings { + base_url: String, + api_key: String, + request_timeout_ms: u64, +} + +struct PuzzleGeneratedImages { + task_id: String, + images: Vec, +} + +struct PuzzleDownloadedImage { + extension: String, + mime_type: String, + bytes: Vec, +} + struct GeneratedPuzzleAssetResponse { image_src: String, asset_id: String, } -fn save_placeholder_puzzle_asset( - session_segment_seed: &str, - work_segment_seed: &str, - leaf_segment_seed: &str, - file_stem: &str, +fn require_puzzle_dashscope_settings(state: &AppState) -> Result { + let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); + if base_url.is_empty() { + return Err(AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "dashscope", + "reason": "DASHSCOPE_BASE_URL 未配置", + }))); + } + + let api_key = state + .config + .dashscope_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "dashscope", + "reason": "DASHSCOPE_API_KEY 未配置", + })) + })?; + + Ok(PuzzleDashScopeSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1), + }) +} + +fn build_puzzle_dashscope_http_client( + settings: &PuzzleDashScopeSettings, +) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms)) + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "dashscope", + "message": format!("构造拼图 DashScope HTTP 客户端失败:{error}"), + })) + }) +} + +fn to_puzzle_generated_image_candidate( + candidate: &PuzzleGeneratedImageCandidateRecord, +) -> PuzzleGeneratedImageCandidate { + // SpacetimeDB 模块反序列化的是 module-puzzle 的持久化结构,必须保留 snake_case 字段名;HTTP 响应层再单独映射为 camelCase。 + PuzzleGeneratedImageCandidate { + candidate_id: candidate.candidate_id.clone(), + image_src: candidate.image_src.clone(), + asset_id: candidate.asset_id.clone(), + prompt: candidate.prompt.clone(), + actual_prompt: candidate.actual_prompt.clone(), + source_type: candidate.source_type.clone(), + selected: candidate.selected, + } +} + +async fn create_puzzle_text_to_image_generation( + http_client: &reqwest::Client, + settings: &PuzzleDashScopeSettings, + prompt: &str, + negative_prompt: &str, size: &str, - prompt: Option<&str>, + candidate_count: u32, +) -> Result { + let mut parameters = Map::from_iter([ + ("n".to_string(), json!(candidate_count.clamp(1, 2))), + ("size".to_string(), Value::String(size.to_string())), + ("prompt_extend".to_string(), Value::Bool(true)), + ("watermark".to_string(), Value::Bool(false)), + ]); + parameters.insert( + "negative_prompt".to_string(), + Value::String(negative_prompt.to_string()), + ); + + let response = http_client + .post(format!( + "{}/services/aigc/text2image/image-synthesis", + settings.base_url + )) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header("X-DashScope-Async", "enable") + .json(&json!({ + "model": PUZZLE_TEXT_TO_IMAGE_MODEL, + "input": { "prompt": prompt }, + "parameters": parameters, + })) + .send() + .await + .map_err(|error| map_puzzle_dashscope_request_error(format!("创建拼图图片生成任务失败:{error}")))?; + let status = response.status(); + let response_text = response.text().await.map_err(|error| { + map_puzzle_dashscope_request_error(format!("读取拼图图片生成响应失败:{error}")) + })?; + if !status.is_success() { + return Err(map_puzzle_dashscope_upstream_error( + response_text.as_str(), + "创建拼图图片生成任务失败", + )); + } + let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图图片生成响应失败")?; + let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": "拼图图片生成任务未返回 task_id", + })) + })?; + let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); + + while Instant::now() < deadline { + let poll_response = http_client + .get(format!("{}/tasks/{}", settings.base_url, task_id)) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .send() + .await + .map_err(|error| { + map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}")) + })?; + let poll_status = poll_response.status(); + let poll_text = poll_response.text().await.map_err(|error| { + map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}")) + })?; + if !poll_status.is_success() { + return Err(map_puzzle_dashscope_upstream_error( + poll_text.as_str(), + "查询拼图图片生成任务失败", + )); + } + let poll_payload = parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?; + let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status") + .unwrap_or_default() + .trim() + .to_string(); + if task_status == "SUCCEEDED" { + let image_urls = extract_puzzle_image_urls(&poll_payload); + if image_urls.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": "拼图图片生成成功但未返回图片地址", + }))); + } + let mut images = Vec::with_capacity(image_urls.len()); + for image_url in image_urls.into_iter().take(candidate_count.clamp(1, 2) as usize) { + images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); + } + return Ok(PuzzleGeneratedImages { task_id, images }); + } + if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { + return Err(map_puzzle_dashscope_upstream_error( + poll_text.as_str(), + "拼图图片生成任务失败", + )); + } + sleep(Duration::from_secs(2)).await; + } + + Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": "拼图图片生成超时或未返回图片地址", + }))) +} + +async fn download_puzzle_remote_image( + http_client: &reqwest::Client, + image_url: &str, +) -> Result { + let response = http_client.get(image_url).send().await.map_err(|error| { + map_puzzle_dashscope_request_error(format!("下载拼图正式图片失败:{error}")) + })?; + let status = response.status(); + let content_type = response + .headers() + .get(reqwest::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + let bytes = response.bytes().await.map_err(|error| { + map_puzzle_dashscope_request_error(format!("读取拼图正式图片内容失败:{error}")) + })?; + if !status.is_success() { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": "下载拼图正式图片失败", + "status": status.as_u16(), + }))); + } + let mime_type = normalize_puzzle_downloaded_image_mime_type(content_type.as_str()); + Ok(PuzzleDownloadedImage { + extension: puzzle_mime_to_extension(mime_type.as_str()).to_string(), + mime_type, + bytes: bytes.to_vec(), + }) +} + +async fn persist_puzzle_generated_asset( + state: &AppState, + owner_user_id: &str, + session_id: &str, + level_name: &str, + candidate_id: &str, + task_id: &str, + image: PuzzleDownloadedImage, + generated_at_micros: i64, ) -> Result { - let asset_id = format!("{file_stem}-{}", current_utc_millis()); - let relative_dir = PathBuf::from("generated-puzzle-covers") - .join(sanitize_path_segment(session_segment_seed, "session")) - .join(sanitize_path_segment(work_segment_seed, "puzzle")) - .join(sanitize_path_segment(leaf_segment_seed, "candidate")) - .join(&asset_id); - let output_dir = resolve_public_output_dir(&relative_dir)?; - fs::create_dir_all(&output_dir).map_err(io_error)?; - let file_name = format!("{file_stem}.svg"); - let svg = build_puzzle_placeholder_svg(size, prompt.unwrap_or(file_stem)); - fs::write(output_dir.join(&file_name), svg).map_err(io_error)?; + let oss_client = state.oss_client().ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "aliyun-oss", + "reason": "OSS 未完成环境变量配置", + })) + })?; + let http_client = reqwest::Client::new(); + let asset_id = format!("asset-{generated_at_micros}"); + let put_result = oss_client + .put_object( + &http_client, + OssPutObjectRequest { + prefix: LegacyAssetPrefix::PuzzleAssets, + path_segments: vec![ + sanitize_path_segment(session_id, "session"), + sanitize_path_segment(level_name, "puzzle"), + sanitize_path_segment(candidate_id, "candidate"), + asset_id.clone(), + ], + file_name: format!("image.{}", image.extension), + content_type: Some(image.mime_type.clone()), + access: OssObjectAccess::Private, + metadata: build_puzzle_asset_metadata(owner_user_id, session_id, candidate_id), + body: image.bytes, + }, + ) + .await + .map_err(map_puzzle_asset_oss_error)?; + let head = oss_client + .head_object( + &http_client, + OssHeadObjectRequest { + object_key: put_result.object_key.clone(), + }, + ) + .await + .map_err(map_puzzle_asset_oss_error)?; + let asset_object = state + .spacetime_client() + .confirm_asset_object( + build_asset_object_upsert_input( + generate_asset_object_id(generated_at_micros), + head.bucket, + head.object_key, + AssetObjectAccessPolicy::Private, + head.content_type.or(Some(image.mime_type)), + head.content_length, + head.etag, + "puzzle_cover_image".to_string(), + Some(task_id.to_string()), + Some(owner_user_id.to_string()), + None, + Some(session_id.to_string()), + generated_at_micros, + ) + .map_err(map_puzzle_asset_field_error)?, + ) + .await + .map_err(map_puzzle_asset_spacetime_error)?; + state + .spacetime_client() + .bind_asset_object_to_entity( + build_asset_entity_binding_input( + generate_asset_binding_id(generated_at_micros), + asset_object.asset_object_id, + PUZZLE_ENTITY_KIND.to_string(), + session_id.to_string(), + candidate_id.to_string(), + "puzzle_cover_image".to_string(), + Some(owner_user_id.to_string()), + None, + generated_at_micros, + ) + .map_err(map_puzzle_asset_field_error)?, + ) + .await + .map_err(map_puzzle_asset_spacetime_error)?; Ok(GeneratedPuzzleAssetResponse { - image_src: format!( - "/{}/{}", - relative_dir.to_string_lossy().replace('\\', "/"), - file_name - ), + image_src: put_result.legacy_public_path, asset_id, }) } -fn build_puzzle_placeholder_svg(size: &str, label: &str) -> String { - let (width, height) = parse_size(size); +fn build_puzzle_image_prompt(level_name: &str, prompt: &str) -> String { format!( - r##" - - - - - - - - - - - -{title} -Puzzle placeholder -"##, - width = width, - height = height, - cx1 = width / 4, - cy1 = height / 3, - r1 = (width.min(height) / 5).max(42), - cx2 = width * 3 / 4, - cy2 = height / 4, - r2 = (width.min(height) / 7).max(30), - frame_x = width / 9, - frame_y = height / 9, - frame_w = width * 7 / 9, - frame_h = height * 7 / 9, - frame_r = (width.min(height) / 20).max(18), - font_main = (width.min(height) / 14).max(22), - font_sub = (width.min(height) / 30).max(12), - title = escape_svg_text(label), + "生成一张适合做正方形拼图关卡的高清插画。关卡名:{level_name}。画面要求:{prompt}。必须有清晰主体、丰富但不混乱的区域层次、适合被切成 3x3 或 4x4 拼图块,避免文字、水印、边框和 UI 元素。" ) } -fn parse_size(size: &str) -> (u32, u32) { - let mut parts = size.split('*'); - let width = parts - .next() - .and_then(|value| value.trim().parse::().ok()) - .filter(|value| *value > 0) - .unwrap_or(1536); - let height = parts - .next() - .and_then(|value| value.trim().parse::().ok()) - .filter(|value| *value > 0) - .unwrap_or(1536); - (width, height) +fn build_puzzle_asset_metadata( + owner_user_id: &str, + session_id: &str, + candidate_id: &str, +) -> BTreeMap { + BTreeMap::from([ + ("asset_kind".to_string(), "puzzle_cover_image".to_string()), + ("owner_user_id".to_string(), owner_user_id.to_string()), + ("entity_kind".to_string(), PUZZLE_ENTITY_KIND.to_string()), + ("entity_id".to_string(), session_id.to_string()), + ("slot".to_string(), candidate_id.to_string()), + ]) } -fn escape_svg_text(value: &str) -> String { - value - .replace('&', "&") - .replace('<', "<") - .replace('>', ">") +fn parse_puzzle_json_payload(raw_text: &str, fallback_message: &str) -> Result { + serde_json::from_str::(raw_text).map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": format!("{fallback_message}:{error}"), + })) + }) +} + +fn extract_puzzle_task_id(payload: &Value) -> Option { + find_first_puzzle_string_by_key(payload, "task_id") +} + +fn extract_puzzle_image_urls(payload: &Value) -> Vec { + let mut urls = Vec::new(); + collect_puzzle_strings_by_key(payload, "image", &mut urls); + collect_puzzle_strings_by_key(payload, "url", &mut urls); + let mut deduped = Vec::new(); + for url in urls { + if !deduped.contains(&url) { + deduped.push(url); + } + } + deduped +} + +fn find_first_puzzle_string_by_key(payload: &Value, target_key: &str) -> Option { + let mut results = Vec::new(); + collect_puzzle_strings_by_key(payload, target_key, &mut results); + results.into_iter().next() +} + +fn collect_puzzle_strings_by_key(payload: &Value, target_key: &str, results: &mut Vec) { + match payload { + Value::Array(entries) => { + for entry in entries { + collect_puzzle_strings_by_key(entry, target_key, results); + } + } + Value::Object(object) => { + for (key, value) in object { + if key == target_key + && let Some(text) = value.as_str() + { + results.push(text.to_string()); + } + collect_puzzle_strings_by_key(value, target_key, results); + } + } + _ => {} + } +} + +fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or("image/jpeg"); + match mime_type { + "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { + mime_type.to_string() + } + _ => "image/jpeg".to_string(), + } +} + +fn puzzle_mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "jpg", + } +} + +fn map_puzzle_dashscope_request_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": message, + })) +} + +fn map_puzzle_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "dashscope", + "message": parse_puzzle_api_error_message(raw_text, fallback_message), + })) +} + +fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> String { + let trimmed = raw_text.trim(); + if trimmed.is_empty() { + return fallback_message.to_string(); + } + if let Ok(payload) = serde_json::from_str::(trimmed) + && let Some(message) = find_first_puzzle_string_by_key(&payload, "message") + { + return message; + } + fallback_message.to_string() +} + +fn map_puzzle_asset_oss_error(error: platform_oss::OssError) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "aliyun-oss", + "message": error.to_string(), + })) +} + +fn map_puzzle_asset_spacetime_error(error: SpacetimeClientError) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": error.to_string(), + })) +} + +fn map_puzzle_asset_field_error(error: AssetObjectFieldError) -> AppError { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "asset-object", + "message": error.to_string(), + })) } fn sanitize_path_segment(value: &str, fallback: &str) -> String { @@ -1588,31 +1980,9 @@ fn sanitize_path_segment(value: &str, fallback: &str) -> String { } } -fn resolve_public_output_dir(relative_dir: &Path) -> Result { - let workspace_root = Path::new(env!("CARGO_MANIFEST_DIR")) - .ancestors() - .nth(3) - .ok_or_else(|| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_message("无法定位仓库根目录") - })?; - Ok(workspace_root.join("public").join(relative_dir)) -} - -fn io_error(error: std::io::Error) -> AppError { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_message(error.to_string()) -} - fn current_utc_micros() -> i64 { let duration = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap_or_default(); (duration.as_secs() as i64) * 1_000_000 + i64::from(duration.subsec_micros()) } - -fn current_utc_millis() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() -} diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 3dc8cf13..59052ab9 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -690,8 +690,10 @@ pub fn build_generated_candidates( let candidate_id = format!("{session_id}-candidate-{}", index + 1); PuzzleGeneratedImageCandidate { candidate_id: candidate_id.clone(), + // 拼图候选图的正式持久化由 api-server 上传 OSS;这里仅保留 reducer + // 单测/保底路径构造,前缀必须与 OSS 兼容路由一致,不能再指向 public 目录。 image_src: format!( - "/generated-puzzle-covers/{session_id}/{candidate_seed}/cover.svg" + "/generated-puzzle-assets/{session_id}/{candidate_seed}/cover.svg" ), asset_id: format!("puzzle-cover-{candidate_seed}"), prompt: prompt.clone(), @@ -1550,6 +1552,21 @@ mod tests { ); } + #[test] + fn generated_candidates_use_oss_compatible_prefix() { + let anchor_pack = infer_anchor_pack("雨夜猫咪", Some("雨夜猫咪")); + let draft = compile_result_draft(&anchor_pack, &[]); + let candidates = build_generated_candidates("session-1", None, &draft, 2, 1_000) + .expect("candidates should build"); + + assert_eq!(candidates.len(), 2); + assert!(candidates[0] + .image_src + .starts_with("/generated-puzzle-assets/session-1/")); + let legacy_public_prefix = ["generated-puzzle", "covers"].join("-"); + assert!(!candidates[0].image_src.contains(&legacy_public_prefix)); + } + #[test] fn tag_similarity_score_uses_jaccard() { let score = tag_similarity_score( diff --git a/server-rs/crates/platform-oss/src/lib.rs b/server-rs/crates/platform-oss/src/lib.rs index ce2dceb0..b719a1d6 100644 --- a/server-rs/crates/platform-oss/src/lib.rs +++ b/server-rs/crates/platform-oss/src/lib.rs @@ -40,6 +40,7 @@ pub enum LegacyAssetPrefix { Characters, Animations, BigFishAssets, + PuzzleAssets, CustomWorldScenes, CustomWorldCovers, QwenSprites, @@ -209,6 +210,7 @@ impl LegacyAssetPrefix { "generated-characters" => Some(Self::Characters), "generated-animations" => Some(Self::Animations), "generated-big-fish-assets" => Some(Self::BigFishAssets), + "generated-puzzle-assets" => Some(Self::PuzzleAssets), "generated-custom-world-scenes" => Some(Self::CustomWorldScenes), "generated-custom-world-covers" => Some(Self::CustomWorldCovers), "generated-qwen-sprites" => Some(Self::QwenSprites), @@ -222,6 +224,7 @@ impl LegacyAssetPrefix { Self::Characters => "generated-characters", Self::Animations => "generated-animations", Self::BigFishAssets => "generated-big-fish-assets", + Self::PuzzleAssets => "generated-puzzle-assets", Self::CustomWorldScenes => "generated-custom-world-scenes", Self::CustomWorldCovers => "generated-custom-world-covers", Self::QwenSprites => "generated-qwen-sprites", diff --git a/server-rs/crates/spacetime-client/src/custom_world.rs b/server-rs/crates/spacetime-client/src/custom_world.rs index f47ca19e..fbd97c37 100644 --- a/server-rs/crates/spacetime-client/src/custom_world.rs +++ b/server-rs/crates/spacetime-client/src/custom_world.rs @@ -460,6 +460,41 @@ impl SpacetimeClient { .await } + pub async fn upsert_custom_world_agent_operation_progress( + &self, + input: CustomWorldAgentOperationProgressRecordInput, + ) -> Result { + let procedure_input = CustomWorldAgentOperationProgressInput { + session_id: input.session_id, + owner_user_id: input.owner_user_id, + operation_id: input.operation_id, + operation_type: parse_rpg_agent_operation_type_record(input.operation_type.as_str())?, + operation_status: parse_rpg_agent_operation_status_record( + input.operation_status.as_str(), + )?, + phase_label: input.phase_label, + phase_detail: input.phase_detail, + operation_progress: input.operation_progress, + error_message: input.error_message, + updated_at_micros: input.updated_at_micros, + }; + + self.call_after_connect(move |connection, sender| { + connection + .procedures() + .upsert_custom_world_agent_operation_progress_then( + procedure_input, + move |_, result| { + let mapped = result + .map_err(|error| SpacetimeClientError::Procedure(error.to_string())) + .and_then(map_custom_world_agent_operation_procedure_result); + send_once(&sender, mapped); + }, + ); + }) + .await + } + pub async fn get_custom_world_agent_operation( &self, session_id: String, diff --git a/server-rs/crates/spacetime-client/src/mapper.rs b/server-rs/crates/spacetime-client/src/mapper.rs index 4432521d..294feaf0 100644 --- a/server-rs/crates/spacetime-client/src/mapper.rs +++ b/server-rs/crates/spacetime-client/src/mapper.rs @@ -2762,6 +2762,41 @@ pub(crate) fn format_rpg_agent_operation_status( } } +pub(crate) fn parse_rpg_agent_operation_type_record( + value: &str, +) -> Result { + match value.trim() { + "process_message" => Ok(crate::module_bindings::RpgAgentOperationType::ProcessMessage), + "draft_foundation" => Ok(crate::module_bindings::RpgAgentOperationType::DraftFoundation), + "update_draft_card" => Ok(crate::module_bindings::RpgAgentOperationType::UpdateDraftCard), + "sync_result_profile" => { + Ok(crate::module_bindings::RpgAgentOperationType::SyncResultProfile) + } + "generate_characters" => { + Ok(crate::module_bindings::RpgAgentOperationType::GenerateCharacters) + } + "generate_landmarks" => { + Ok(crate::module_bindings::RpgAgentOperationType::GenerateLandmarks) + } + "generate_role_assets" => { + Ok(crate::module_bindings::RpgAgentOperationType::GenerateRoleAssets) + } + "sync_role_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncRoleAssets), + "generate_scene_assets" => { + Ok(crate::module_bindings::RpgAgentOperationType::GenerateSceneAssets) + } + "sync_scene_assets" => Ok(crate::module_bindings::RpgAgentOperationType::SyncSceneAssets), + "expand_long_tail" => Ok(crate::module_bindings::RpgAgentOperationType::ExpandLongTail), + "publish_world" => Ok(crate::module_bindings::RpgAgentOperationType::PublishWorld), + "revert_checkpoint" => Ok(crate::module_bindings::RpgAgentOperationType::RevertCheckpoint), + "delete_characters" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteCharacters), + "delete_landmarks" => Ok(crate::module_bindings::RpgAgentOperationType::DeleteLandmarks), + other => Err(SpacetimeClientError::Runtime(format!( + "未知 rpg agent operation type: {other}" + ))), + } +} + pub(crate) fn parse_rpg_agent_operation_status_record( value: &str, ) -> Result { @@ -3710,6 +3745,20 @@ pub struct CustomWorldAgentMessageFinalizeRecordInput { pub updated_at_micros: i64, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CustomWorldAgentOperationProgressRecordInput { + pub session_id: String, + pub owner_user_id: String, + pub operation_id: String, + pub operation_type: String, + pub operation_status: String, + pub phase_label: String, + pub phase_detail: String, + pub operation_progress: u32, + pub error_message: Option, + pub updated_at_micros: i64, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct CustomWorldAgentActionExecuteRecordInput { pub session_id: String, diff --git a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs index d1817e04..26fc2191 100644 --- a/server-rs/crates/spacetime-client/src/module_bindings/mod.rs +++ b/server-rs/crates/spacetime-client/src/module_bindings/mod.rs @@ -115,6 +115,7 @@ pub mod custom_world_agent_message_snapshot_type; pub mod custom_world_agent_message_submit_input_type; pub mod custom_world_agent_operation_type; pub mod custom_world_agent_operation_get_input_type; +pub mod custom_world_agent_operation_progress_input_type; pub mod custom_world_agent_operation_procedure_result_type; pub mod custom_world_agent_operation_progress_input_type; pub mod custom_world_agent_operation_snapshot_type; @@ -342,6 +343,7 @@ pub mod start_ai_task_reducer; pub mod start_ai_task_stage_reducer; pub mod turn_in_quest_reducer; pub mod unpublish_custom_world_profile_reducer; +pub mod upsert_custom_world_agent_operation_progress_procedure; pub mod upsert_chapter_progression_reducer; pub mod upsert_custom_world_profile_reducer; pub mod upsert_npc_state_reducer; @@ -587,6 +589,7 @@ pub use custom_world_agent_message_snapshot_type::CustomWorldAgentMessageSnapsho pub use custom_world_agent_message_submit_input_type::CustomWorldAgentMessageSubmitInput; pub use custom_world_agent_operation_type::CustomWorldAgentOperation; pub use custom_world_agent_operation_get_input_type::CustomWorldAgentOperationGetInput; +pub use custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput; pub use custom_world_agent_operation_procedure_result_type::CustomWorldAgentOperationProcedureResult; pub use custom_world_agent_operation_progress_input_type::CustomWorldAgentOperationProgressInput; pub use custom_world_agent_operation_snapshot_type::CustomWorldAgentOperationSnapshot; @@ -856,6 +859,7 @@ pub use start_ai_task_reducer::start_ai_task; pub use start_ai_task_stage_reducer::start_ai_task_stage; pub use turn_in_quest_reducer::turn_in_quest; pub use unpublish_custom_world_profile_reducer::unpublish_custom_world_profile; +pub use upsert_custom_world_agent_operation_progress_procedure::upsert_custom_world_agent_operation_progress; pub use upsert_chapter_progression_reducer::upsert_chapter_progression; pub use upsert_custom_world_profile_reducer::upsert_custom_world_profile; pub use upsert_npc_state_reducer::upsert_npc_state; diff --git a/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx b/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx new file mode 100644 index 00000000..c9d52eef --- /dev/null +++ b/src/components/big-fish-creation/BigFishAgentWorkspace.interaction.test.tsx @@ -0,0 +1,104 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import type { BigFishSessionSnapshotResponse } from '../../../packages/shared/src/contracts/bigFish'; +import { BigFishAgentWorkspace } from './BigFishAgentWorkspace'; + +const baseSession: BigFishSessionSnapshotResponse = { + sessionId: 'big-fish-session-1', + currentTurn: 3, + progressPercent: 64, + stage: 'collecting_anchors', + anchorPack: { + gameplayPromise: { + key: 'gameplayPromise', + label: '玩法承诺', + value: '从微光小鱼一路吞噬成长为深海巨兽', + status: 'confirmed', + }, + ecologyVisualTheme: { + key: 'ecologyVisualTheme', + label: '生态视觉主题', + value: '幽蓝珊瑚海沟', + status: 'confirmed', + }, + growthLadder: { + key: 'growthLadder', + label: '成长阶梯', + value: '', + status: 'missing', + }, + riskTempo: { + key: 'riskTempo', + label: '风险节奏', + value: '', + status: 'missing', + }, + }, + draft: null, + assetSlots: [], + assetCoverage: { + levelMainImageReadyCount: 0, + levelMotionReadyCount: 0, + backgroundReady: false, + requiredLevelCount: 8, + publishReady: false, + blockers: [], + }, + messages: [ + { + id: 'message-1', + role: 'assistant', + kind: 'chat', + text: '爽点和生态已经清楚,继续补剩余关键词。', + createdAt: '2026-04-24T10:00:00.000Z', + }, + ], + lastAssistantReply: '爽点和生态已经清楚,继续补剩余关键词。', + publishReady: false, + updatedAt: '2026-04-24T10:00:00.000Z', +}; + +beforeEach(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; + } +}); + +test('big fish workspace submits quick keyword fill request after two turns', async () => { + const user = userEvent.setup(); + const onSubmitMessage = vi.fn(); + + render( + {}} + onSubmitMessage={onSubmitMessage} + onExecuteAction={() => {}} + />, + ); + + await user.click(screen.getByRole('button', { name: '补充剩余关键字' })); + + expect(onSubmitMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: '请补充剩余关键字。', + }), + ); +}); + +test('big fish workspace hides keyword fill before two turns', () => { + render( + {}} + onSubmitMessage={() => {}} + onExecuteAction={() => {}} + />, + ); + + expect(screen.queryByRole('button', { name: '补充剩余关键字' })).toBeNull(); +}); diff --git a/src/components/big-fish-creation/BigFishAgentWorkspace.tsx b/src/components/big-fish-creation/BigFishAgentWorkspace.tsx index d15bf12c..bd9673ab 100644 --- a/src/components/big-fish-creation/BigFishAgentWorkspace.tsx +++ b/src/components/big-fish-creation/BigFishAgentWorkspace.tsx @@ -84,6 +84,17 @@ export function BigFishAgentWorkspace({ isStreamingReply={Boolean(streamingReplyText)} isBusy={isBusy} error={error} + quickActions={[ + { + key: 'summarize', + label: '总结当前设定', + }, + { + key: 'quickFill', + label: '补充剩余关键字', + minTurn: 2, + }, + ]} onBack={onBack} onSubmitText={(text) => { onSubmitMessage({ @@ -94,6 +105,15 @@ export function BigFishAgentWorkspace({ onPrimaryAction={() => { onExecuteAction({ action: 'big_fish_compile_draft' }); }} + onQuickAction={(action) => { + onSubmitMessage({ + clientMessageId: createCreationAgentClientMessageId('big-fish'), + text: + action.key === 'quickFill' + ? '请补充剩余关键字。' + : '请总结一下当前已经成形的大鱼吃小鱼设定。', + }); + }} /> ); } diff --git a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx index 0032211e..c729e33c 100644 --- a/src/components/big-fish-runtime/BigFishRuntimeShell.tsx +++ b/src/components/big-fish-runtime/BigFishRuntimeShell.tsx @@ -2,13 +2,16 @@ import { ArrowLeft, Loader2 } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import type { + BigFishAssetSlotResponse, BigFishRuntimeEntityResponse, BigFishRuntimeSnapshotResponse, SubmitBigFishInputRequest, } from '../../../packages/shared/src/contracts/bigFish'; +import { ResolvedAssetImage } from '../ResolvedAssetImage'; type BigFishRuntimeShellProps = { run: BigFishRuntimeSnapshotResponse | null; + assetSlots?: BigFishAssetSlotResponse[]; isBusy?: boolean; error?: string | null; onBack: () => void; @@ -54,32 +57,87 @@ function projectEntity( }; } +function findBigFishAssetSlot( + slots: BigFishAssetSlotResponse[], + assetKind: string, + level?: number, + motionKey?: string, +) { + return slots.find((slot) => { + if (slot.assetKind !== assetKind || slot.status !== 'ready') { + return false; + } + if (level !== undefined && slot.level !== level) { + return false; + } + if (motionKey !== undefined && slot.motionKey !== motionKey) { + return false; + } + return true; + }); +} + +function resolveRuntimeEntityAsset( + entity: BigFishRuntimeEntityResponse, + assetSlots: BigFishAssetSlotResponse[], +) { + return ( + findBigFishAssetSlot(assetSlots, 'level_motion', entity.level, 'move_swim') ?? + findBigFishAssetSlot(assetSlots, 'level_motion', entity.level, 'idle_float') ?? + findBigFishAssetSlot(assetSlots, 'level_main_image', entity.level) + ); +} + function BigFishEntityDot({ entity, run, owned, + assetSlots, }: { entity: BigFishRuntimeEntityResponse; run: BigFishRuntimeSnapshotResponse; owned: boolean; + assetSlots: BigFishAssetSlotResponse[]; }) { const projected = projectEntity(entity, run); const isLeader = run.leaderEntityId === entity.entityId; + const assetSlot = resolveRuntimeEntityAsset(entity, assetSlots); + const entityImageSrc = assetSlot?.assetUrl?.trim() || null; return (
run.playerLevel - ? 'border-rose-100/70 bg-rose-500/88 shadow-rose-950/24' - : 'border-emerald-100/70 bg-emerald-400/88 shadow-emerald-950/20' + className={`absolute -translate-x-1/2 -translate-y-1/2 overflow-hidden rounded-full border shadow-lg transition-all ${ + entityImageSrc + ? owned + ? isLeader + ? 'border-cyan-50 shadow-cyan-950/40' + : 'border-cyan-100/80 shadow-cyan-950/28' + : entity.level > run.playerLevel + ? 'border-rose-100/80 shadow-rose-950/28' + : 'border-emerald-100/80 shadow-emerald-950/24' + : owned + ? isLeader + ? 'border-cyan-100 bg-cyan-300 shadow-cyan-950/30' + : 'border-cyan-100/70 bg-cyan-500/88 shadow-cyan-950/24' + : entity.level > run.playerLevel + ? 'border-rose-100/70 bg-rose-500/88 shadow-rose-950/24' + : 'border-emerald-100/70 bg-emerald-400/88 shadow-emerald-950/20' }`} style={projected} > - + {entityImageSrc ? ( + <> + +
+ + ) : null} + {entity.level}
@@ -88,6 +146,7 @@ function BigFishEntityDot({ export function BigFishRuntimeShell({ run, + assetSlots = [], isBusy = false, error = null, onBack, @@ -142,10 +201,20 @@ export function BigFishRuntimeShell({ const statusLabel = run.status === 'won' ? '通关' : run.status === 'failed' ? '失败' : '进行中'; + const backgroundAsset = + findBigFishAssetSlot(assetSlots, 'stage_background')?.assetUrl?.trim() || null; return (
+ {backgroundAsset ? ( + + ) : null} +
@@ -168,6 +237,7 @@ export function BigFishRuntimeShell({ entity={entity} run={run} owned={false} + assetSlots={assetSlots} /> ))} {run.ownedEntities.map((entity) => ( @@ -176,6 +246,7 @@ export function BigFishRuntimeShell({ entity={entity} run={run} owned + assetSlots={assetSlots} /> ))}
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index 15425bd9..845938d5 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -61,11 +61,11 @@ import { } from '../../services/puzzle-agent'; import { getPuzzleGalleryDetail } from '../../services/puzzle-gallery'; import { - advancePuzzleNextLevel, - dragPuzzlePieceOrGroup, - startPuzzleRun, - swapPuzzlePieces, -} from '../../services/puzzle-runtime'; + advanceLocalPuzzleLevel, + dragLocalPuzzlePiece, + startLocalPuzzleRun, + swapLocalPuzzlePieces, +} from '../../services/puzzle-runtime/puzzleLocalRuntime'; import { deletePuzzleWork, listPuzzleWorks } from '../../services/puzzle-works'; import { deleteRpgEntryWorldProfile } from '../../services/rpg-entry'; import { getRpgEntryWorldGalleryDetailByCode } from '../../services/rpg-entry/rpgEntryLibraryClient'; @@ -1028,8 +1028,9 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); try { - const { run } = await startPuzzleRun({ profileId }); - setPuzzleRun(run); + const { item } = await getPuzzleGalleryDetail(profileId); + setSelectedPuzzleDetail(item); + setPuzzleRun(startLocalPuzzleRun(item)); setSelectionStage('puzzle-runtime'); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '启动拼图玩法失败。')); @@ -1064,28 +1065,19 @@ export function PlatformEntryFlowShellImpl({ ); const swapPuzzlePiecesInRun = useCallback( - async (payload: { firstPieceId: string; secondPieceId: string }) => { + (payload: { firstPieceId: string; secondPieceId: string }) => { if (!puzzleRun || isPuzzleBusy) { return; } - setIsPuzzleBusy(true); setPuzzleError(null); - - try { - const { run } = await swapPuzzlePieces(puzzleRun.runId, payload); - setPuzzleRun(run); - } catch (error) { - setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。')); - } finally { - setIsPuzzleBusy(false); - } + setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload)); }, - [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage], + [isPuzzleBusy, puzzleRun], ); const dragPuzzlePiece = useCallback( - async (payload: { + (payload: { pieceId: string; targetRow: number; targetCol: number; @@ -1094,19 +1086,10 @@ export function PlatformEntryFlowShellImpl({ return; } - setIsPuzzleBusy(true); setPuzzleError(null); - - try { - const { run } = await dragPuzzlePieceOrGroup(puzzleRun.runId, payload); - setPuzzleRun(run); - } catch (error) { - setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。')); - } finally { - setIsPuzzleBusy(false); - } + setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload)); }, - [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage], + [isPuzzleBusy, puzzleRun], ); const advancePuzzleLevel = useCallback(async () => { @@ -1114,18 +1097,9 @@ export function PlatformEntryFlowShellImpl({ return; } - setIsPuzzleBusy(true); setPuzzleError(null); - - try { - const { run } = await advancePuzzleNextLevel(puzzleRun.runId); - setPuzzleRun(run); - } catch (error) { - setPuzzleError(resolvePuzzleErrorMessage(error, '进入下一关失败。')); - } finally { - setIsPuzzleBusy(false); - } - }, [isPuzzleBusy, puzzleRun, resolvePuzzleErrorMessage]); + setPuzzleRun(advanceLocalPuzzleLevel(puzzleRun)); + }, [isPuzzleBusy, puzzleRun]); const leaveAgentWorkspace = useCallback(() => { enterCreateTab(); @@ -1872,6 +1846,7 @@ export function PlatformEntryFlowShellImpl({ > { diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx new file mode 100644 index 00000000..34363323 --- /dev/null +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -0,0 +1,103 @@ +/* @vitest-environment jsdom */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, expect, test, vi } from 'vitest'; + +import type { PuzzleAgentSessionSnapshot } from '../../../packages/shared/src/contracts/puzzleAgentSession'; +import { PuzzleAgentWorkspace } from './PuzzleAgentWorkspace'; + +const baseSession: PuzzleAgentSessionSnapshot = { + sessionId: 'puzzle-session-1', + currentTurn: 3, + progressPercent: 62, + stage: 'collecting_anchors', + anchorPack: { + themePromise: { + key: 'themePromise', + label: '题材承诺', + value: '雾港遗迹拼图', + status: 'confirmed', + }, + visualSubject: { + key: 'visualSubject', + label: '画面主体', + value: '潮雾中的灯塔与断桥', + status: 'confirmed', + }, + visualMood: { + key: 'visualMood', + label: '视觉气质', + value: '', + status: 'missing', + }, + compositionHooks: { + key: 'compositionHooks', + label: '拼图记忆点', + value: '', + status: 'missing', + }, + tagsAndForbidden: { + key: 'tagsAndForbidden', + label: '标签与禁忌', + value: '', + status: 'missing', + }, + }, + draft: null, + messages: [ + { + id: 'message-1', + role: 'assistant', + kind: 'chat', + text: '画面主体已经清楚,继续收束剩余关键词。', + createdAt: '2026-04-24T10:00:00.000Z', + }, + ], + lastAssistantReply: '画面主体已经清楚,继续收束剩余关键词。', + publishedProfileId: null, + suggestedActions: [], + resultPreview: null, + updatedAt: '2026-04-24T10:00:00.000Z', +}; + +beforeEach(() => { + if (!Element.prototype.scrollIntoView) { + Element.prototype.scrollIntoView = () => {}; + } +}); + +test('puzzle workspace submits quick keyword fill request after two turns', async () => { + const user = userEvent.setup(); + const onSubmitMessage = vi.fn(); + + render( + {}} + onSubmitMessage={onSubmitMessage} + onExecuteAction={() => {}} + />, + ); + + await user.click(screen.getByRole('button', { name: '补充剩余关键字' })); + + expect(onSubmitMessage).toHaveBeenCalledWith( + expect.objectContaining({ + text: '请补充剩余关键字。', + }), + ); +}); + +test('puzzle workspace hides keyword fill before two turns', () => { + render( + {}} + onSubmitMessage={() => {}} + onExecuteAction={() => {}} + />, + ); + + expect(screen.queryByRole('button', { name: '补充剩余关键字' })).toBeNull(); +}); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx index b31803f9..f7cf70ed 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx @@ -100,6 +100,17 @@ export function PuzzleAgentWorkspace({ isStreamingReply={Boolean(streamingReplyText)} isBusy={isBusy} error={error} + quickActions={[ + { + key: 'summarize', + label: '总结当前设定', + }, + { + key: 'quickFill', + label: '补充剩余关键字', + minTurn: 2, + }, + ]} onBack={onBack} onSubmitText={(text) => { onSubmitMessage({ @@ -110,6 +121,15 @@ export function PuzzleAgentWorkspace({ onPrimaryAction={() => { onExecuteAction({ action: 'compile_puzzle_draft' }); }} + onQuickAction={(action) => { + onSubmitMessage({ + clientMessageId: createCreationAgentClientMessageId('puzzle'), + text: + action.key === 'quickFill' + ? '请补充剩余关键字。' + : '请总结一下当前已经成形的拼图设定。', + }); + }} /> ); } diff --git a/src/components/rpg-entry/useRpgCreationSessionController.ts b/src/components/rpg-entry/useRpgCreationSessionController.ts index a8bdecc9..4256fbef 100644 --- a/src/components/rpg-entry/useRpgCreationSessionController.ts +++ b/src/components/rpg-entry/useRpgCreationSessionController.ts @@ -76,6 +76,9 @@ export function useRpgCreationSessionController( const hasRequestedInitialAgentWorkspaceAuthRef = useRef(false); const isAgentDraftResultAutoOpenSuppressedRef = useRef(false); const currentAgentSessionIdRef = useRef(null); + const activeAgentReplyAbortControllerRef = useRef( + null, + ); const latestAgentSessionSyncRequestIdRef = useRef(0); const [isCreatingAgentSession, setIsCreatingAgentSession] = useState(false); @@ -128,6 +131,11 @@ export function useRpgCreationSessionController( latestAgentSessionSyncRequestIdRef.current += 1; }, []); + const abortActiveAgentReplyStream = useCallback(() => { + activeAgentReplyAbortControllerRef.current?.abort(); + activeAgentReplyAbortControllerRef.current = null; + }, []); + const mergePendingAgentUserMessageIntoSession = useCallback( ( session: CustomWorldAgentSessionSnapshot | null, @@ -223,8 +231,26 @@ export function useRpgCreationSessionController( setSelectionStage('agent-workspace'); }, [enterCreateTab, openLoginModal, persistAgentUiState, setSelectionStage, userId]); + useEffect(() => { + if ( + selectionStage !== 'agent-workspace' && + selectionStage !== 'custom-world-generating' + ) { + abortActiveAgentReplyStream(); + setStreamingAgentReplyText(''); + setIsStreamingAgentReply(false); + } + }, [abortActiveAgentReplyStream, selectionStage]); + + useEffect(() => { + return () => { + abortActiveAgentReplyStream(); + }; + }, [abortActiveAgentReplyStream]); + useEffect(() => { if (!activeAgentSessionId) { + abortActiveAgentReplyStream(); invalidateAgentSessionSyncRequests(); setAgentSession(null); setAgentOperation(null); @@ -238,6 +264,7 @@ export function useRpgCreationSessionController( } if (!userId) { + abortActiveAgentReplyStream(); invalidateAgentSessionSyncRequests(); setAgentSession(null); setAgentOperation(null); @@ -255,6 +282,7 @@ export function useRpgCreationSessionController( activeAgentSessionId === initialAgentUiStateRef.current.activeSessionId; if (currentAgentSessionIdRef.current !== activeAgentSessionId) { + abortActiveAgentReplyStream(); setAgentSession(null); setAgentOperation(null); setStreamingAgentReplyText(''); @@ -306,6 +334,7 @@ export function useRpgCreationSessionController( }; }, [ activeAgentSessionId, + abortActiveAgentReplyStream, enterCreateTab, invalidateAgentSessionSyncRequests, persistAgentUiState, @@ -540,6 +569,8 @@ export function useRpgCreationSessionController( setStreamingAgentReplyText(''); setIsStreamingAgentReply(true); setPendingAgentUserMessage(pendingMessagePayload); + const replyAbortController = new AbortController(); + activeAgentReplyAbortControllerRef.current = replyAbortController; setAgentSession((current) => mergePendingAgentUserMessageIntoSession(current, pendingMessagePayload), ); @@ -550,10 +581,17 @@ export function useRpgCreationSessionController( payload, { onUpdate: (text) => { + if (replyAbortController.signal.aborted) { + return; + } setStreamingAgentReplyText(text); }, + signal: replyAbortController.signal, }, ); + if (replyAbortController.signal.aborted) { + return; + } const mergedNextSession = mergePendingAgentUserMessageIntoSession( nextSession, pendingMessagePayload, @@ -568,6 +606,9 @@ export function useRpgCreationSessionController( hasServerEchoedPendingMessage ? null : pendingMessagePayload, ); } catch (error) { + if (replyAbortController.signal.aborted) { + return; + } const errorMessage = resolveRpgCreationErrorMessage( error, '发送共创消息失败。', @@ -597,7 +638,12 @@ export function useRpgCreationSessionController( setStreamingAgentReplyText(''); persistAgentUiState(activeAgentSessionId, null); } finally { - setIsStreamingAgentReply(false); + if (activeAgentReplyAbortControllerRef.current === replyAbortController) { + activeAgentReplyAbortControllerRef.current = null; + } + if (!replyAbortController.signal.aborted) { + setIsStreamingAgentReply(false); + } } }, [ diff --git a/src/services/aiTypes.ts b/src/services/aiTypes.ts index 5ddc423f..ad0cab8c 100644 --- a/src/services/aiTypes.ts +++ b/src/services/aiTypes.ts @@ -52,6 +52,7 @@ export interface StoryRequestOptions { export interface TextStreamOptions { onUpdate?: (text: string) => void; + signal?: AbortSignal; } export interface CustomWorldSceneImageRequest { diff --git a/src/services/puzzle-runtime/puzzleLocalRuntime.ts b/src/services/puzzle-runtime/puzzleLocalRuntime.ts new file mode 100644 index 00000000..5480b24c --- /dev/null +++ b/src/services/puzzle-runtime/puzzleLocalRuntime.ts @@ -0,0 +1,195 @@ +import type { + DragPuzzlePieceRequest, + PuzzleBoardSnapshot, + PuzzleGridSize, + PuzzlePieceState, + PuzzleRunSnapshot, + SwapPuzzlePiecesRequest, +} from '../../../packages/shared/src/contracts/puzzleRuntimeSession'; +import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary'; + +function resolvePuzzleGridSize(clearedLevelCount: number): PuzzleGridSize { + return clearedLevelCount >= 3 ? 4 : 3; +} + +function buildInitialPositions(gridSize: PuzzleGridSize) { + const positions = Array.from({ length: gridSize * gridSize }, (_, index) => ({ + row: Math.floor(index / gridSize), + col: index % gridSize, + })); + return positions.slice(1).concat(positions.slice(0, 1)); +} + +function rebuildBoardSnapshot( + gridSize: PuzzleGridSize, + pieces: PuzzlePieceState[], +): PuzzleBoardSnapshot { + const resolvedPieceIds = new Set( + pieces + .filter( + (piece) => + piece.currentRow === piece.correctRow && + piece.currentCol === piece.correctCol, + ) + .map((piece) => piece.pieceId), + ); + const allTilesResolved = resolvedPieceIds.size === pieces.length; + + return { + rows: gridSize, + cols: gridSize, + pieces: pieces.map((piece) => ({ + ...piece, + mergedGroupId: resolvedPieceIds.has(piece.pieceId) + ? 'resolved-main' + : null, + })), + mergedGroups: resolvedPieceIds.size + ? [ + { + groupId: 'resolved-main', + pieceIds: Array.from(resolvedPieceIds), + occupiedCells: pieces + .filter((piece) => resolvedPieceIds.has(piece.pieceId)) + .map((piece) => ({ + row: piece.currentRow, + col: piece.currentCol, + })), + }, + ] + : [], + selectedPieceId: null, + allTilesResolved, + }; +} + +function buildInitialBoard(gridSize: PuzzleGridSize): PuzzleBoardSnapshot { + const shuffledPositions = buildInitialPositions(gridSize); + const pieces = Array.from({ length: gridSize * gridSize }, (_, index) => { + const correctRow = Math.floor(index / gridSize); + const correctCol = index % gridSize; + const current = shuffledPositions[index]; + return { + pieceId: `piece-${index}`, + correctRow, + correctCol, + currentRow: current.row, + currentCol: current.col, + mergedGroupId: null, + }; + }); + return rebuildBoardSnapshot(gridSize, pieces); +} + +function applyNextBoard( + run: PuzzleRunSnapshot, + nextBoard: PuzzleBoardSnapshot, +): PuzzleRunSnapshot { + if (!run.currentLevel) { + return run; + } + const status = nextBoard.allTilesResolved ? 'cleared' : 'playing'; + return { + ...run, + clearedLevelCount: + status === 'cleared' && run.currentLevel.status !== 'cleared' + ? run.clearedLevelCount + 1 + : run.clearedLevelCount, + currentLevel: { + ...run.currentLevel, + board: nextBoard, + status, + }, + }; +} + +export function startLocalPuzzleRun(item: PuzzleWorkSummary): PuzzleRunSnapshot { + const gridSize = resolvePuzzleGridSize(0); + return { + runId: `local-puzzle-run-${item.profileId}-${Date.now()}`, + entryProfileId: item.profileId, + clearedLevelCount: 0, + currentLevelIndex: 1, + currentGridSize: gridSize, + playedProfileIds: [item.profileId], + previousLevelTags: item.themeTags, + currentLevel: { + runId: `local-puzzle-run-${item.profileId}`, + levelIndex: 1, + gridSize, + profileId: item.profileId, + levelName: item.levelName, + authorDisplayName: item.authorDisplayName, + themeTags: item.themeTags, + coverImageSrc: item.coverImageSrc, + board: buildInitialBoard(gridSize), + status: 'playing', + }, + recommendedNextProfileId: null, + }; +} + +export function swapLocalPuzzlePieces( + run: PuzzleRunSnapshot, + payload: SwapPuzzlePiecesRequest, +): PuzzleRunSnapshot { + const currentLevel = run.currentLevel; + if (!currentLevel || currentLevel.status === 'cleared') { + return run; + } + const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece })); + const first = pieces.find((piece) => piece.pieceId === payload.firstPieceId); + const second = pieces.find((piece) => piece.pieceId === payload.secondPieceId); + if (!first || !second) { + return run; + } + const firstPosition = { row: first.currentRow, col: first.currentCol }; + first.currentRow = second.currentRow; + first.currentCol = second.currentCol; + second.currentRow = firstPosition.row; + second.currentCol = firstPosition.col; + + return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces)); +} + +export function dragLocalPuzzlePiece( + run: PuzzleRunSnapshot, + payload: DragPuzzlePieceRequest, +): PuzzleRunSnapshot { + const currentLevel = run.currentLevel; + if (!currentLevel || currentLevel.status === 'cleared') { + return run; + } + if ( + payload.targetRow < 0 || + payload.targetCol < 0 || + payload.targetRow >= currentLevel.gridSize || + payload.targetCol >= currentLevel.gridSize + ) { + return run; + } + const pieces = currentLevel.board.pieces.map((piece) => ({ ...piece })); + const moving = pieces.find((piece) => piece.pieceId === payload.pieceId); + if (!moving) { + return run; + } + const occupying = pieces.find( + (piece) => + piece.pieceId !== payload.pieceId && + piece.currentRow === payload.targetRow && + piece.currentCol === payload.targetCol, + ); + const source = { row: moving.currentRow, col: moving.currentCol }; + moving.currentRow = payload.targetRow; + moving.currentCol = payload.targetCol; + if (occupying) { + occupying.currentRow = source.row; + occupying.currentCol = source.col; + } + + return applyNextBoard(run, rebuildBoardSnapshot(currentLevel.gridSize, pieces)); +} + +export function advanceLocalPuzzleLevel(run: PuzzleRunSnapshot): PuzzleRunSnapshot { + return run; +} diff --git a/src/services/rpg-creation/rpgCreationAgentClient.ts b/src/services/rpg-creation/rpgCreationAgentClient.ts index 377ad81b..f4540731 100644 --- a/src/services/rpg-creation/rpgCreationAgentClient.ts +++ b/src/services/rpg-creation/rpgCreationAgentClient.ts @@ -66,6 +66,7 @@ export async function streamRpgCreationMessage( `/api/runtime${RPG_AGENT_API_BASE}/${encodeURIComponent(sessionId)}/messages/stream`, payload, '发送共创消息失败', + options.signal, ); return readCreationAgentSessionFromSse(response, { diff --git a/src/services/rpg-creation/rpgCreationRequestHelpers.ts b/src/services/rpg-creation/rpgCreationRequestHelpers.ts index ba4a1678..7e3624f0 100644 --- a/src/services/rpg-creation/rpgCreationRequestHelpers.ts +++ b/src/services/rpg-creation/rpgCreationRequestHelpers.ts @@ -21,11 +21,13 @@ export async function openRpgCreationSsePost( url: string, payload: unknown, fallbackMessage: string, + signal?: AbortSignal, ) { const response = await fetchWithApiAuth(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), + signal, }); if (!response.ok) {