diff --git a/.codex/skills/spacetimedb-cli/SKILL.md b/.codex/skills/spacetimedb-cli/SKILL.md index 3bd0d454..ad00a2d8 100644 --- a/.codex/skills/spacetimedb-cli/SKILL.md +++ b/.codex/skills/spacetimedb-cli/SKILL.md @@ -44,8 +44,8 @@ spacetime generate --lang typescript|csharp|rust|unrealcpp --out-dir ./bindings ### Publishing & Deployment ```bash -# Publish to Maincloud (default) -spacetime publish my-database --yes +# Publish to an explicit server +spacetime publish my-database --server http://127.0.0.1:3101 --yes # Publish to local server spacetime publish my-database --server local --yes @@ -133,8 +133,8 @@ spacetime logout | Name | URL | Description | |------|-----|-------------| -| `maincloud` | `https://maincloud.spacetimedb.com` | Production cloud (default) | | `local` | `http://127.0.0.1:3000` | Local development server | +| `dev` | `http://127.0.0.1:3101` | Genarrative local development server | ## Common Workflows @@ -224,6 +224,6 @@ rustup target add wasm32-unknown-unknown ## Notes - Many commands are marked UNSTABLE and may change -- Default server is `maincloud` unless configured otherwise +- Genarrative scripts should pass `--server` or `--server-url` explicitly instead of relying on the CLI default - Use `--yes` flag in scripts to avoid interactive prompts - Dev mode watches files and auto-rebuilds on changes diff --git a/.env.example b/.env.example index 879be395..e8b61440 100644 --- a/.env.example +++ b/.env.example @@ -119,6 +119,11 @@ RPG_LLM_WEB_SEARCH_ENABLED="true" DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1" DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY" +# Server-side APIMart image generation config for optional puzzle image models. +APIMART_BASE_URL="https://api.apimart.ai/v1" +APIMART_API_KEY="YOUR_APIMART_API_KEY" +APIMART_IMAGE_REQUEST_TIMEOUT_MS="180000" + # 阿里云 OSS 配置。 # Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量, # 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。 diff --git a/.env.local b/.env.local index 95f32753..6ff657dd 100644 --- a/.env.local +++ b/.env.local @@ -54,11 +54,7 @@ GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101" GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr" GENARRATIVE_SPACETIME_TOKEN="" -GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="https://maincloud.spacetimedb.com" -GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr" -GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="" - # admin GENARRATIVE_ADMIN_USERNAME=admin GENARRATIVE_ADMIN_PASSWORD=123456 -ADMIN_API_TARGET=http://127.0.0.1:8082 \ No newline at end of file +ADMIN_API_TARGET=http://127.0.0.1:8082 diff --git a/AGENTS.md b/AGENTS.md index 20606d92..babe33e5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ - 后端路线固定为 `server-rs + Axum + SpacetimeDB`。旧 `server-node`、Express、PostgreSQL 不再作为兼容目标;历史实现只能作为迁移参考,若旧文档与 DDD 约束冲突,先修正文档和方案再编码。 - DDD 分层边界按总纲执行:领域规则沉到 `module-*`,SpacetimeDB 表和事务编排留在 `spacetime-module`,后端访问 SpacetimeDB 统一经 `spacetime-client` facade,HTTP/SSE/BFF 留在 `api-server`,外部副作用留在 `platform-*`,前后端 DTO 留在 `shared-contracts`。 - 前端只做表现、交互和临时 UI 状态,不承接正式业务真相,不绕过后端投影或后端 API 直接实现业务规则。 -- 修改后端代码后,按对应 DDD 文档中的验收命令执行测试;涉及 API smoke 时继续使用 `npm run api-server:maincloud` 验证并确认 `/healthz`。 +- 修改后端代码后,按对应 DDD 文档中的验收命令执行测试;涉及 API smoke 时使用 `npm run api-server` 重新拉起后端并执行相应自动测试,同时确认 `/healthz`。 - 凡是涉及 SpacetimeDB 的设计、实现、脚本、调试、前端绑定接入,统一显式使用以下 skill 作为执行依据: - [$spacetimedb-cli](.codex\\skills\\spacetimedb-cli\\SKILL.md) - [$spacetimedb-rust](.codex\\skills\\spacetimedb-rust\\SKILL.md) @@ -36,6 +36,8 @@ - 涉及 `crates/spacetime-module` 的表、reducer、view、Rust API 使用时,按 `spacetimedb-rust` 与 `spacetimedb-concepts` 执行。 - 涉及前端或 Node 侧的 SpacetimeDB TypeScript SDK、订阅、绑定使用时,按 `spacetimedb-typescript` 与 `spacetimedb-concepts` 执行。 - 若仓库内旧实现或旧文档与这些 skill 冲突,先修正文档和方案,再继续编码。 +- 修改后端代码后,必须使用 `npm run api-server` 自动重新运行后端,并执行相应自动测试;不要再使用旧的后端重启命令。 +- 数据库表结构更改后,需要对齐migration.rs ## 文档图谱 diff --git a/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md b/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md index 7ca7e503..e4225fb9 100644 --- a/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md +++ b/docs/audits/engineering/RPG_FRONTEND_SCRIPT_BACKEND_MIGRATION_AUDIT_2026-04-28.md @@ -628,7 +628,7 @@ SpacetimeDB 方向: 4. LLM、OSS、图片生成等外部 I/O 放在 `api-server` / `platform-*` crate 中,再把确定结果写回 SpacetimeDB。 5. 前端调用 reducer 使用生成绑定和对象参数,不编辑生成代码。 6. 涉及表结构修改时同步更新 `migration.rs`。 -7. 修改后端代码后统一执行 `npm run api-server:maincloud`,并跑对应自动测试。 +7. 修改后端代码后统一执行 `npm run api-server`,并跑对应自动测试。 ## 9. 最小验收标准 diff --git a/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md b/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md index ff65dd05..cf861d8c 100644 --- a/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md +++ b/docs/audits/engineering/SERVER_NODE_FREEZE_AND_DEPRECATION_2026-04-24.md @@ -109,7 +109,7 @@ 1. `package.json` 中不存在 `server-node:*`、`dev:node`、`m7:api-compare`、`check:server-node-freeze` 等旧入口。 2. `scripts/` 下不存在 `dev-node.mjs`、`smoke-server-node.ts`、`m7-api-compare.ts`、`smoke-same-origin-stack.ts` 等旧 Node 后端脚本。 3. `package.json` 与 `package-lock.json` 中不存在 `express`、`@types/express`、`pg`、`postgres` 依赖。 -4. 当前开发入口继续固定为 `npm run dev`、`npm run dev:web`、`npm run api-server:maincloud` 与 Rust / SpacetimeDB 相关脚本,不恢复旧 Node 后端切换开关。 +4. 当前开发入口继续固定为 `npm run dev`、`npm run dev:web`、`npm run api-server` 与 Rust / SpacetimeDB 相关脚本,不恢复旧 Node 后端切换开关。 ## 9. Caddy 本地服务入口移除(2026-04-26) diff --git a/docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md b/docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md index 1d490fc8..e99b495a 100644 --- a/docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md +++ b/docs/experience/PROJECT_WORK_EXPERIENCE_PLAYBOOK.md @@ -255,14 +255,14 @@ node scripts/vite-cli.mjs --port=3000 --host=0.0.0.0 后端代码更新后统一执行: ```bash -npm run api-server:maincloud +npm run api-server ``` 执行要求: - 该命令是后端更新后的默认重启入口,不再使用此前的后端重启命令。 - 重启后必须继续执行与本次后端改动对应的自动测试;涉及 Rust workspace 时优先跑 `server-rs` 下的检查或测试脚本。 -- 若本次改动涉及 SpacetimeDB 发布、绑定生成或 Maincloud 联调,按 `spacetimedb-cli` 经验执行,并在验证记录中写清楚实际命令与结果。 +- 若本次改动涉及 SpacetimeDB 发布、绑定生成或本地联调,按 `spacetimedb-cli` 经验执行,并在验证记录中写清楚实际命令与结果。 ## 14. 一句话总结 diff --git a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md index 6c2cea76..58044fec 100644 --- a/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md +++ b/docs/prd/AI_NATIVE_CUSTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md @@ -493,12 +493,15 @@ interface CustomWorldCoverProfile { 第一版必须有: 1. `进入世界` +2. `删除作品` 可选: 1. `查看作品` 2. `基于此作品继续创作` +`删除作品` 必须放在已发布卡片主操作的左侧,并在创作页内弹出二次确认面板后再执行删除。 + 第一版不强制做“基于已发布作品继续创作”,避免先把发布后再开草稿链带复杂。 --- @@ -884,7 +887,7 @@ type SelectionStage = 1. 不做完整 Agent 工作区 2. 不做世界底稿生成 -3. 不做作品删除确认流 +3. 不做独立的作品删除管理后台,删除确认统一收口在创作页内完成 4. 不做作品搜索排序高级功能 5. 不做发布世界管理后台 6. 不做已发布作品的二次派生创作 @@ -901,9 +904,10 @@ type SelectionStage = 2. 创作页面能同时展示草稿和已发布作品。 3. 草稿作品可以继续创作。 4. 已发布作品可以进入世界。 -5. 新建作品入口可以正确创建 Agent session 并跳转到创作工作区。 -6. 页面在移动端首屏可用,信息层级清楚。 -7. 草稿与已发布作品都通过后端聚合接口返回,前端不自己拼数据来源。 +5. 已发布作品卡的删除按钮位于主操作左侧,点击后先弹出创作页内的二次确认面板。 +6. 新建作品入口可以正确创建 Agent session 并跳转到创作工作区。 +7. 页面在移动端首屏可用,信息层级清楚。 +8. 草稿与已发布作品都通过后端聚合接口返回,前端不自己拼数据来源。 --- diff --git a/docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md b/docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md index 60aad456..091c8353 100644 --- a/docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md +++ b/docs/reference/RPG_CREATION_AND_RUNTIME_SCRIPT_RESPONSIBILITY_MAP_2026-04-28.md @@ -753,7 +753,7 @@ RPG 运行时链: 这些脚本不直接参与玩法,但直接支撑开发、发布、绑定和检查: -### `scripts/api-server-maincloud.mjs` +### `scripts/api-server-dev.mjs` 职责: diff --git a/docs/technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md b/docs/technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md index a0a0ac71..e5eda6e8 100644 --- a/docs/technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md +++ b/docs/technical/ADMIN_WEB_CONSOLE_TECHNICAL_SOLUTION_2026-04-30.md @@ -259,7 +259,7 @@ export interface ProfileInviteCodeAdminResponse { `tableStats` 中单表失败必须展示 `errorMessage`,不能让整页变成空白。SpacetimeDB private 表或当前身份不可见的表在 `/sql` 下可能返回 `no such table` / `marked private`,后台服务必须将这类错误归一为“不可统计(private 或当前身份不可见)”,避免把预期的访问边界展示成原始 HTTP 400 故障。 -线上如果大量表都显示“不可统计(private 或当前身份不可见)”,优先检查 `api-server` 启动环境中的 `GENARRATIVE_SPACETIME_TOKEN` / `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN` 是否存在且属于目标库 owner。Jenkins 覆盖发布包时必须保留部署目录已有运行 token;只带迁移 token 不能让后台概览读取 private 表。 +线上如果大量表都显示“不可统计(private 或当前身份不可见)”,优先检查 `api-server` 启动环境中的 `GENARRATIVE_SPACETIME_TOKEN` 是否存在且属于目标库 owner。Jenkins 覆盖发布包时必须保留部署目录已有运行 token;只带迁移 token 不能让后台概览读取 private 表。 ### 4.6 API 调试 contract @@ -380,7 +380,7 @@ export interface ProfileInviteCodeAdminResponse { ### 7.1 本地联调 -1. 启动后端:`npm run api-server:maincloud`。 +1. 启动后端:`npm run api-server`。 2. 启动后台前端:在 `apps/admin-web` 执行 `npm run dev`。 3. 后台 dev server 通过 Vite proxy 转发 `/admin/api` 到 `ADMIN_API_TARGET`;未配置时默认 `http://127.0.0.1:3100`。 4. 若使用非 3100 端口,在仓库根目录 `.env.local` 设置 `ADMIN_API_TARGET=http://127.0.0.1:`,并重启后台前端 dev server。 @@ -437,7 +437,7 @@ export interface ProfileInviteCodeAdminResponse { - 后续接入根 workspace 后,补充后台工程 build/typecheck 脚本。 3. 后端: - 继续保留 `cargo test -p api-server --manifest-path server-rs/Cargo.toml admin`。 - - 修改后端管理 API 后必须运行 `npm run api-server:maincloud` 并手动验证 `/admin` 为 404、`/admin/api/login` 可用。 + - 修改后端管理 API 后必须运行 `npm run api-server` 并手动验证 `/admin` 为 404、`/admin/api/login` 可用。 ## 9. 后续扩展边界 diff --git a/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md b/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md index 8367add3..ffd2b6e2 100644 --- a/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md +++ b/docs/technical/AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md @@ -11,7 +11,7 @@ ## 2. 根因 -### 2.1 Maincloud 目标库挂起 +### 2.1 远端目标库挂起 CLI 直接查询 `xushi-p4wfr` 返回: @@ -20,7 +20,7 @@ Error: database is suspended HTTP status server error (503 Service Unavailable) ``` -这说明 `maincloud.spacetimedb.com` 入口在线,但具体数据库 `xushi-p4wfr` 当前不可订阅、不可查 schema、不可执行 SQL。所有依赖该库的 procedure 都会失败。 +这说明远端 SpacetimeDB 入口在线,但具体数据库 `xushi-p4wfr` 当前不可订阅、不可查 schema、不可执行 SQL。所有依赖该库的 procedure 都会失败。 ### 2.2 认证快照同步被当成硬失败 @@ -64,7 +64,7 @@ HTTP status server error (503 Service Unavailable) ## 4. 本地可跑链路 -Maincloud `xushi-p4wfr` 挂起期间,抓大鹅本地体验应使用本地 SpacetimeDB: +远端 `xushi-p4wfr` 挂起期间,抓大鹅本地体验应使用本地 SpacetimeDB: ```powershell spacetime --root-dir=server-rs/.spacetimedb/local start --edition standalone --listen-addr 127.0.0.1:3101 @@ -75,10 +75,10 @@ spacetime --root-dir=server-rs/.spacetimedb/local publish xushi-p4wfr --server h 再让 Rust API 指向本地库: ```powershell -$env:GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL="http://127.0.0.1:3101" -$env:GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE="xushi-p4wfr" -$env:GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN="" -npm run api-server:maincloud +$env:GENARRATIVE_SPACETIME_SERVER_URL="http://127.0.0.1:3101" +$env:GENARRATIVE_SPACETIME_DATABASE="xushi-p4wfr" +$env:GENARRATIVE_SPACETIME_TOKEN="" +npm run api-server ``` 最后重启前端: @@ -96,10 +96,10 @@ npm run dev:web 1. `GET http://127.0.0.1:3000/api/auth/login-options` 返回 `["phone","password"]`。 2. `GET http://127.0.0.1:3000/api/runtime/match3d/gallery` 返回 `{"items":[]}`,不再返回 SpacetimeDB 503。 3. 未登录请求 `POST http://127.0.0.1:3000/api/creation/match3d/sessions` 返回 `401`,说明同源请求已进入 Rust 鉴权层,不再被 Vite `404`。 -4. 隔离端口指向挂起的 Maincloud 并使用 mock 短信时,手机号验证码登录返回 `200` 和 token;日志只记录“认证快照写入 SpacetimeDB 失败,当前认证流程继续”。 +4. 隔离端口指向挂起的远端库并使用 mock 短信时,手机号验证码登录返回 `200` 和 token;日志只记录“认证快照写入 SpacetimeDB 失败,当前认证流程继续”。 ## 6. 后续 -1. Maincloud `xushi-p4wfr` 仍需恢复数据库挂起状态,否则正式云端玩法 procedure 仍不可用。 +1. 远端 `xushi-p4wfr` 仍需恢复数据库挂起状态,否则对应玩法 procedure 仍不可用。 2. 本地开发如只为体验抓大鹅,可继续使用本地 SpacetimeDB 链路。 -3. 认证快照同步失败会影响进程重启后的云端恢复完整性,需要在 Maincloud 恢复后重新完成一次成功同步。 +3. 认证快照同步失败会影响进程重启后的远端恢复完整性,需要在目标库恢复后重新完成一次成功同步。 diff --git a/docs/technical/BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md b/docs/technical/BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md index 726a3e04..0eb3d515 100644 --- a/docs/technical/BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md +++ b/docs/technical/BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md @@ -7,7 +7,7 @@ 但当前链路仍暴露出两个直接体验问题: 1. 前端草稿进度页仍把大鱼吃小鱼展示成单个 `compile` 步骤,用户会感觉“整个生成过程只有一步,而且一直卡在第一步”。 -2. 前端在打开大鱼草稿或结果页时,会通过 `GET /api/runtime/big-fish/agent/sessions/:sessionId` 拉取完整会话;当 Maincloud 上游偶发抖动时,Rust `spacetime-client` 统一 10 秒超时会直接映射成 `502`,用户会看到反复报错。 +2. 前端在打开大鱼草稿或结果页时,会通过 `GET /api/runtime/big-fish/agent/sessions/:sessionId` 拉取完整会话;当 SpacetimeDB 上游偶发抖动时,Rust `spacetime-client` 统一 10 秒超时会直接映射成 `502`,用户会看到反复报错。 ## 修复口径 @@ -39,7 +39,7 @@ 这样可以覆盖两类常见情况: -1. Maincloud 连接偶发抖动,第一次 procedure 超时但第二次马上恢复。 +1. SpacetimeDB 连接偶发抖动,第一次 procedure 超时但第二次马上恢复。 2. 用户打开草稿页时碰到短暂断链,不再被立即判定成稳定的坏网关故障。 ## 落地范围 diff --git a/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md b/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md index b0fc049d..4cdaf4fa 100644 --- a/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md +++ b/docs/technical/JENKINS_SPACETIMEDB_DATABASE_MIGRATION_PIPELINES_2026-04-29.md @@ -39,7 +39,7 @@ Genarrative-Database-Export 关键参数: 1. `DATABASE`:目标 SpacetimeDB 数据库名;留空时读取仓库环境变量。 -2. `SERVER`:SpacetimeDB server 别名,默认 `maincloud`。 +2. `SERVER`:SpacetimeDB server 别名,默认 `dev`。 3. `SERVER_URL`:显式服务地址;填写后优先于 `SERVER`。 4. `DEPLOY_DIRECTORY`:固定部署目录,默认 `/var/lib/jenkins/deploy/Genarrative`。 5. `ROOT_DIR`:可选,透传给 `spacetime --root-dir`;为空时使用 `/.spacetimedb`。 @@ -91,7 +91,7 @@ Genarrative-Database-Import ## 5. 本地部署测试参数 -`Genarrative-Build-And-Deploy` 增加以下本地发布包参数,便于在 Jenkins 中测试本地 SpacetimeDB,不依赖 Maincloud: +`Genarrative-Build-And-Deploy` 增加以下本地发布包参数,便于在 Jenkins 中测试本地 SpacetimeDB: 1. `DATABASE`:发布包默认数据库名,默认 `genarrative-pipeline-local-test`。SpacetimeDB CLI 当前要求数据库名匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`,只能使用小写字母、数字,并用单个短横线分隔;不要使用大写字母、点号、下划线、首尾短横线或连续短横线。 2. `API_PORT`:发布包内 api-server 端口,默认 `8082`。 @@ -107,7 +107,7 @@ SERVER_URL=http://127.0.0.1:3101 DEPLOY_DIRECTORY=/var/lib/jenkins/deploy/Genarrative ``` -这样脚本会自动使用 `/var/lib/jenkins/deploy/Genarrative/.spacetimedb` 作为 `spacetime --root-dir`,避免回退到 Jenkins 用户全局 CLI 登录态,也避免误连 Maincloud。 +这样脚本会自动使用 `/var/lib/jenkins/deploy/Genarrative/.spacetimedb` 作为 `spacetime --root-dir`,避免回退到 Jenkins 用户全局 CLI 登录态,也避免误连非本地目标。 ## 6. 文件清单 diff --git a/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md b/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md index 3b7bb215..664e8931 100644 --- a/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md +++ b/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md @@ -76,4 +76,4 @@ Responses 非流式解析优先读取 `output_text`,再兼容 `output[].conten 3. `platform-llm` 单测覆盖 Responses 非流式、Responses SSE、Responses web_search tools 请求体。 4. `cargo test -p platform-llm --manifest-path server-rs/Cargo.toml` 通过。 5. `cargo test -p api-server creation_agent_llm_turn --manifest-path server-rs/Cargo.toml` 通过。 -6. 修改后按项目约束使用 `npm run api-server:maincloud` 重新启动后端,并执行相应自动测试。 +6. 修改后按项目约束使用 `npm run api-server` 重新启动后端,并执行相应自动测试。 diff --git a/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md b/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md index 3b4a3c59..e57c1d58 100644 --- a/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md +++ b/docs/technical/M7_TEST_DEPLOY_CUTOVER_EXECUTION_PLAN_2026-04-22.md @@ -2,7 +2,7 @@ 日期:`2026-04-22` -归档说明:截至 `2026-04-26`,Rust 迁移已完成,旧 `server-node/` 已删除,M7 阶段性预检包装入口已移除。后续长期检查统一使用 `server-rs/scripts/check.ps1`、`server-rs/scripts/smoke.ps1`、`server-rs/scripts/oss-smoke.ps1` 与 `npm run api-server:maincloud`。 +归档说明:截至 `2026-04-26`,Rust 迁移已完成,旧 `server-node/` 已删除,M7 阶段性预检包装入口已移除。后续长期检查统一使用 `server-rs/scripts/check.ps1`、`server-rs/scripts/smoke.ps1`、`server-rs/scripts/oss-smoke.ps1` 与 `npm run api-server`。 ## 1. 文档目标 diff --git a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md index a5bf15fb..d8816907 100644 --- a/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md +++ b/docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md @@ -784,7 +784,7 @@ B3 当前落地状态: 1. 创作到发布到试玩主链通过。 2. 运行态点击、入槽、三消、失败、胜利通过。 3. 移动端视口检查通过。 -4. `npm run api-server:maincloud` 通过。 +4. `npm run api-server` 通过。 5. 对应测试与 `npm run check:encoding` 通过。 --- @@ -820,7 +820,7 @@ npm run check:encoding -- docs/technical/MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IM ```powershell cargo test -p module-match3d cargo test -p shared-contracts -npm run api-server:maincloud +npm run api-server npm run check:encoding ``` diff --git a/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md b/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md index 1411fc43..32203a52 100644 --- a/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md +++ b/docs/technical/MATCH3D_DOMAIN_AND_CONTRACTS_STAGE1_2026-04-30.md @@ -110,4 +110,4 @@ server-rs/crates/module-match3d 1. `cargo test -p module-match3d` 通过。 2. `cargo test -p shared-contracts match3d` 通过。 3. `npm run check:encoding` 覆盖新增中文文档和新增源码。 -4. 本阶段不要求运行 `npm run api-server:maincloud`,因为未修改后端运行服务入口、SpacetimeDB 表或 `api-server` facade。 +4. 本阶段不要求运行 `npm run api-server`,因为未修改后端运行服务入口、SpacetimeDB 表或 `api-server` facade。 diff --git a/docs/technical/MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md b/docs/technical/MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md index 40a8b19f..99e408ea 100644 --- a/docs/technical/MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md +++ b/docs/technical/MATCH3D_Q1_INTEGRATION_ACCEPTANCE_2026-05-01.md @@ -143,4 +143,4 @@ npm run check:encoding 3. 不把 Match3D 公开广场并入更复杂的推荐、排行和运营榜单策略。 4. 不删除 `/match3d` 本地 playground;它作为开发调试入口继续保留。 5. 全量 `npm run typecheck` 曾存在非 Match3D 既有阻塞,本轮以 Q1 定向测试和后端定向检查作为集成验收口径。 -6. Maincloud 运行态仍依赖当前 SpacetimeDB 环境稳定性;如 `npm run api-server:maincloud` 现场遇到订阅 HTTP 500,应按 Maincloud/SpacetimeDB 联调链路单独排查。 +6. 运行态仍依赖当前 SpacetimeDB 环境稳定性;如 `npm run api-server` 现场遇到订阅 HTTP 500,应按本地 SpacetimeDB 联调链路单独排查。 diff --git a/docs/technical/MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md b/docs/technical/MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md index e6a95861..ecf2f746 100644 --- a/docs/technical/MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md +++ b/docs/technical/MATCH3D_SPACETIME_CLIENT_AND_API_FACADE_2026-04-30.md @@ -119,10 +119,10 @@ cargo check -p spacetime-client --manifest-path server-rs\Cargo.toml cargo check -p api-server --manifest-path server-rs\Cargo.toml cargo test -p shared-contracts match3d --manifest-path server-rs\Cargo.toml npm run check:encoding -npm run api-server:maincloud +npm run api-server ``` -`api-server:maincloud` 是修改后端后的必跑项;如果本地缺少 Maincloud 环境或 SpacetimeDB 发布态不一致,需要在最终结果里明确说明。 +`api-server` 是修改后端后的必跑项;如果本地 SpacetimeDB 发布态不一致,需要在最终结果里明确说明。 ## 7. 后续接入点 diff --git a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md new file mode 100644 index 00000000..4847ac0d --- /dev/null +++ b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md @@ -0,0 +1,33 @@ +# 新建作品入口配置说明 2026-05-01 + +## 背景 + +创作中心顶部“新建作品”入口和平台创作类型弹层都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。 + +## 落地规则 + +1. 新建作品入口配置统一放在 `src/config/newWorkEntryConfig.ts`。 +2. `visible` 控制玩法是否展示在新建作品入口和创作类型弹层中。 +3. `open` 控制玩法是否允许点击创建;`open: false` 时入口保持展示但禁用。 +4. `title`、`subtitle`、`badge` 控制玩法卡片文案。 +5. `startCard` 控制创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案。 +6. `typeModal` 控制平台创作类型弹层标题和描述。 +7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。 + +## 当前状态 + +| 玩法 | 展示 | 开放 | 说明 | +| --- | --- | --- | --- | +| 角色扮演 | 是 | 是 | 点击后进入 RPG Agent 共创工作台 | +| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 | +| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 | +| 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Agent 共创工作台 | +| AIRP | 是 | 否 | 保留入口,显示敬请期待 | +| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 | + +## 验收 + +1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作中心顶部卡带和平台创作类型弹层应同步变化。 +2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。 +3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。 +4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。 diff --git a/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md b/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md index 4155e906..464d1683 100644 --- a/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md +++ b/docs/technical/PROFILE_REDEEM_CODE_IMPLEMENTATION_2026-04-28.md @@ -128,4 +128,4 @@ - Rust/module-runtime:覆盖公共码、唯一码、私有码、失败场景、流水来源和余额累加。 - Axum:覆盖用户鉴权、管理员鉴权、runtime error 到 400 的映射和兼容路径。 - 前端:覆盖入口替换、独立 modal、成功刷新余额和失败展示后端 message。 -- 验证命令:`cargo test`、目标前端测试、`npm run api-server:maincloud`、`npm run check:encoding`。 +- 验证命令:`cargo test`、目标前端测试、`npm run api-server`、`npm run check:encoding`。 diff --git a/docs/technical/PUBLIC_WORK_DETAIL_OWNED_EDIT_ACTION_2026-05-02.md b/docs/technical/PUBLIC_WORK_DETAIL_OWNED_EDIT_ACTION_2026-05-02.md new file mode 100644 index 00000000..897f2f24 --- /dev/null +++ b/docs/technical/PUBLIC_WORK_DETAIL_OWNED_EDIT_ACTION_2026-05-02.md @@ -0,0 +1,29 @@ +# 公开作品详情页自有作品编辑分流 + +更新时间:`2026-05-02` + +## 背景 + +平台统一作品详情页左下角一直使用“作品改造”动作。这个动作适合打开其他作者作品时复制一份新草稿,但当详情页展示的是当前登录用户自己的作品时,继续复制会产生一份新的自己的作品,和用户预期的“编辑原作品”不一致。 + +## 落地规则 + +1. 作品详情页以 `ownerUserId === 当前登录用户 id` 判断作品归属。 +2. 非本人作品保持“作品改造”,点击后继续走现有 remix / 复制草稿链路。 +3. 本人作品把左下角按钮显示为“作品编辑”,点击后进入该作品绑定的原草稿或结果编辑页。 +4. 本人作品编辑不调用 remix 接口,不创建新的同款作品。 +5. 如果本人作品缺少可恢复的草稿会话,只展示可定位错误,不静默复制。 + +## 分玩法入口 + +1. RPG:复用已保存作品编辑入口,直接打开当前 profile 的结果编辑页。 +2. 拼图:优先使用当前详情项的 `sourceSessionId` 恢复原拼图草稿。 +3. 大鱼吃小鱼:使用公开作品的 `sourceSessionId` 恢复原玩法草稿。 +4. 抓大鹅:保留并传递 `sourceSessionId`,恢复原创作会话后进入结果页。 + +## 验收标准 + +1. 登录用户打开自己的公开作品详情时,左下角按钮为“作品编辑”。 +2. 点击“作品编辑”不会调用对应玩法的 remix 接口。 +3. 登录用户打开他人公开作品详情时,左下角按钮仍为“作品改造”。 +4. 未登录用户点击“作品改造”仍先触发登录拦截。 diff --git a/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md new file mode 100644 index 00000000..48c7877a --- /dev/null +++ b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md @@ -0,0 +1,75 @@ +# 拼图 APIMart 图片模型路由接入 2026-05-01 + +## 背景 + +拼图创作已收口为填表式流程,首图生成和结果页关卡重新生成都由 `server-rs/crates/api-server/src/puzzle.rs` 执行外部图片 I/O,再把正式图写入 OSS 与 SpacetimeDB。新的模型选择只影响图片生成上游,不改变 SpacetimeDB 表结构、拼图草稿结构或前端运行时规则。 + +本轮参考 APIMart 文档: + +1. `https://docs.apimart.ai/cn/api-reference/images/gpt-image-2/generation` +2. `https://docs.apimart.ai/cn/api-reference/images/gemini-3.1-flash/generation` + +两条文档均指向 OpenAI 兼容风格的图片生成入口:`POST https://api.apimart.ai/v1/images/generations`,头部使用 `Authorization: Bearer {APIMART_API_KEY}`。请求体至少包含 `model`、`prompt`、`n`、`size`。返回体按 OpenAI images 兼容格式优先读取 `data[].url`,若供应商返回异步任务结构,则继续按 `task_id` / `tasks/{task_id}` 轮询并提取图片 URL。 + +## 模型选项 + +拼图图片生成支持两个选项: + +| 前端显示 | 请求值 | 上游 | +| --- | --- | --- | +| `gpt-image-2` | `gpt-image-2` | APIMart `/v1/images/generations` | +| `nanobanana2` | `gemini-3.1-flash-image-preview` | APIMart `/v1/images/generations` | + +默认值为 `gpt-image-2`。前端只负责展示和传递所选模型,不能把模型路由逻辑、上游请求体拼装或 API Key 暴露到浏览器。历史草稿或旧请求中的空值、`original`、未知值统一按 `gpt-image-2` 处理,不再把拼图生图路由回 DashScope 原模型。 + +## 前端交互 + +1. 拼图创作表单的“画面描述”输入框左下角显示当前调用模型。 +2. 拼图结果页关卡详情的“画面描述”输入框左下角同样显示当前调用模型。 +3. 点击模型标识弹出轻量选择菜单,只支持 `gpt-image-2`、`nanobanana2` 两项。 +4. 菜单只是模型选择控件,不写入说明性规则文案。 +5. 参考图入口继续在画面描述输入框右下角,模型选择在左下角,两者不得遮挡文本。 +6. 表单创建 session、自动保存表单草稿、首图生成失败重试时都要保留当前模型选择。 +7. 结果页关卡重新生成时将当前模型随 `generate_puzzle_images` action 传给后端。 +8. “生成草稿”和关卡详情“生成画面 / 重新生成画面”按钮文本右侧展示 `消耗2光点`。 +9. 关卡详情点击“生成画面 / 重新生成画面”后先弹出确认消耗光点弹窗,确认后开始请求;按钮区域切换为 30 秒倒计时进度条,并展示预计剩余生成完成时间。 + +## 后端路由 + +1. `CreatePuzzleAgentSessionRequest` 与 `ExecutePuzzleAgentActionRequest` 增加可选 `imageModel` 字段;该字段不进入 SpacetimeDB reducer 输入结构。 +2. `compile_puzzle_draft_with_initial_cover` 与 `generate_puzzle_image_candidates` 增加图片模型参数。 +3. `imageModel` 归一化规则: + - 空值、`original` 或未知值统一回落为 `gpt-image-2`; + - `gpt-image-2` 走 APIMart; + - `gemini-3.1-flash-image-preview` 走 APIMart,前端显示名为 `nanobanana2`。 +4. APIMart 文生图和图生图共用 `POST /v1/images/generations`。有参考图时,后端将参考图 Data URL 作为 `image_urls` 数组传入;若上游不接受该字段,错误按上游失败返回,不在前端降级伪造结果。 +5. APIMart 尺寸使用文档要求的比例写法 `1:1`。`gemini-3.1-flash-image-preview` 额外带 `resolution = "1K"`,对齐约 1024px 的拼图正方形素材。 +6. APIMart 生成成功后仍下载远程图片,沿用现有 OSS 私有对象、`asset_object` 和 `asset_entity_binding` 写入流程。若图片已成功上传 OSS,但 Maincloud / SpacetimeDB 短暂返回 `503 Service Unavailable`,资产索引写入允许降级跳过,并返回本次生成图片;日志必须记录 `拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过`。 +7. `save_puzzle_generated_images` 写回草稿时若遇到 Maincloud 连接级 `503` 或断线,API 层基于本次生成结果合成 session 快照返回给前端,避免 APIMart 已成功出图却被后置持久化误报成服务不可用。余额不足、参数错误、上游生图失败仍按原错误返回,不做伪成功。 +8. 结果页 `generate_puzzle_images` 会携带当前作品信息和 `levelsJson`。当 Maincloud / SpacetimeDB 在读取 session 阶段就返回连接级 `503` 或断线时,后端必须先用这份结果页快照构造最小内存 session,再继续调用 APIMart;外部图片已经生成后仍按第 6、7 条处理持久化降级。余额不足、参数错误、缺少草稿快照、关卡不存在等业务错误不走此降级。 +9. APIMart 异步任务轮询按文档口径在提交后先等待 `10s`,再调用 `GET /v1/tasks/{task_id}`;图片地址提取同时支持 `url: "..."` 与 `url: ["..."]` 两种结构。 +10. APIMart 错误统一映射为 `502 UPSTREAM_ERROR`,`details.provider = "apimart"`,保留上游状态码、业务 message 和截断后的 raw excerpt。 +11. 拼图首图生成 `compile_puzzle_draft` 与关卡图片生成 `generate_puzzle_images` 每次预扣 `2` 光点;余额不足仍返回 `409 CONFLICT`,Maincloud 连接级 503 仍按既有降级策略处理。 + +## 环境变量 + +新增服务端环境变量: + +```text +APIMART_BASE_URL="https://api.apimart.ai/v1" +APIMART_API_KEY="YOUR_APIMART_API_KEY" +APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 +``` + +`APIMART_API_KEY` 只能存在于本地或部署环境,不写入 Git 跟踪文件。若选择 APIMart 模型但缺少 key,后端返回服务不可用错误,前端展示现有错误面板。 + +## 验收 + +1. 创作表单和关卡详情的画面描述框左下角能切换 `gpt-image-2`、`nanobanana2`,默认显示 `gpt-image-2`。 +2. 点击“生成草稿”时,后端首图生成使用当前表单选择的模型。 +3. 点击“生成画面 / 重新生成画面”时,后端当前关卡图片生成使用关卡详情选择的模型。 +4. 历史 `original` 或空模型值不会再触发 DashScope,统一按 `gpt-image-2` 请求 APIMart。 +5. 选择 APIMart 模型时,请求 `POST {APIMART_BASE_URL}/images/generations`,使用 `Authorization: Bearer {APIMART_API_KEY}`,`model` 等于请求值,`size = 1:1`。 +6. “生成草稿”和关卡详情生图按钮展示 `消耗2光点`;关卡详情确认后展示 30 秒预计剩余进度条。 +7. 不改 SpacetimeDB 表结构,因此无需更新 `migration.rs` 或重新生成 bindings。 +8. 后端改动后运行对应 Rust 测试,并按项目约束用 `npm run api-server:maincloud` 重启验证。 diff --git a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md index 70609e82..dd646464 100644 --- a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md +++ b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md @@ -14,9 +14,9 @@ 4. 玩家在生成草稿前退出,再次从创作中心点击这条拼图草稿时,必须恢复到填表页,并回填之前自动保存的作品名称、作品描述和画面描述;只有执行 `compile_puzzle_draft` 且生成结果页草稿后,草稿入口才进入结果页。 5. 表单自动保存走 `save_puzzle_form_draft` action,不消耗光点,不生成图片,不改变 `stage = collecting_anchors`;生成草稿按钮仍单独触发 `compile_puzzle_draft` 并进入进度页。 6. 点击拼图入口始终创建新草稿,不复用上一次未完成 session;恢复旧草稿只通过“我的创作”中的草稿卡进入。 -7. 若 Maincloud 仍运行旧 wasm,缺少 `save_puzzle_form_draft` procedure,前端提交生成或生成失败页重试时不得继续复用空 `seedText` 的表单 session,必须用当前表单 payload 新建带真实 seed 的 session 再执行 `compile_puzzle_draft`。 +7. 若运行中的旧 wasm 缺少 `save_puzzle_form_draft` procedure,前端提交生成或生成失败页重试时不得继续复用空 `seedText` 的表单 session,必须用当前表单 payload 新建带真实 seed 的 session 再执行 `compile_puzzle_draft`。 8. api-server 也要兼容旧 wasm:`save_puzzle_form_draft` 缺失时,自动保存 action 降级返回当前 session;`compile_puzzle_draft` 前置保存缺失且当前 session 为空 seed 时,创建一条带表单 seed 的替代 session 后继续编译,避免再次暴露 `No such procedure`。 -9. 正式修复仍是发布最新 SpacetimeDB wasm。当前 Maincloud `xushi-p4wfr` 的迁移操作员表为空,但旧库引导密钥来自旧 wasm,本次临时生成的新引导密钥无法授权导出迁移,需使用已有迁移操作员 token 或数据库 owner 重新授权后发布;禁止为绕过冲突直接清库,除非明确接受数据丢失。 +9. 正式修复仍是发布最新 SpacetimeDB wasm。如果目标库的迁移操作员表为空,但旧库引导密钥来自旧 wasm,本次临时生成的新引导密钥无法授权导出迁移,需使用已有迁移操作员 token 或数据库 owner 重新授权后发布;禁止为绕过冲突直接清库,除非明确接受数据丢失。 1. 作品名称为必填字段,保存到 `workTitle`,兼容写入旧 `seedText`,同时作为作品级 `workTitle` 的真相源。 2. 作品描述为必填字段,保存到 `workDescription`,作为作品详情页、作品列表和发布资料中的 `summary` 真相源。 diff --git a/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md b/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md index 502a974f..c7da2561 100644 --- a/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md +++ b/docs/technical/PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md @@ -14,13 +14,13 @@ ### 1. 图片生成 -1. 拼图生成图固定使用 `1024*1024`。 -2. 文生图和参考图生图共用同一个尺寸常量,禁止一条链路仍生成竖屏或横版图。 -3. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、主体清晰、层次明确、无文字水印等约束。 -4. 文生图正向 prompt 必须由后端压缩到 `500` 字符以内,优先保留玩家画面描述开头与固定拼图约束,避免 DashScope 旧 text2image 协议把超长 prompt 判为“请求参数不合法”。 -5. DashScope 上游失败时,api-server 必须在错误 details 中保留业务 message、`upstreamStatus` 和截断后的 `rawExcerpt`,日志也要记录同样的摘要,避免生成进度页只能看到通用 HTTP 文案。 -6. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 不做网络 I/O。 -7. 拼图文生图请求体按 DashScope Wan text2image 协议收口:`input` 放 `prompt` 与非空 `negative_prompt`,`parameters` 放 `n`、`size`、`prompt_extend`、`watermark`。不要在 `input` 与 `parameters` 里重复写入反向提示词,否则上游容易返回参数非法。 +1. 拼图默认使用 APIMart `gpt-image-2` 生成图,外部请求尺寸固定为 `1:1`;`nanobanana2` 仍映射为 `gemini-3.1-flash-image-preview`。 +2. 历史 `original` 或空模型值只做兼容输入,不再进入 DashScope 原模型链路,统一按 `gpt-image-2` 路由。 +3. 文生图和参考图生图共用同一个正方形尺寸口径,禁止一条链路仍生成竖屏或横版图。 +4. 拼图图片提示词明确写入 `1:1 正方形画布`,继续保留适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、主体清晰、层次明确、无文字水印等约束。 +5. 文生图正向 prompt 必须由后端压缩到 `500` 字符以内,优先保留玩家画面描述开头与固定拼图约束,避免上游把超长 prompt 判为“请求参数不合法”。 +6. APIMart 上游失败时,api-server 必须在错误 details 中保留业务 message、`upstreamStatus` 和截断后的 `rawExcerpt`,日志也要记录同样的摘要,避免生成进度页只能看到通用 HTTP 文案。 +7. 图片生成仍由 `api-server` 执行。SpacetimeDB reducer 不做网络 I/O。 8. 光点预扣失败属于钱包或 SpacetimeDB 服务链路错误,不得映射成 `400 BAD_REQUEST`。除余额不足返回 `409 CONFLICT` 外,其余预扣异常统一按上游/服务错误暴露,避免生成页误提示“请求参数不合法”。 ### 2. 前端规则裁决 @@ -47,10 +47,10 @@ ## 验收 -1. 点击拼图草稿生成或重新生成画面时,后端请求 DashScope 的 `size` 为 `1024*1024`。 +1. 点击拼图草稿生成或重新生成画面时,后端请求 APIMart 的 `size` 为 `1:1`,默认模型为 `gpt-image-2`。 2. 图片提示词包含 `1:1 正方形拼图关卡`。 3. 图片提示词长度不超过 `500` 字符,超长画面描述会被截断,但适配 `3x3 / 4x4 / 5x5 / 6x6 / 7x7` 拼图切块、`避免文字、水印、边框和 UI 元素` 等玩法约束不能丢。 -4. DashScope 返回参数错误、任务失败或非 2xx 时,前端错误优先展示后端 details.message,后端日志能看到 `upstreamStatus` 和 `rawExcerpt`。 +4. APIMart 返回参数错误、任务失败或非 2xx 时,前端错误优先展示后端 details.message,后端日志能看到 `upstreamStatus` 和 `rawExcerpt`。 5. 正式拼图 run 中拖动拼块后,前端立即更新棋盘、合并块和通关状态,不再等待 `/drag`。 6. 移动端运行时棋盘为正方形,并尽量贴近屏幕两侧边缘。 7. 基础单块和合并块都能看到圆角,合并块的外凸角与内凹角都不是直角,且图片不会溢出圆角裁剪。 diff --git a/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md b/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md index 670b7c02..97eb1f04 100644 --- a/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md +++ b/docs/technical/PUZZLE_IMAGE_ASSET_PROXY_FIX_2026-04-27.md @@ -28,4 +28,4 @@ 1. `npm run check:encoding` 2. `cargo check -p api-server --manifest-path server-rs/Cargo.toml` -3. `npm run api-server:maincloud` 重启后,点击拼图结果页“生成或更换图片”,候选图应能写回并正常展示。 +3. `npm run api-server` 重启后,点击拼图结果页“生成或更换图片”,候选图应能写回并正常展示。 diff --git a/docs/technical/PUZZLE_RUNTIME_FIRST_EXIT_REMODEL_PROMPT_2026-05-02.md b/docs/technical/PUZZLE_RUNTIME_FIRST_EXIT_REMODEL_PROMPT_2026-05-02.md new file mode 100644 index 00000000..348857c2 --- /dev/null +++ b/docs/technical/PUZZLE_RUNTIME_FIRST_EXIT_REMODEL_PROMPT_2026-05-02.md @@ -0,0 +1,38 @@ +# 拼图运行时首次退出改造引导 2026-05-02 + +## 背景 + +玩家从公开拼图作品进入运行态后,左上角返回会直接离开玩法。若玩家因为体验不佳准备退出,需要在首次退出时给出改造入口,让玩家可以把当前作品复制为自己的草稿继续调整。 + +本轮只改拼图运行时前端交互与既有改造链路,不新增后端表,不改变拼图存档投影规则,不接入旧 `server-node`。 + +## 交互规则 + +1. 触发点只限拼图运行态左上角返回按钮。 +2. 对同一浏览器里的同一拼图 `profileId`,首次点击返回时不直接退出,而是弹出独立面板。 +3. 面板标题固定为两行: + - `体验不佳?` + - `试试改造功能!` +4. 面板主按钮为 `作品改造`,点击后复用公开详情页已有的拼图改造链路: + - 使用当前运行关卡的 `currentLevel.profileId` 调用 `remixPuzzleGalleryWork(profileId)`,避免下一关或相似作品运行态误用旧详情页作品。 + - 成功后写入 `puzzleFlow.session`。 + - 进入 `puzzle-result`,即游戏作品改造页。 +5. 面板次按钮为 `保存并退出`,点击后关闭面板并执行原返回逻辑。 +6. 非首次点击返回不再弹出面板,直接执行原返回逻辑。 + +## 首次状态 + +首次曝光是浏览器侧 UI 引导状态,不是业务真相态: + +1. 以 `currentLevel.profileId` 作为作品粒度。 +2. 使用 `localStorage` 记录已展示状态。 +3. `localStorage` 不可用时,使用当前组件生命周期内的内存集合兜底,避免同一挂载周期重复弹出。 +4. 点击 `作品改造` 或 `保存并退出` 都视为已经完成本次引导曝光。 + +## 验收 + +1. 首次点击拼图运行态左上角返回,出现标题为 `体验不佳?试试改造功能!` 的独立面板。 +2. 点击 `作品改造` 后进入拼图结果页改造草稿。 +3. 点击 `保存并退出` 后返回原目标页面。 +4. 同一作品再次点击左上角返回,不再出现面板。 +5. 不影响设置面板里的返回按钮、失败续时、通关结算和下一关入口。 diff --git a/docs/technical/README.md b/docs/technical/README.md index 9ab100fb..2c33713e 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -49,8 +49,9 @@ - [SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md](./SERVER_RS_DDD_FULL_REFACTOR_2026-04-28.md):冻结 `server-rs` 一次性 DDD 重构总纲,明确 crate 依赖方向、模块目录、上下文聚合/命令/事件/读模型、SpacetimeDB adapter 映射和表结构变更约束。 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 - [PRODUCT_NAMING_BAIMENG_RENAME_2026-05-01.md](./PRODUCT_NAMING_BAIMENG_RENAME_2026-05-01.md):冻结当前对外中文命名,产品展示名统一为“百梦”,消费单位为“光点”,公开账号标识为“百梦号”,创作侧称谓为“百梦主”。 +- [SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md](./SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md):记录旧云端 SpacetimeDB 配置、发布脚本和默认文档口径的移除结果,冻结后续仅使用本地或显式 `SERVER_URL` 的运维规则。 - [SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md](./SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md):记录本地 standalone 启动时报 `mismatched database identity` 的 root-dir/replica 数据残留根因、备份重建步骤和脚本诊断口径。 -- [AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md](./AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md):记录 Maincloud `xushi-p4wfr` 挂起导致认证快照同步和抓大鹅创作失败的根因、认证同步非阻断修复、`/api/creation` Vite 代理补齐和本地 SpacetimeDB 可跑链路。 +- [AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md](./AUTH_SNAPSHOT_AND_MATCH3D_LOCAL_DEV_FIX_2026-05-01.md):记录远端库挂起导致认证快照同步和抓大鹅创作失败的根因、认证同步非阻断修复、`/api/creation` Vite 代理补齐和本地 SpacetimeDB 可跑链路。 - [LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md](./LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md):冻结 RPG 运行时剧情推理使用 `doubao-seed-character-251128` 的 `/chat/completions`,以及所有模板创作大模型推理使用 `deepseek-v3-2-251201` 的 `/responses`。 - [PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md](./PROFILE_INVITE_CODE_REGISTRATION_AND_ADMIN_2026-04-30.md):冻结邀请码从“我的 Tab 填写”迁到注册环节的前后端边界、`profile_invite_code.metadata_json` 表结构扩展、管理员邀请码虚拟主体和奖励规则。 - [MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md](./MATCH3D_CREATION_AND_RUNTIME_MINIMAL_IMPLEMENTATION_2026-04-30.md):冻结抓大鹅 Match3D 首版 demo 的独立玩法域、表与 procedure、HTTP facade、前端即时反馈/后端权威确认协议,以及可并行开发包。 @@ -68,7 +69,7 @@ - [RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md](./RPG_CREATION_RESULT_VIEW_BACKEND_TRUTH_MIGRATION_2026-04-28.md):冻结 RPG 创作结果页保存、Agent session/result preview 真相优先级和结果页入口裁决迁移到后端 result-view 的落地边界。 - [RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md](./RPG_CREATION_PROFILE_GENERATION_BACKEND_MIGRATION_2026-04-28.md):记录 RPG 创作 profile 生成移除非浏览器 legacy AI 回退,统一通过 `server-rs` 的 `/api/runtime/custom-world/profile` 生成世界底稿。 - [CREATION_PUBLIC_GALLERY_AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md](./CREATION_PUBLIC_GALLERY_AND_AGENT_RESTORE_GUARD_FIX_2026-04-28.md):记录 RPG Agent 旧 URL 恢复指针必须有本机用户归属才读取受保护 session,以及 Big Fish 公开广场读取失败按空广场降级的修复口径。 -- [BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md](./BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md):记录大鱼吃小鱼草稿进度页从单步 compile 改为多阶段感知展示,以及大鱼会话读取在 Maincloud 抖动时增加短重试与超时语义收口的修复口径。 +- [BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md](./BIG_FISH_DRAFT_PROGRESS_AND_SESSION_TIMEOUT_GUARD_FIX_2026-04-28.md):记录大鱼吃小鱼草稿进度页从单步 compile 改为多阶段感知展示,以及大鱼会话读取在 SpacetimeDB 抖动时增加短重试与超时语义收口的修复口径。 - [BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md](./BIG_FISH_PROMPT_MODULE_EXTRACTION_2026-04-28.md):记录大鱼吃小鱼草稿生成、生图、动作三类提示词从业务脚本中抽离到独立 `prompt/big_fish.rs` 模块的边界与职责划分。 - [BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md](./BIG_FISH_MAIN_IMAGE_TRANSPARENT_BACKGROUND_ALIGNMENT_2026-04-28.md):记录大鱼吃小鱼等级主图与动作关键帧正式图在 Rust 后端复用 RPG 角色主图透明背景 alpha 后处理的对齐口径,并明确场地背景不走该处理。 - [PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md](./PUZZLE_IMAGE_AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md):记录拼图生成图片回到 1:1,运行时拖动、交换、合并与拆分由前端即时裁决,以及移动端棋盘贴近屏幕边缘的落地边界。 @@ -88,7 +89,7 @@ - [SPACETIMEDB_TABLE_CATALOG.md](./SPACETIMEDB_TABLE_CATALOG.md):持续维护当前 SpacetimeDB 表目录,按领域说明每张表的作用、字段结构、索引和常用 `spacetime sql` 查询模板。 - [RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md](./RPG_OPENING_SCENE_ACT_IMAGE_PRESENTATION_SYNC_2026-04-26.md):记录开局场景与普通场景复用同一场景展示解析服务,修复列表幕缩略图和详情幕背景预览图片不一致的问题。 - [FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md](./FRONTEND_FIRST_LOAD_PERFORMANCE_FIX_2026-04-26.md):记录网站启动后首次加载约三分钟的前端根因,收口 `RouteImageReadyGate` 首屏图片门控和 Vite dev server 无关文件监听范围。 -- [RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md](./RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md):记录 RPG 作品删除时报 `No such procedure` 的根因,补齐 `delete_custom_world_agent_session` 在有效 SpacetimeDB 模块入口中的导出,并要求发布后核验 Maincloud schema。 +- [RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md](./RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md):记录 RPG 作品删除时报 `No such procedure` 的根因,补齐 `delete_custom_world_agent_session` 在有效 SpacetimeDB 模块入口中的导出,并要求发布后核验 schema。 - [CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md](./CURRENT_BACKEND_IMPLEMENTATION_BASELINE_2026-04-25.md):冻结当前后端唯一落地口径,明确新功能以 `server-rs + Axum + SpacetimeDB` 为准,旧 `server-node` / Express / PostgreSQL 与 Go 方向只允许作为迁移参考。 - [RPG_DRAFT_GENERATION_CONTINUE_AND_ETA_FIX_2026-04-25.md](./RPG_DRAFT_GENERATION_CONTINUE_AND_ETA_FIX_2026-04-25.md):记录世界草稿生成失败/中断后进度不再误到 `100%`、主按钮改为“继续生成草稿”并复用已保存底稿续跑,以及按阶段耗时模型估算预计等待时间的修复口径。 - [RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md](./RUNTIME_NPC_CHAT_LLM_MIGRATION_2026-04-25.md):冻结运行时 NPC 聊天从 Rust 确定性兜底迁到 `platform-llm` 的边界,要求旧 Node 聊天提示词原样迁移,覆盖回复、建议、好感变化与限轮收束。 diff --git a/docs/technical/RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md b/docs/technical/RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md index 689bbff7..455c05cc 100644 --- a/docs/technical/RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md +++ b/docs/technical/RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md @@ -48,4 +48,4 @@ Agent 路由保持原有 submit/finalize 分工: 3. `cargo test -p api-server runtime_chat` 4. `cargo test -p api-server creation_agent_llm_turn` 5. `node scripts/check-encoding.mjs docs/technical/RPG_AND_AGENT_CHAT_TRUE_SSE_STREAMING_2026-04-26.md server-rs/crates/api-server/src/runtime_chat.rs server-rs/crates/api-server/src/custom_world.rs server-rs/crates/api-server/src/big_fish.rs docs/technical/README.md` -6. 修改后端代码后,使用 `npm run api-server:maincloud` 重启后端。 +6. 修改后端代码后,使用 `npm run api-server` 重启后端。 diff --git a/docs/technical/RPG_FOUNDATION_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md b/docs/technical/RPG_FOUNDATION_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md new file mode 100644 index 00000000..7935e9c6 --- /dev/null +++ b/docs/technical/RPG_FOUNDATION_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md @@ -0,0 +1,45 @@ +# RPG foundation draft LLM 联网搜索降级修正(2026-05-01) + +## 背景 + +本次现场错误为: + +```text +agent-foundation-story-outline-batch-1 LLM 请求失败:LLM 请求超时,累计尝试 2 次 +``` + +同一轮 `logs/llm-raw` 还记录了前置 `ToolNotOpen`: + +```text +Your account has not activated web search. +``` + +当前 `custom_world_foundation_draft.rs` 的分阶段底稿生成全部硬编码 `.with_web_search(true)`。但这些批次只根据 Agent 已收集的八锚点、世界骨架、角色名单和场景名单生成结构化 JSON,本身不需要实时联网检索;联网搜索只能作为模板创作的增强能力,不能成为 foundation draft 的必经前置。 + +## 根因 + +1. `generate_custom_world_foundation_draft(...)` 没有接收 `AppConfig.creation_agent_llm_web_search_enabled`。 +2. `request_foundation_json_stage(...)` 对所有 Responses 请求固定开启 `web_search`。 +3. 非流式 foundation draft 调用没有复用创作 Agent SSE turn 中的 `ToolNotOpen` 降级策略。 +4. 当账号未开通 web search 或搜索工具链响应慢时,底稿批次会在内部 JSON 生成阶段失败,最终 operation 进入 failed。 + +## 落地策略 + +1. `api-server` 调用 foundation draft 生成器时显式传入 `state.config.creation_agent_llm_web_search_enabled`。 +2. foundation draft 每个业务 JSON 阶段先按配置决定是否带 `tools: [{ type: "web_search", max_keyword: 3 }]`。 +3. 若开启搜索后出现以下错误,自动使用同一 system/user prompt 无搜索重试一次: + - `ToolNotOpen` + - `has not activated web search` + - `未开通` + - 上游连接失败 + - 请求超时 +4. JSON 修复阶段继续不启用搜索,因为修复只处理已有响应文本。 +5. SpacetimeDB reducer / procedure 不新增任何外部 I/O;仍只接收 api-server 已生成的确定性 `draftProfile` 并落库。 + +## 验收标准 + +1. `agent-foundation-*-batch-*` 首次搜索增强失败时,日志出现一次降级 warning,operation 不应直接失败。 +2. 降级重试请求体不再包含 `tools` / `web_search`。 +3. `GENARRATIVE_CREATION_AGENT_LLM_WEB_SEARCH_ENABLED=false` 时,foundation draft 全流程直接不带搜索工具。 +4. `cargo test -p api-server custom_world_foundation_draft --manifest-path server-rs/Cargo.toml` 通过。 +5. 修改后按项目约束使用 `npm run api-server:maincloud` 重启后端。 diff --git a/docs/technical/RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md b/docs/technical/RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md index 4408473b..6728bf9e 100644 --- a/docs/technical/RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md +++ b/docs/technical/RPG_HOME_CUSTOM_WORLD_LIBRARY_TIMEOUT_FIX_2026-04-29.md @@ -10,7 +10,7 @@ 2. `GET /api/runtime/custom-world-gallery` 3. 个人看板、浏览历史、存档等私有数据 -其中 `custom-world-library` 通过 `api-server -> spacetime-client -> list_custom_world_profiles procedure` 读取当前用户作品。旧实现把每个作品的完整 `profile_payload_json` 一并返回给首页列表,而首页卡片只需要标题、摘要、封面、状态和计数字段。用户作品较多或 Maincloud 连接抖动时,这个 procedure 容易超过 `spacetime-client` 固定 `10s` 等待窗口,最终由 Axum 映射成 `502 Bad Gateway`,前端控制台显示 `SpacetimeDB procedure 调用超时`。 +其中 `custom-world-library` 通过 `api-server -> spacetime-client -> list_custom_world_profiles procedure` 读取当前用户作品。旧实现把每个作品的完整 `profile_payload_json` 一并返回给首页列表,而首页卡片只需要标题、摘要、封面、状态和计数字段。用户作品较多或 SpacetimeDB 连接抖动时,这个 procedure 容易超过 `spacetime-client` 固定 `10s` 等待窗口,最终由 Axum 映射成 `502 Bad Gateway`,前端控制台显示 `SpacetimeDB procedure 调用超时`。 ## 2. 修复口径 @@ -19,8 +19,8 @@ 1. `list_custom_world_profiles` 仍保持旧 procedure 名称和返回 envelope,避免本轮重新生成 bindings。 2. 列表返回的 `profile_payload_json` 改为轻量摘要 JSON,只包含首页卡片和标签兜底需要的少量字段。 3. 单条详情、发布、下架、编辑继续使用完整 profile snapshot,确保进入详情或结果页时仍有完整世界数据。 -4. `spacetime-client` 的 procedure 等待窗口从硬编码 `10s` 改为可配置,Maincloud 默认使用更宽的窗口吸收连接冷启动与短时抖动。 -5. Axum 的 `GET /api/runtime/custom-world-library` 首屏接口改走已有 `custom-world/works` 轻量读模型,并在用户点击详情/编辑时再调用 owner-only detail 接口取完整 profile,避免 Maincloud wasm 尚未发布轻量 profile procedure 时首页继续命中重 procedure。 +4. `spacetime-client` 的 procedure 等待窗口从硬编码 `10s` 改为可配置,用更宽的窗口吸收连接冷启动与短时抖动。 +5. Axum 的 `GET /api/runtime/custom-world-library` 首屏接口改走已有 `custom-world/works` 轻量读模型,并在用户点击详情/编辑时再调用 owner-only detail 接口取完整 profile,避免旧 wasm 尚未发布轻量 profile procedure 时首页继续命中重 procedure。 ## 3. 轻量 profile JSON 字段 @@ -47,4 +47,4 @@ 3. `cargo check -p spacetime-client` 通过。 4. `cargo check -p api-server` 通过。 5. `npm run check:encoding` 通过。 -6. 修改后按项目约束使用 `npm run api-server:maincloud` 重启后端。 +6. 修改后按项目约束使用 `npm run api-server` 重启后端。 diff --git a/docs/technical/RPG_INVENTORY_EQUIPMENT_FORGE_VIEW_BACKEND_MIGRATION_2026-04-28.md b/docs/technical/RPG_INVENTORY_EQUIPMENT_FORGE_VIEW_BACKEND_MIGRATION_2026-04-28.md index 07594d9a..f6f26edc 100644 --- a/docs/technical/RPG_INVENTORY_EQUIPMENT_FORGE_VIEW_BACKEND_MIGRATION_2026-04-28.md +++ b/docs/technical/RPG_INVENTORY_EQUIPMENT_FORGE_VIEW_BACKEND_MIGRATION_2026-04-28.md @@ -83,7 +83,7 @@ 6. 修改后执行: - Rust 相关测试。 - TypeScript 相关测试。 - - `npm run api-server:maincloud`。 + - `npm run api-server`。 ## 5. 本次实现结果 diff --git a/docs/technical/RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md b/docs/technical/RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md index 9c825702..2e374a21 100644 --- a/docs/technical/RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md +++ b/docs/technical/RPG_PROMPT_FRONTEND_REMOVAL_AND_SERVER_RS_MIGRATION_2026-04-28.md @@ -91,7 +91,7 @@ server-rs/crates/api-server/src/prompt/rpg/ 1. `npm run check:encoding` 2. `npm run test -- src/services/ai.test.ts src/hooks/rpg-runtime-story/storyResponseOptions.test.ts` 3. `cargo check -p api-server` -4. `npm run api-server:maincloud` +4. `npm run api-server` ## 后续编辑约定 diff --git a/docs/technical/RPG_RUNTIME_OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md b/docs/technical/RPG_RUNTIME_OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md index e869795c..a3d53309 100644 --- a/docs/technical/RPG_RUNTIME_OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md +++ b/docs/technical/RPG_RUNTIME_OPENING_STORY_BOOTSTRAP_FIX_2026-04-26.md @@ -81,7 +81,7 @@ npm run typecheck -- --pretty false npm run check:encoding ``` -局部测试、局部 ESLint、全量类型检查与编码检查均通过。后端代码未在本次任务中修改,因此未执行 `npm run api-server:maincloud`。 +局部测试、局部 ESLint、全量类型检查与编码检查均通过。后端代码未在本次任务中修改,因此未执行 `npm run api-server`。 ## 2026-04-27 第二轮复查修正 @@ -123,7 +123,7 @@ npm test -- --run src/data/sceneEncounterPreviews.test.ts src/hooks/rpg-runtime- npx eslint src/data/sceneEncounterPreviews.ts src/data/sceneEncounterPreviews.test.ts src/services/customWorldSceneActRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.ts src/hooks/rpg-runtime-story/storyChoiceRuntime.test.ts src/hooks/rpg-runtime-story/uiTypes.ts src/components/rpg-runtime-panels/RpgAdventurePanelOverlays.tsx ``` -以上局部测试与局部 ESLint 已通过。后端代码未在本轮修改中触碰,因此不需要执行 `npm run api-server:maincloud`。 +以上局部测试与局部 ESLint 已通过。后端代码未在本轮修改中触碰,因此不需要执行 `npm run api-server`。 ## 2026-04-27 第三轮复查修正 @@ -149,7 +149,7 @@ npx eslint src/hooks/rpg-session/useRpgSessionBootstrap.ts src/hooks/useGameFlow npm run typecheck -- --pretty false ``` -以上局部测试、局部 ESLint 与全量类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server:maincloud`。 +以上局部测试、局部 ESLint 与全量类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server`。 ## 2026-04-27 第四轮复查修正 @@ -183,7 +183,7 @@ npm run typecheck -- --pretty false npm run check:encoding ``` -以上相关测试、局部 ESLint、全量类型检查与编码检查均通过。后端代码未在本轮修改中触碰,因此未执行 `npm run api-server:maincloud`。 +以上相关测试、局部 ESLint、全量类型检查与编码检查均通过。后端代码未在本轮修改中触碰,因此未执行 `npm run api-server`。 ## 2026-04-27 第五轮误导链路闭口 @@ -232,4 +232,4 @@ npx eslint src/hooks/rpg-session/useRpgSessionBootstrap.ts src/hooks/useGameFlow npm run typecheck -- --pretty false ``` -以上测试、ESLint 与类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server:maincloud`。 +以上测试、ESLint 与类型检查已通过。后端代码未在本轮修改中触碰,因此仍不需要执行 `npm run api-server`。 diff --git a/docs/technical/RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md b/docs/technical/RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md index a9bbd81c..35312bbe 100644 --- a/docs/technical/RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md +++ b/docs/technical/RPG_WORK_DELETE_SPACETIMEDB_PROCEDURE_EXPORT_FIX_2026-04-25.md @@ -4,15 +4,15 @@ 创作页或作品详情删除 RPG 作品时报 `No such procedure`。 -本次核对 Maincloud `xushi-p4wfr` schema 后确认: +本次核对 `xushi-p4wfr` schema 后确认: 1. 已发布 / 作品库 profile 删除依赖 `delete_custom_world_profile_and_return`。 2. 草稿作品删除依赖 `delete_custom_world_agent_session`。 -3. 本地 Rust client 绑定里存在 `delete_custom_world_agent_session`,但 Maincloud schema 中没有该 procedure。 +3. 本地 Rust client 绑定里存在 `delete_custom_world_agent_session`,但目标 schema 中没有该 procedure。 ## 根因 -`server-rs/crates/spacetime-module/src/custom_world/mod.rs` 中有一份草稿删除实现,但当前有效发布入口仍是 `server-rs/crates/spacetime-module/src/lib.rs` 中的 custom world 实现。`lib.rs` 未导出 `delete_custom_world_agent_session`,导致发布到 Maincloud 的模块 schema 缺少该 procedure。 +`server-rs/crates/spacetime-module/src/custom_world/mod.rs` 中有一份草稿删除实现,但当前有效发布入口仍是 `server-rs/crates/spacetime-module/src/lib.rs` 中的 custom world 实现。`lib.rs` 未导出 `delete_custom_world_agent_session`,导致发布后的模块 schema 缺少该 procedure。 ## 落地口径 @@ -31,7 +31,7 @@ 1. `cargo check -p spacetime-module --manifest-path server-rs/Cargo.toml` 2. `npm run check:encoding` -3. `npm run spacetime:publish:maincloud` -4. 发布后用 `spacetime describe xushi-p4wfr --server maincloud --json` 确认 schema 包含: +3. 发布最新 SpacetimeDB wasm。 +4. 发布后用 `spacetime describe xushi-p4wfr --server <目标服务> --json` 确认 schema 包含: - `delete_custom_world_profile_and_return` - `delete_custom_world_agent_session` diff --git a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md index 2dc49f6b..ae2aa550 100644 --- a/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md +++ b/docs/technical/RUST_LOCAL_AND_REMOTE_DEPLOYMENT_SCRIPTS_2026-04-22.md @@ -112,9 +112,9 @@ npm run dev:rust:logs -- --follow 2. 仅供测试断言使用的辅助函数使用 `#[cfg(test)]` 限定,避免进入 `cargo run -p api-server` 的普通二进制编译。 3. 已无调用入口且无迁移价值的映射函数直接删除;如果后续新增同类 SpacetimeDB 记录映射,再按实际调用路径补回,避免提前保留死代码。 -Maincloud API 重启补充: +api-server 单独重启补充: -1. `npm run api-server:maincloud` 会先读取 `.env`、`.env.local`,把 `GENARRATIVE_SPACETIME_MAINCLOUD_*` 映射为 `api-server` 使用的 `GENARRATIVE_SPACETIME_*`,再运行 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`。 +1. `npm run api-server` 会先读取 `.env`、`.env.local`,使用 `GENARRATIVE_SPACETIME_*` 启动 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`。 2. Windows 下脚本会尽力停止本仓库 `server-rs/target/debug/api-server.exe` 对应的旧进程,避免 cargo 重新编译时 exe 被占用。 3. 旧进程已经退出或清理过程中出现瞬时等待失败时,不应阻断新的 `api-server` 启动;脚本只记录清理失败并继续启动。 @@ -191,7 +191,7 @@ cd build/ ./stop.sh ``` -如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/`、`api-server`、`spacetime_module.wasm`、`migration-bootstrap-secret.txt`、`scripts/`、`.env*`、`start.sh`、`stop.sh`、`web-server.mjs`、`README.md` 等发布产物;后台管理前端位于 `web/admin/`,随 `web/` 一并覆盖。文件产物使用普通复制,`web/`、`scripts/` 等目录产物递归复制,不会删除部署目录中的 `.spacetimedb/`、`logs/`、`run/`、`deploy-state/`、`database-migrations/` 这类运行态目录。Jenkins 覆盖 `.env.local` 时会保留目标部署目录已有的 `GENARRATIVE_SPACETIME_TOKEN` / `GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN`,避免后台表统计在部署后失去读取 private 表所需的 owner 身份。 +如果后续通过 Jenkins 的部署脚本把发布包覆盖到固定部署目录,部署阶段默认只替换 `web/`、`api-server`、`spacetime_module.wasm`、`migration-bootstrap-secret.txt`、`scripts/`、`.env*`、`start.sh`、`stop.sh`、`web-server.mjs`、`README.md` 等发布产物;后台管理前端位于 `web/admin/`,随 `web/` 一并覆盖。文件产物使用普通复制,`web/`、`scripts/` 等目录产物递归复制,不会删除部署目录中的 `.spacetimedb/`、`logs/`、`run/`、`deploy-state/`、`database-migrations/` 这类运行态目录。Jenkins 覆盖 `.env.local` 时会保留目标部署目录已有的 `GENARRATIVE_SPACETIME_TOKEN`,避免后台表统计在部署后失去读取 private 表所需的 owner 身份。 安全边界: diff --git a/docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md b/docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md new file mode 100644 index 00000000..0c092f05 --- /dev/null +++ b/docs/technical/SPACETIMEDB_CLOUD_CONFIG_REMOVAL_2026-05-02.md @@ -0,0 +1,42 @@ +# SpacetimeDB 云端配置移除记录 + +日期:`2026-05-02` + +## 1. 目标 + +当前项目不再使用旧云端 SpacetimeDB 目标,仓库内不再保留云端专用配置、脚本入口和文档默认口径。后续开发、迁移、Jenkins 流水线与后台验证均以本地或显式传入的 SpacetimeDB 服务为准。 + +## 2. 入口调整 + +1. 根工程后端单独启动入口统一为: + +```bash +npm run api-server +``` + +2. 该入口读取 `.env`、`.env.local` 与当前进程环境中的 `GENARRATIVE_SPACETIME_*`,默认服务地址回落到 `http://127.0.0.1:3101`。 +3. 已移除旧云端发布入口和脚本。SpacetimeDB 模块发布继续通过本地联调脚本、发布包 `start.sh` 或显式 `spacetime publish --server ` 执行。 + +## 3. 环境变量口径 + +`api-server`、迁移脚本和 Jenkins 部署脚本只使用以下运行变量: + +```text +GENARRATIVE_SPACETIME_SERVER_URL +GENARRATIVE_SPACETIME_DATABASE +GENARRATIVE_SPACETIME_TOKEN +``` + +旧云端专用变量不再作为兼容回退读取。需要连接其它 SpacetimeDB 服务时,必须显式设置 `GENARRATIVE_SPACETIME_SERVER_URL` 或在脚本参数中传入 `--server-url`。 + +## 4. 迁移脚本口径 + +1. `scripts/spacetime-migration-common.mjs` 默认 server 为本地 `dev`,解析到 `http://127.0.0.1:3101`。 +2. 授权、撤销、导出等 CLI 调用会显式传 `-s`,避免落回机器上的 SpacetimeDB CLI 默认服务。 +3. Jenkins 数据库导入导出流水线默认 `SERVER=dev`,需要操作其它目标时必须显式填写 `SERVER_URL`。 + +## 5. 后续约束 + +1. 新增 SpacetimeDB 运维脚本时,不允许把云端服务写成默认值。 +2. 文档中的验证命令统一使用 `npm run api-server`。 +3. 如果某次任务需要连接非本地 SpacetimeDB,必须在文档和验证记录中写清楚实际 `SERVER_URL`、数据库名和 root-dir。 diff --git a/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md b/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md index 294c0069..cc8ab42f 100644 --- a/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md +++ b/docs/technical/SPACETIMEDB_JSON_STRING_MIGRATION_PROCEDURE_2026-04-27.md @@ -13,7 +13,7 @@ SpacetimeDB reducer 必须保持确定性,不能访问文件系统和网络。 procedure 不再访问 HTTP 文件桥,也不接收部署机本地文件路径。这样可以避开 SpacetimeDB 对 private/special-purpose 地址的 HTTP 访问限制,并避免把 private 表内容通过临时 HTTP 服务转发。 -SpacetimeDB Wasm 运行环境不支持 `std::time::SystemTime::now()`,procedure 或 reducer 内需要当前时间时必须使用 `ctx.timestamp`。如果共享 crate 同时服务前端/本地纯逻辑与 SpacetimeDB 模块,应提供 `*_at(now_ms)` 或显式时间参数版本,SpacetimeDB 模块只调用注入时间的函数,避免发布后在 maincloud 触发 `time not implemented on this platform` panic。 +SpacetimeDB Wasm 运行环境不支持 `std::time::SystemTime::now()`,procedure 或 reducer 内需要当前时间时必须使用 `ctx.timestamp`。如果共享 crate 同时服务前端/本地纯逻辑与 SpacetimeDB 模块,应提供 `*_at(now_ms)` 或显式时间参数版本,SpacetimeDB 模块只调用注入时间的函数,避免发布后触发 `time not implemented on this platform` panic。 `spacetime login show --token` 输出的是 CLI 登录 token,不是 HTTP `/v1/database/.../call` 所需的数据库连接 token。导入脚本如果没有显式传 `--token`,会自动调用 `POST /v1/identity` 获取 Web API token;迁移时不要把 CLI token 传给 `--token`。 @@ -41,17 +41,13 @@ SpacetimeDB Wasm 运行环境不支持 `std::time::SystemTime::now()`,procedur 运维流程: -```bash -npm run spacetime:publish:maincloud -- --database -# 控制台会输出: -# [spacetime:maincloud] 迁移引导密钥: <本次发布随机密钥> -``` +本地开发发布时,`npm run dev:rust` 会在发布模块前输出本次随机生成的迁移引导密钥。发布包部署时,`npm run deploy:rust:remote` 会把同一份密钥写入发布包根目录的 `migration-bootstrap-secret.txt`,目标服务器执行 `./start.sh` 发布 wasm 时也会再次显示该密钥。 发布完成后,在同一台机器上用当前 `spacetime login` 身份授权操作员: ```bash node scripts/spacetime-authorize-migration-operator.mjs \ - --server maincloud \ + --server dev \ --database xushi-p4wfr \ --bootstrap-secret <本次发布随机密钥> \ --operator-identity \ @@ -62,7 +58,7 @@ node scripts/spacetime-authorize-migration-operator.mjs \ ```bash node scripts/spacetime-revoke-migration-operator.mjs \ - --server maincloud \ + --server dev \ --database xushi-p4wfr \ --operator-identity ``` @@ -71,9 +67,8 @@ node scripts/spacetime-revoke-migration-operator.mjs \ ### 发布脚本密钥行为 -当前所有会构建或发布 `spacetime-module` 的脚本默认都会生成并显示迁移引导密钥: +当前会构建或发布 `spacetime-module` 的脚本默认都会生成并显示迁移引导密钥: -- `npm run spacetime:publish:maincloud`:在本机 `cargo build` 前生成密钥,控制台输出 `[spacetime:maincloud] 迁移引导密钥: ...`。 - `npm run dev:rust`:在本地 `spacetime publish --module-path` 前生成密钥,控制台输出 `[dev:rust] 迁移引导密钥: ...`。 - `npm run deploy:rust:remote`:在构建发布包 wasm 前生成密钥,控制台输出 `[deploy:rust] 迁移引导密钥: ...`,并把同一份密钥写入发布包根目录的 `migration-bootstrap-secret.txt`。服务器执行 `./start.sh` 发布 wasm 时也会再次显示该文件里的密钥。 @@ -145,11 +140,11 @@ Node 导入脚本默认在文件超过 `524288` bytes 时使用分片导入; ### 发布冲突自动迁移 -`npm run spacetime:publish:maincloud` 默认采用冲突感知发布: +Ubuntu 发布包的 `start.sh` 默认采用冲突感知发布: 1. 先不清库发布新 wasm。 2. 如果发布成功,流程结束。 -3. 如果发布失败且输出可判定为 schema 冲突,脚本自动导出旧库迁移 JSON 到 `tmp/spacetime-migrations/maincloud//.json`。 +3. 如果发布失败且输出可判定为 schema 冲突,脚本自动导出旧库迁移 JSON 到 `database-migrations//.json` 或 `GENARRATIVE_SPACETIME_MIGRATION_DIR` 指定目录。 4. 导出成功后执行清库发布新 wasm。 5. 新 wasm 发布成功后,把第 3 步导出的 JSON 自动导入回灌。 @@ -159,15 +154,11 @@ SpacetimeDB 2.1 对 schema 冲突的报错文案可能不再包含 `schema confl 任一阶段失败都会中止流程,并保留已经导出的迁移 JSON。非 schema 冲突的发布失败不会进入迁移流程。 -```bash -npm run spacetime:publish:maincloud -- --database xushi-p4wfr -``` - 可选参数: -- `--no-migrate-on-conflict`:禁用冲突自动迁移,只保留原始发布失败。 -- `--migration-dir `:指定迁移 JSON 输出目录。 -- `--clear-database`:显式清库发布;该模式代表人工确认清库,不触发自动迁移。 +- `GENARRATIVE_SPACETIME_MIGRATE_ON_CONFLICT=false`:禁用冲突自动迁移,只保留原始发布失败。 +- `GENARRATIVE_SPACETIME_MIGRATION_DIR=`:指定迁移 JSON 输出目录。 +- `./start.sh --clear-database`:显式清库发布;该模式代表人工确认清库,不触发自动迁移。 冲突自动迁移需要发布脚本本次生成的 `GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET`。因此不要和 `--no-migration-bootstrap-secret` 同时使用。 @@ -237,7 +228,7 @@ node scripts/spacetime-export-migration-json.mjs \ ```bash node scripts/spacetime-authorize-migration-operator.mjs \ - --server maincloud \ + --server dev \ --database xushi-p4wfr \ --bootstrap-secret <服务器目标库发布时输出的随机密钥> \ --operator-identity <服务器 spacetime login show 中的 identity> \ @@ -248,7 +239,7 @@ node scripts/spacetime-authorize-migration-operator.mjs \ ```bash node scripts/spacetime-import-migration-json.mjs \ - --server maincloud \ + --server dev \ --database xushi-p4wfr \ --bootstrap-secret <服务器目标库发布时输出的随机密钥> \ --in tmp/spacetime-migrations/source-2026-04-27.json @@ -258,7 +249,7 @@ node scripts/spacetime-import-migration-json.mjs \ ```bash node scripts/spacetime-import-migration-json.mjs \ - --server maincloud \ + --server dev \ --database xushi-p4wfr \ --bootstrap-secret <服务器目标库发布时输出的随机密钥> \ --in tmp/spacetime-migrations/source-2026-04-27.json \ @@ -298,7 +289,7 @@ node scripts/spacetime-export-migration-json.mjs \ --include ai_task,ai_task_stage,ai_text_chunk,ai_result_reference ``` -`--server` 支持 `dev`、`local`、`maincloud`,也可以直接传 SpacetimeDB 服务器 URL。导出、授权、撤销默认走 `spacetime call`,使用当前机器的 CLI 登录态;导入默认走 Web API request body,避免大 JSON 触发命令行长度限制。数据库名可通过 `--database`、`GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE` 或 `GENARRATIVE_SPACETIME_DATABASE` 提供。 +`--server` 支持 `dev`、`local`,也可以直接传 SpacetimeDB 服务器 URL。导出、授权、撤销默认走 `spacetime call`,使用当前机器的 CLI 登录态;导入默认走 Web API request body,避免大 JSON 触发命令行长度限制。数据库名可通过 `--database` 或 `GENARRATIVE_SPACETIME_DATABASE` 提供。 授权脚本额外支持: @@ -319,7 +310,7 @@ node scripts/spacetime-export-migration-json.mjs \ - 拼图:`puzzle_agent_session`、`puzzle_agent_message`、`puzzle_work_profile`、`puzzle_runtime_run` - 大鱼:`big_fish_creation_session`、`big_fish_agent_message`、`big_fish_asset_slot` -`big_fish_runtime_run` 当前运行态已由前端本地运行服务承接,不再加入迁移白名单;但 maincloud 旧库仍可能存在该表。为避免热升级被 “Removing the table big_fish_runtime_run requires a manual migration” 阻断,模块发布期可以保留兼容空壳表,后续确认旧数据可丢弃后再走正式删除表迁移。 +`big_fish_runtime_run` 当前运行态已由前端本地运行服务承接,不再加入迁移白名单;但旧库仍可能存在该表。为避免热升级被 “Removing the table big_fish_runtime_run requires a manual migration” 阻断,模块发布期可以保留兼容空壳表,后续确认旧数据可丢弃后再走正式删除表迁移。 后续新增 SpacetimeDB 表时,必须同步把表加入迁移白名单与本文档。 diff --git a/docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md b/docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md index b7817b50..8d3e5583 100644 --- a/docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md +++ b/docs/technical/SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md @@ -16,7 +16,7 @@ error starting database: failed to init replica 1 for : m 2. `replica 1` 的持久化数据仍带有旧库 `c20037fcfaac4e5c4b1f492f026a4f6119a98f56319b77f21ef021ededf8b7ae`。 3. SpacetimeDB 因同一个副本目录中 identity 不一致而拒绝继续启动。 -这不是 Rust 编译错误,也不是 `api-server:maincloud` 的 token 错误。只要错误来自 `server-rs/.spacetimedb/local/.../spacetime-standalone.log`,优先按本地 root-dir 数据目录污染处理。 +这不是 Rust 编译错误,也不是 `api-server` 的 token 错误。只要错误来自 `server-rs/.spacetimedb/local/.../spacetime-standalone.log`,优先按本地 root-dir 数据目录污染处理。 ## 2. 根因 @@ -37,7 +37,7 @@ server-rs/.spacetimedb/local 1. 不在脚本里默认删除 `.spacetimedb` 数据,避免误删本地开发数据。 2. 如果只是本地开发库且数据可丢弃,优先备份后重建 `data` 目录。 3. 如果数据必须保留,不要清理目录;应改回创建旧库时使用的 database/root-dir,或先导出迁移数据。 -4. Maincloud 发布与本地 standalone root-dir 是两条链路;不要通过切回 `server-node` 或 PostgreSQL 绕过。 +4. 本地 standalone root-dir 与其它部署目标是两条链路;不要通过切回 `server-node` 或 PostgreSQL 绕过。 ## 4. 本地可丢弃数据时的修复 @@ -77,7 +77,7 @@ npm run dev:rust 1. 用旧库对应的 database/root-dir 重新启动。 2. 使用迁移导出脚本导出旧数据,再清理本地 root-dir 并导入到新库。 -3. 如目标其实是 Maincloud,改用 `npm run api-server:maincloud` 连接云端,避免误启动本地 standalone。 +3. 如目标其实是其它已运行的 SpacetimeDB 服务,改用 `GENARRATIVE_SPACETIME_SERVER_URL` 指向该服务,避免误启动本地 standalone。 ## 6. 脚本诊断 diff --git a/docs/technical/SPACETIMEDB_MAINCLOUD_PUBLISH_2026-04-24.md b/docs/technical/SPACETIMEDB_MAINCLOUD_PUBLISH_2026-04-24.md deleted file mode 100644 index 3948086d..00000000 --- a/docs/technical/SPACETIMEDB_MAINCLOUD_PUBLISH_2026-04-24.md +++ /dev/null @@ -1,63 +0,0 @@ -# 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. 发布前输出目标数据库名和 server,便于在 Jenkins 或手工日志中确认实际发布目标。 -4. 输出 `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. 在 Windows 启动前检查 `server-rs/target/debug/api-server.exe` 对应的旧进程;如果旧进程仍在运行,先停止它,避免 Rust 编译阶段覆盖 exe 时出现 `failed to remove file ... 拒绝访问。 (os error 5)`。 -4. 启动 `cargo run -p api-server --manifest-path server-rs/Cargo.toml`。 - -## 设计约束 - -- Maincloud 数据库名必须显式配置,不能默认读取本地 `spacetime.local.json`。 -- Maincloud 数据库名必须匹配 `^[a-z0-9]+(-[a-z0-9]+)*$`,只能使用小写字母、数字,并用单个短横线分隔;否则 `spacetime publish` 会报 `invalid characters in database name`。 -- 发布脚本只处理 SpacetimeDB 模块发布,不启动本地 SpacetimeDB。 -- `api-server` 继续通过 `SpacetimeClientConfig` 的 `server_url / database / token` 连接数据库,不在前端增加逻辑。 -- Windows 进程清理只能匹配本仓库 `server-rs/target/debug/api-server.exe` 的完整路径,不能按进程名泛化清理,避免影响其他 Rust 服务。 diff --git a/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md b/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md index e8fdd394..50e8e788 100644 --- a/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md +++ b/docs/technical/SPACETIMEDB_START_SH_PUBLISH_403_IDENTITY_FIX_2026-04-26.md @@ -20,7 +20,7 @@ SpacetimeDB 的数据库更新权限绑定到创建或被授权的身份。只 1. 部署机上执行 `start.sh` 的用户切换过 `spacetime login` 身份。 2. 固定部署目录保留了旧 `.spacetimedb/`,但当前 CLI 身份不是旧数据库创建者。 -3. `GENARRATIVE_SPACETIME_SERVER_URL` 指向 Maincloud,而当前 CLI 身份不是该 Maincloud 数据库的所有者或授权成员。 +3. `GENARRATIVE_SPACETIME_SERVER_URL` 指向其它 SpacetimeDB 服务,而当前 CLI 身份不是该数据库的所有者或授权成员。 4. `.env.local` 中的 `GENARRATIVE_SPACETIME_DATABASE` 指向了另一个环境的数据库名或数据库 identity。 ## 3. 落地修复 @@ -62,7 +62,7 @@ mv .spacetimedb ".spacetimedb.backup.$(date +%Y%m%d-%H%M%S)" 2. 找到创建该数据库的 SpacetimeDB 身份。 3. 用该身份对应的 CLI root 执行发布,或在 SpacetimeDB 侧补授权后再发布。 -如果目标是 Maincloud: +如果目标是其它 SpacetimeDB 服务: 1. 执行 `spacetime login show` 确认当前身份。 2. 确认该身份对 `GENARRATIVE_SPACETIME_DATABASE` 有更新权限。 diff --git a/docs/technical/WORK_PUBLISH_SHARE_PANEL_2026-05-02.md b/docs/technical/WORK_PUBLISH_SHARE_PANEL_2026-05-02.md new file mode 100644 index 00000000..16aedd54 --- /dev/null +++ b/docs/technical/WORK_PUBLISH_SHARE_PANEL_2026-05-02.md @@ -0,0 +1,56 @@ +# 作品发布完成分享面板 2026-05-02 + +## 背景 + +当前 RPG、拼图、大鱼吃小鱼、抓大鹅 Match3D 的发布链路已经能把作品同步到公开广场,但发布成功后的用户反馈主要是跳转到作品详情或按钮状态变化。用户完成发布后缺少一个明确的“分享给朋友”收口动作,导致公开作品号与链接不够显眼。 + +## 目标 + +发布动作确认成功后弹出独立平台风面板: + +1. 面板标题固定为“分享给朋友”。 +2. 标题下显示可复制的分享文本。 +3. 分享文本下方显示主按钮“分享”,点击后复制完整分享文本。 +4. 页面底部显示三个分享渠道 icon:微信、QQ、抖音。 +5. 移动端使用底部弹层,桌面端居中展示,复用 `UnifiedModal` 的平台弹窗外壳。 + +## 分享文本 + +分享文本统一使用: + +```text +邀请你来玩《作品名》 +作品号:公开作品码 +公开链接 +``` + +公开作品码来源: + +- RPG:优先使用发布后作品库 / 公开详情返回的 `publicWorkCode`。 +- 拼图:使用 `buildPuzzlePublicWorkCode(profileId)`。 +- 大鱼吃小鱼:使用 `buildBigFishPublicWorkCode(sourceSessionId)`。 +- 抓大鹅 Match3D:使用 `buildMatch3DPublicWorkCode(profileId)`。 + +公开链接统一使用 `buildPublicWorkStagePath(stage, publicWorkCode)` 转换为当前站点绝对链接。 + +## 渠道 icon 规则 + +本次只做前端分享引导,不接入微信、QQ、抖音的原生 SDK。点击渠道 icon 与主“分享”按钮保持一致,复制同一份分享文本。 + +仓库现有 `media/social-media-group/wechat.png` 与 `qq.png` 是社群二维码,不作为本面板渠道 icon 使用。渠道 icon 采用轻量圆形文字标识,避免误导用户进入社群。 + +## 接入范围 + +- `RpgCreationResultActionBar`:RPG 发布成功后由父层回传分享数据并打开面板。 +- `PuzzleResultView`:拼图发布 action 完成后由平台父层打开面板。 +- `BigFishResultView`:大鱼发布 action 完成后由平台父层打开面板。 +- `Match3DResultView`:本地 `publishMatch3DWork` 成功后直接触发面板数据,分享链接对齐现有作品详情入口。 +- `PlatformEntryFlowShellImpl`:集中维护发布完成分享状态,避免各玩法重复实现弹窗。 + +## 验收标准 + +1. 用户完成作品发布后能看到“分享给朋友”面板。 +2. 面板内展示完整分享文本,主按钮点击后复制成功并短暂显示成功态。 +3. 底部固定展示微信、QQ、抖音三个渠道 icon。 +4. 关闭面板不影响已发布作品进入详情、刷新广场或继续游玩。 +5. 不新增后端接口,不改动 SpacetimeDB 表结构。 diff --git a/jenkins/Jenkinsfile.database-export b/jenkins/Jenkinsfile.database-export index 808416d6..f89a988d 100644 --- a/jenkins/Jenkinsfile.database-export +++ b/jenkins/Jenkinsfile.database-export @@ -15,7 +15,7 @@ pipeline { string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '执行节点标签') string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB 数据库名,留空则读取环境变量') - string(name: 'SERVER', defaultValue: 'maincloud', description: 'SpacetimeDB server 别名,例如 maincloud/local/dev') + string(name: 'SERVER', defaultValue: 'dev', description: 'SpacetimeDB server 别名,例如 dev/local') string(name: 'SERVER_URL', defaultValue: '', description: 'SpacetimeDB server URL,填写后优先于 SERVER') string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录,ROOT_DIR 为空时使用其 .spacetimedb') string(name: 'ROOT_DIR', defaultValue: '', description: 'spacetime CLI root-dir,可选,优先于 DEPLOY_DIRECTORY') diff --git a/jenkins/Jenkinsfile.database-import b/jenkins/Jenkinsfile.database-import index 140fa23d..790799bf 100644 --- a/jenkins/Jenkinsfile.database-import +++ b/jenkins/Jenkinsfile.database-import @@ -15,7 +15,7 @@ pipeline { string(name: 'AGENT_LABEL', defaultValue: 'built-in', description: '执行节点标签') string(name: 'GENARRATIVE_WORKSPACE_ROOT', defaultValue: '', description: '源码根目录,留空则使用当前 Jenkins 工作区') string(name: 'DATABASE', defaultValue: '', description: 'SpacetimeDB 数据库名,留空则读取环境变量') - string(name: 'SERVER', defaultValue: 'maincloud', description: 'SpacetimeDB server 别名,例如 maincloud/local/dev') + string(name: 'SERVER', defaultValue: 'dev', description: 'SpacetimeDB server 别名,例如 dev/local') string(name: 'SERVER_URL', defaultValue: '', description: 'SpacetimeDB server URL,填写后优先于 SERVER') string(name: 'DEPLOY_DIRECTORY', defaultValue: '/var/lib/jenkins/deploy/Genarrative', description: '固定部署目录,ROOT_DIR 为空时使用其 .spacetimedb') string(name: 'ROOT_DIR', defaultValue: '', description: 'spacetime CLI root-dir,可选,优先于 DEPLOY_DIRECTORY') diff --git a/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.input.json b/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.input.json new file mode 100644 index 00000000..88d1a6c5 --- /dev/null +++ b/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": true, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:bootstrap\n\n目标:\n1. 先把世界的基本方向抓住\n2. 不要一次塞太多新设定\n3. 回复要降低用户开口压力\n\n本轮行为要求:\n1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索\n2. 如果用户信息很少,不要强行把整套结构一次补满\n3. replyText 要像共创搭档,而不是像审问\n4. 默认只推进一个最关键的问题方向\n5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步\n6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题\n7. 不要把问题问得像表单采集,不要一口气追问多个维度\n\n用户体验要求:\n1. 让用户觉得“现在很容易继续往下说”\n2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉\n3. replyText 最好短、稳、可接话\n4. 如果用户信息很少,也不要显得冷淡或机械\n\n本轮用户输入较少或较虚。\n请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。\nreplyText 要让用户容易继续往下说。\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: sparse\n- driftRisk: low\n- conversationMode: bootstrap\n- judgementSummary: 用户仅提供了一个非常概括的题材词,信息密度极低,处于最初始阶段。正式生成时应使用启发式提问,提出一个最易于回答、最能推动设计落地的问题,比如关于这个王国的性质或主角身份。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": null,\n \"playerFantasy\": null,\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" + }, + { + "role": "user", + "content": "请按约定输出这一轮的 JSON。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.output.txt b/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.output.txt new file mode 100644 index 00000000..6397774e --- /dev/null +++ b/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.output.txt @@ -0,0 +1,131 @@ +event: response.created +data: {"type":"response.created","response":{"created_at":1777642663,"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"created_at":1777642663,"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"了解","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"的","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"创作","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"背景","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"和","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"灵感","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"来源","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"帮助你","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"构建","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"这个","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"游戏","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"世界","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"让我","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":28} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":29} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":30} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"相关信息","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":31} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":32} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n","sequence_number":33} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"part":{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"},"sequence_number":34} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"}],"status":"completed","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},"sequence_number":35} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"},"sequence_number":36} + +event: response.web_search_call.in_progress +data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":37} + +event: response.web_search_call.searching +data: {"type":"response.web_search_call.searching","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":38} + +event: response.web_search_call.completed +data: {"type":"response.web_search_call.completed","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":39} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 游戏设定 世界观;玩具题材游戏 设计灵感;王国主题玩具世界","type":"search"},"status":"completed","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"},"sequence_number":40} + +event: error +data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":41} + +event: response.failed +data: {"type":"response.failed","response":{"created_at":1777642663,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"}],"status":"completed","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},{"type":"web_search_call","action":{"query":"玩具王国 游戏设定 世界观;玩具题材游戏 设计灵感;王国主题玩具世界","type":"search"},"status":"completed","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":1791,"output_tokens":122,"total_tokens":1913,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":42} + +data: [DONE] + diff --git a/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.input.json b/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.input.json new file mode 100644 index 00000000..ca3df2cf --- /dev/null +++ b/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": true, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:bootstrap\n\n目标:\n1. 先把世界的基本方向抓住\n2. 不要一次塞太多新设定\n3. 回复要降低用户开口压力\n\n本轮行为要求:\n1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索\n2. 如果用户信息很少,不要强行把整套结构一次补满\n3. replyText 要像共创搭档,而不是像审问\n4. 默认只推进一个最关键的问题方向\n5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步\n6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题\n7. 不要把问题问得像表单采集,不要一口气追问多个维度\n\n用户体验要求:\n1. 让用户觉得“现在很容易继续往下说”\n2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉\n3. replyText 最好短、稳、可接话\n4. 如果用户信息很少,也不要显得冷淡或机械\n\n本轮用户输入较少或较虚。\n请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。\nreplyText 要让用户容易继续往下说。\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: sparse\n- driftRisk: low\n- conversationMode: bootstrap\n- judgementSummary: 用户仅提供了核心题材词,信息非常稀疏,尚未形成任何具体方向。正式生成时,应避免空泛回应,必须采用一个启发式提问来推动设计落地,例如聚焦于玩家身份或世界氛围。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": null,\n \"playerFantasy\": null,\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" + }, + { + "role": "user", + "content": "请按约定输出这一轮的 JSON。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.output.txt b/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.output.txt new file mode 100644 index 00000000..d1ee8032 --- /dev/null +++ b/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.output.txt @@ -0,0 +1,117 @@ +event: response.created +data: {"type":"response.created","response":{"created_at":1777644754,"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"created_at":1777644754,"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"的相关","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"信息","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"理解","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"这个概念","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"为","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"我们的","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"共创","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"提供","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"更有","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"价值的","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"参考","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":28} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n","sequence_number":29} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"part":{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"},"sequence_number":30} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"}],"status":"completed","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},"sequence_number":31} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"},"sequence_number":32} + +event: response.web_search_call.in_progress +data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":33} + +event: response.web_search_call.searching +data: {"type":"response.web_search_call.searching","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":34} + +event: response.web_search_call.completed +data: {"type":"response.web_search_call.completed","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":35} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 游戏设定;玩具王国 世界观;玩具主题 游戏","type":"search"},"status":"completed","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"},"sequence_number":36} + +event: error +data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":37} + +event: response.failed +data: {"type":"response.failed","response":{"created_at":1777644754,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"}],"status":"completed","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},{"type":"web_search_call","action":{"query":"玩具王国 游戏设定;玩具王国 世界观;玩具主题 游戏","type":"search"},"status":"completed","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":1808,"output_tokens":120,"total_tokens":1928,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":38} + diff --git a/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.input.json b/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.input.json new file mode 100644 index 00000000..5e3d8b4f --- /dev/null +++ b/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": true, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:bootstrap\n\n目标:\n1. 先把世界的基本方向抓住\n2. 不要一次塞太多新设定\n3. 回复要降低用户开口压力\n\n本轮行为要求:\n1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索\n2. 如果用户信息很少,不要强行把整套结构一次补满\n3. replyText 要像共创搭档,而不是像审问\n4. 默认只推进一个最关键的问题方向\n5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步\n6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题\n7. 不要把问题问得像表单采集,不要一口气追问多个维度\n\n用户体验要求:\n1. 让用户觉得“现在很容易继续往下说”\n2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉\n3. replyText 最好短、稳、可接话\n4. 如果用户信息很少,也不要显得冷淡或机械\n\n本轮用户输入较少或较虚。\n请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。\nreplyText 要让用户容易继续往下说。\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: sparse\n- driftRisk: low\n- conversationMode: bootstrap\n- judgementSummary: 用户输入简短,仅确认了玩家身份方向,信息密度低。正式生成时应采用启发式提问,聚焦于让这个身份更具体、更有故事感,例如询问孩子为何闯入或初始处境。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": \"一个由玩具构成的鲜活王国,等待玩家探索与守护。\",\n \"playerFantasy\": null,\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"好的,我捕捉到核心了:一个关于“玩具王国”的世界。为了让它变得具体,我们可以先聊聊玩家在这个王国里会是谁?比如,是某个玩具的守护者,还是一个闯入这里的人类孩子?\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"误闯入这里的人类孩子\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" + }, + { + "role": "user", + "content": "请按约定输出这一轮的 JSON。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.output.txt b/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.output.txt new file mode 100644 index 00000000..948a5618 --- /dev/null +++ b/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.output.txt @@ -0,0 +1,117 @@ +event: response.created +data: {"type":"response.created","response":{"created_at":1777644794,"id":"resp_02177764479383248aafbb39a6fef3e9b38f22991150366ae7197","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903993},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"created_at":1777644794,"id":"resp_02177764479383248aafbb39a6fef3e9b38f22991150366ae7197","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903993},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764479470200000000000000000000ffffac1549fec91d99"},"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"和","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"误","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"闯入","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"的人类","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"孩子","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"的","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"创意","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"灵感","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"来","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"帮助我们","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"构建","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"这个世界","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":28} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":29} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n","sequence_number":30} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"part":{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n"},"sequence_number":31} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n"}],"status":"completed","id":"msg_02177764479470200000000000000000000ffffac1549fec91d99"},"sequence_number":32} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df"},"sequence_number":33} + +event: response.web_search_call.in_progress +data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":34} + +event: response.web_search_call.searching +data: {"type":"response.web_search_call.searching","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":35} + +event: response.web_search_call.completed +data: {"type":"response.web_search_call.completed","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":36} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国创意设定;人类孩子误入奇幻世界;玩具主题游戏世界观","type":"search"},"status":"completed","id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df"},"sequence_number":37} + +event: error +data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":38} + diff --git a/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.input.json b/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.input.json new file mode 100644 index 00000000..fdc46d60 --- /dev/null +++ b/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": true, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:force_complete\n\n目标:\n1. 基于当前方向直接补齐剩余设定\n2. 生成一版尽量完整、可进入下一阶段的设定结构\n3. 结束当前收集阶段\n\n本轮行为要求:\n1. 尽量保留已经形成的世界方向\n2. 对明显缺失的关键维度进行合理补全\n3. 不要继续拉长聊天,不要再追问用户\n4. progressPercent 直接输出为 100\n5. replyText 要自然引导用户点击“生成游戏设定草稿”\n6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突\n7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经\n8. replyText 更像阶段完成提示,不再像继续采集信息的对话\n\n用户体验要求:\n1. 让用户感到“系统已经帮我把能补的补好了”\n2. 不要在这一步突然冒出很多陌生设定把用户吓出戏\n3. 回复要有完成感,但不要太官话\n4. 清楚告诉用户下一步可以做什么\n\n本轮用户把部分决定权交给你。\n你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。\n新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。\n\n用户刚刚主动要求你自动补充剩余关键字。\n\n这表示用户接受你基于当前方向自行补完仍缺失的关键设定:当前 RPG 世界方向里的剩余设定\n\n本轮要求:\n1. 不要再继续提问\n2. 不要要求用户再提供世界观、角色、冲突或禁忌信息\n3. 必须保留已有已确认内容,并直接补齐缺失或仍为空的关键项\n4. 对你自行推断补齐的项,应标记或表达为系统推断;已有明确内容继续保持确认或锁定状态\n5. progressPercent 直接输出为 100\n6. 直接输出一版尽量完整的设定结构\n7. replyText 只做简短完成说明,引导用户可以进入“生成游戏设定草稿”,不能出现问号\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: delegate\n- driftRisk: low\n- conversationMode: force_complete\n- judgementSummary: 用户明确授权系统自动补全剩余设定,应停止提问,直接生成完整且自洽的世界设定,并确保与已有方向一致。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": \"一个由玩具构成的鲜活王国,等待玩家探索与守护。\",\n \"playerFantasy\": \"作为一个误入的人类孩子,你渴望找到回家的路,同时在这个充满秘密与危险的玩具王国中找到自己的位置。\",\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"好的,我捕捉到核心了:一个关于“玩具王国”的世界。为了让它变得具体,我们可以先聊聊玩家在这个王国里会是谁?比如,是某个玩具的守护者,还是一个闯入这里的人类孩子?\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"误闯入这里的人类孩子\",\n \"role\": \"user\"\n },\n {\n \"content\": \"明白了,一个误闯入玩具王国的孩子。那么,这个孩子是怀着怎样的心情,又是怎样闯进这里的?比如,是躲进衣柜时无意发现的,还是因为某个特别的玩具?\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"请补充剩余设定。\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" + }, + { + "role": "user", + "content": "请按约定输出这一轮的 JSON。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.output.txt b/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.output.txt new file mode 100644 index 00000000..0ff6ee84 --- /dev/null +++ b/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.output.txt @@ -0,0 +1,155 @@ +event: response.created +data: {"type":"response.created","response":{"created_at":1777644839,"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"created_at":1777644839,"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"基于","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"当前的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"为用户","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"补","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"全","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"剩余的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"内容","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"让我","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"、","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"童话","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"、","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":28} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"儿童","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":29} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"奇幻","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":30} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"世界的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":31} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"参考","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":32} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"信息","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":33} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":34} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":35} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":36} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"完善","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":37} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"这个","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":38} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":39} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":40} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n","sequence_number":41} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"part":{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"},"sequence_number":42} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"}],"status":"completed","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},"sequence_number":43} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"},"sequence_number":44} + +event: response.web_search_call.in_progress +data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":45} + +event: response.web_search_call.searching +data: {"type":"response.web_search_call.searching","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":46} + +event: response.web_search_call.completed +data: {"type":"response.web_search_call.completed","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":47} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 童话设定;儿童奇幻世界 游戏设定;玩具主题 RPG 游戏","type":"search"},"status":"completed","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"},"sequence_number":48} + +event: error +data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":49} + +event: response.failed +data: {"type":"response.failed","response":{"created_at":1777644839,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"}],"status":"completed","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},{"type":"web_search_call","action":{"query":"玩具王国 童话设定;儿童奇幻世界 游戏设定;玩具主题 RPG 游戏","type":"search"},"status":"completed","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":2173,"output_tokens":130,"total_tokens":2303,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":50} + +data: [DONE] + diff --git a/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.input.json b/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.input.json new file mode 100644 index 00000000..2080ed3c --- /dev/null +++ b/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": false, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。" + }, + { + "role": "user", + "content": "请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物、地图细节或多幕场景内容。\n玩家设定:\n世界承诺:\"一个由玩具构成的鲜活王国,等待玩家探索与守护。\"\n玩家幻想:\"作为一个误入的人类孩子,你渴望找到回家的路,同时在这个充满秘密与危险的玩具王国中找到自己的位置。\"\n主题边界:\"主题是童真与成长的冒险,美术方向偏向温暖、怀旧但带有神秘感的玩具屋风格,避免过于黑暗或成人化的恐怖元素。\"\n玩家切入口:\"你是一个在阁楼发现一个发光音乐盒的孩子,随着音乐响起,你被缩小并吸入了这个由玩具构成的王国。\"\n核心冲突:\"玩具王国正面临“遗忘之尘”的侵蚀,这会让玩具们失去活力并最终石化。冲突的首次触发,是玩家亲眼目睹一个熟悉的玩具朋友开始变得僵硬。\"\n关键关系:\"与引路者“修补匠泰迪”的师徒关系,他知晓离开的方法但需要帮助;与对立者“兵人指挥官”的竞争关系,他视人类为威胁,但其偏执源于一段被主人遗弃的伤痛。\"\n暗线与揭示节奏:\"玩具王国并非自然存在,它是由孩子们强烈的情感与记忆共同维系的梦境边疆。“遗忘之尘”的真相,是现实世界中孩子们正在长大并逐渐遗忘。\"\n标志元素与硬规则:\"标志性的“心之齿轮”动力源、会说话的绒毛玩具与发条士兵、王国中央的“记忆之树”、硬规则:玩具不能直接伤害人类孩子,但环境与魔法可以。\"\n\n输出 JSON 模板:\n{\n \"name\": \"世界名称\",\n \"subtitle\": \"世界副标题\",\n \"summary\": \"世界概述\",\n \"tone\": \"世界基调\",\n \"playerGoal\": \"玩家核心目标\",\n \"templateWorldType\": \"WUXIA|XIANXIA\",\n \"majorFactions\": [\"势力甲\", \"势力乙\"],\n \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],\n \"attributeSchema\": {\n \"slots\": [\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" }\n ]\n },\n \"camp\": {\n \"name\": \"开局归处名称\",\n \"description\": \"这是玩家进入世界后的第一处落脚点描述\"\n }\n}\n\n要求:\n- 所有生成文本都必须使用中文。\n- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。\n- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。\n- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。\n- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。\n- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。\n- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。\n- attributeSchema 必须是本世界专属的角色六维名称体系,slots 必须恰好 6 个,每个 slot 只输出 name,维度名必须是 2 到 4 个汉字且互不重复。\n- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。\n- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段。\n- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。\n- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.output.txt b/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.output.txt new file mode 100644 index 00000000..0e8be144 --- /dev/null +++ b/logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.output.txt @@ -0,0 +1 @@ +{"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin Request id: 0217776464712214e907dae862d8462a9d84f058f87472e323c64","param":"","type":"NotFound"}} \ No newline at end of file diff --git a/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.input.json b/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.input.json new file mode 100644 index 00000000..2080ed3c --- /dev/null +++ b/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": false, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。" + }, + { + "role": "user", + "content": "请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n这一步只保留世界顶层信息与一个开局归处占位,不要输出 playableNpcs、storyNpcs、landmarks,也不要展开人物、地图细节或多幕场景内容。\n玩家设定:\n世界承诺:\"一个由玩具构成的鲜活王国,等待玩家探索与守护。\"\n玩家幻想:\"作为一个误入的人类孩子,你渴望找到回家的路,同时在这个充满秘密与危险的玩具王国中找到自己的位置。\"\n主题边界:\"主题是童真与成长的冒险,美术方向偏向温暖、怀旧但带有神秘感的玩具屋风格,避免过于黑暗或成人化的恐怖元素。\"\n玩家切入口:\"你是一个在阁楼发现一个发光音乐盒的孩子,随着音乐响起,你被缩小并吸入了这个由玩具构成的王国。\"\n核心冲突:\"玩具王国正面临“遗忘之尘”的侵蚀,这会让玩具们失去活力并最终石化。冲突的首次触发,是玩家亲眼目睹一个熟悉的玩具朋友开始变得僵硬。\"\n关键关系:\"与引路者“修补匠泰迪”的师徒关系,他知晓离开的方法但需要帮助;与对立者“兵人指挥官”的竞争关系,他视人类为威胁,但其偏执源于一段被主人遗弃的伤痛。\"\n暗线与揭示节奏:\"玩具王国并非自然存在,它是由孩子们强烈的情感与记忆共同维系的梦境边疆。“遗忘之尘”的真相,是现实世界中孩子们正在长大并逐渐遗忘。\"\n标志元素与硬规则:\"标志性的“心之齿轮”动力源、会说话的绒毛玩具与发条士兵、王国中央的“记忆之树”、硬规则:玩具不能直接伤害人类孩子,但环境与魔法可以。\"\n\n输出 JSON 模板:\n{\n \"name\": \"世界名称\",\n \"subtitle\": \"世界副标题\",\n \"summary\": \"世界概述\",\n \"tone\": \"世界基调\",\n \"playerGoal\": \"玩家核心目标\",\n \"templateWorldType\": \"WUXIA|XIANXIA\",\n \"majorFactions\": [\"势力甲\", \"势力乙\"],\n \"coreConflicts\": [\"冲突甲\", \"冲突乙\"],\n \"attributeSchema\": {\n \"slots\": [\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" },\n { \"name\": \"维度名\" }\n ]\n },\n \"camp\": {\n \"name\": \"开局归处名称\",\n \"description\": \"这是玩家进入世界后的第一处落脚点描述\"\n }\n}\n\n要求:\n- 所有生成文本都必须使用中文。\n- 这一步只输出顶层 10 个字段:name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、attributeSchema、camp。\n- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。\n- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。\n- camp 只表示玩家开局时的落脚处占位,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念;不要在这一步生成开局场景任务、三幕事件或三幕背景。\n- 不要输出 playableNpcs、storyNpcs、landmarks、items,也不要输出任何角色和地图细节。\n- majorFactions 保持 2 到 3 个,coreConflicts 保持 2 到 3 个。\n- attributeSchema 必须是本世界专属的角色六维名称体系,slots 必须恰好 6 个,每个 slot 只输出 name,维度名必须是 2 到 4 个汉字且互不重复。\n- attributeSchema.slots 的 name 禁止使用:生命、法力、护甲、攻击、防御、力量、敏捷、智力、精神;不要写通用 DND 或传统四维属性。\n- 不要在 attributeSchema.slots 内输出 definition、positiveSignals、negativeSignals、combatUseText、socialUseText、explorationUseText 或其他说明字段。\n- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。\n- 每个字符串尽量简洁:subtitle 控制在 8 到 18 个汉字内,summary 控制在 16 到 32 个汉字内,tone 控制在 6 到 16 个汉字内,playerGoal 控制在 16 到 32 个汉字内,camp.description 控制在 18 到 40 个汉字内。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.output.txt b/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.output.txt new file mode 100644 index 00000000..a55e22eb --- /dev/null +++ b/logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.output.txt @@ -0,0 +1 @@ +{"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin Request id: 021777646789563cb7552bf2b2738992c27085ac90ab905f9fd41","param":"","type":"NotFound"}} \ No newline at end of file diff --git a/logs/llm-raw/1777646955503-66824-000003-request_timeout.input.json b/logs/llm-raw/1777646955503-66824-000003-request_timeout.input.json new file mode 100644 index 00000000..23b51460 --- /dev/null +++ b/logs/llm-raw/1777646955503-66824-000003-request_timeout.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": false, + "attempt": 2, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。" + }, + { + "role": "user", + "content": "请根据下面的世界核心信息,生成一批场景角色框架名单。\n后续我会继续补全人物档案,所以这一步每个角色只保留身份骨架与资产默认描述字段。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n世界核心信息:\n世界:心忆王国\n副标题:记忆织就的玩具边疆\n世界概述:一个由孩子们强烈情感与记忆维系的玩具梦境世界,正面临遗忘之尘的侵蚀危机。\n世界基调:温暖怀旧中透着神秘感的童真冒险\n玩家核心目标:在寻找回家之路的同时,帮助玩具王国抵抗遗忘之尘的侵蚀,揭开王国存亡的真相。\n主要势力:修补匠工坊、钢铁军团、绒毛议会\n核心冲突:遗忘之尘侵蚀与玩具石化危机、人类孩子身份引发的信任与偏见、守护记忆与面对成长的永恒抉择\n开局归处:修补匠小屋(泰迪的工作室兼临时居所,堆满各种工具与半成品玩具,散发着松木与机油混合的安心气味。)\n输出 JSON 模板:\n{\n \"storyNpcs\": [\n {\n \"name\": \"角色名称\",\n \"title\": \"称号\",\n \"role\": \"身份\",\n \"description\": \"极简定位描述\",\n \"visualDescription\": \"默认角色形象描述\",\n \"actionDescription\": \"默认角色动作描述\",\n \"sceneVisualDescription\": \"默认出现场景描述\",\n \"initialAffinity\": 18,\n \"relationshipHooks\": [\"一个关系切入口\"],\n \"tags\": [\"标签1\", \"标签2\"]\n }\n ]\n}\n要求:\n- 必须生成恰好 2 个场景角色。\n- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。\n- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。\n- 只保留:name、title、role、description、visualDescription、actionDescription、sceneVisualDescription、initialAffinity、relationshipHooks、tags。\n- visualDescription 是打开角色形象图像生成面板时默认填入的角色形象描述,必须具体到体型、服装、轮廓与识别点,控制在 24 到 60 个汉字内。\n- actionDescription 是打开每个角色动作视频生成面板时默认填入的动作描述,必须体现该角色默认动作节奏、武器或施法方式,控制在 18 到 48 个汉字内。\n- sceneVisualDescription 是该角色常出现或关联的场景画面描述,会作为场景生图描述框的默认候选,控制在 24 到 60 个汉字内。\n- relationshipHooks 最多 1 条;tags 保持 1 到 2 个。\n- description 控制在 8 到 18 个汉字内,title 和 role 也尽量短。\n- initialAffinity 必须是 -40 到 90 的整数。\n- 场景角色要覆盖势力成员、居民、异类或怪物,不要全是同一种身份;敌对或怪物型角色可以使用负好感。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777646955503-66824-000003-request_timeout.output.txt b/logs/llm-raw/1777646955503-66824-000003-request_timeout.output.txt new file mode 100644 index 00000000..2947b922 --- /dev/null +++ b/logs/llm-raw/1777646955503-66824-000003-request_timeout.output.txt @@ -0,0 +1 @@ +LLM 请求超时,累计尝试 2 次 \ No newline at end of file diff --git a/package.json b/package.json index f90fb29d..61129b1b 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,8 @@ "admin-web:build": "node scripts/admin-web-build.mjs build", "admin-web:typecheck": "node scripts/admin-web-build.mjs typecheck", "admin-web:preview": "npm --prefix apps/admin-web run preview --", - "spacetime:publish:maincloud": "node scripts/run-bash-script.mjs scripts/spacetime-publish-maincloud.sh", "spacetime:generate": "node scripts/generate-spacetime-bindings.mjs", - "api-server:maincloud": "node scripts/api-server-maincloud.mjs", + "api-server": "node scripts/api-server-dev.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", "build": "node scripts/build-gate.mjs", diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 5d96445c..39131649 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -46,6 +46,7 @@ export type PuzzleAgentActionRequest = workTitle?: string; workDescription?: string; pictureDescription?: string; + imageModel?: string | null; } | { action: 'compile_puzzle_draft'; @@ -54,6 +55,7 @@ export type PuzzleAgentActionRequest = workDescription?: string; pictureDescription?: string; referenceImageSrc?: string | null; + imageModel?: string | null; candidateCount?: number; } | { @@ -61,7 +63,12 @@ export type PuzzleAgentActionRequest = levelId?: string | null; promptText?: string | null; referenceImageSrc?: string | null; + imageModel?: string | null; candidateCount?: number; + workTitle?: string; + workDescription?: string; + summary?: string; + themeTags?: string[]; levelsJson?: string; } | { diff --git a/packages/shared/src/contracts/puzzleAgentSession.ts b/packages/shared/src/contracts/puzzleAgentSession.ts index f580f0a6..fe53c241 100644 --- a/packages/shared/src/contracts/puzzleAgentSession.ts +++ b/packages/shared/src/contracts/puzzleAgentSession.ts @@ -1,4 +1,7 @@ -import type { PuzzleAgentActionResponse, PuzzleAgentSuggestedAction } from './puzzleAgentActions'; +import type { + PuzzleAgentActionResponse, + PuzzleAgentSuggestedAction, +} from './puzzleAgentActions'; import type { PuzzleAnchorPack, PuzzleResultDraft } from './puzzleAgentDraft'; import type { PuzzleResultPreviewEnvelope } from './puzzleResultPreview'; @@ -47,6 +50,7 @@ export interface CreatePuzzleAgentSessionRequest { workDescription?: string; pictureDescription?: string; referenceImageSrc?: string | null; + imageModel?: string | null; } export interface CreatePuzzleAgentSessionResponse { @@ -59,6 +63,7 @@ export interface SendPuzzleAgentMessageRequest { quickFillRequested?: boolean; } -export interface SendPuzzleAgentMessageResponse extends PuzzleAgentActionResponse { +export interface SendPuzzleAgentMessageResponse + extends PuzzleAgentActionResponse { session: PuzzleAgentSessionSnapshot; } diff --git a/scripts/api-server-maincloud.mjs b/scripts/api-server-dev.mjs similarity index 71% rename from scripts/api-server-maincloud.mjs rename to scripts/api-server-dev.mjs index 92709e10..c90de96f 100644 --- a/scripts/api-server-maincloud.mjs +++ b/scripts/api-server-dev.mjs @@ -37,25 +37,18 @@ function loadEnvFile(path, target) { const mergedEnv = { ...process.env }; loadEnvFile(resolve(repoRoot, '.env'), mergedEnv); loadEnvFile(resolve(repoRoot, '.env.local'), mergedEnv); +loadEnvFile(resolve(repoRoot, '.env.secrets.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 || - ''; + mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL || 'http://127.0.0.1:3101'; +mergedEnv.GENARRATIVE_SPACETIME_DATABASE = mergedEnv.GENARRATIVE_SPACETIME_DATABASE || ''; +mergedEnv.GENARRATIVE_SPACETIME_TOKEN = mergedEnv.GENARRATIVE_SPACETIME_TOKEN || ''; if (!mergedEnv.GENARRATIVE_SPACETIME_DATABASE) { console.error( - '[api-server:maincloud] 缺少 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE 或 GENARRATIVE_SPACETIME_DATABASE。', + '[api-server] 缺少 GENARRATIVE_SPACETIME_DATABASE。', ); process.exit(1); } @@ -78,7 +71,7 @@ function stopExistingWindowsApiServer() { ' Wait-Process -Id $process.Id -Timeout 5 -ErrorAction SilentlyContinue', ' Write-Output $process.Id', ' } catch {', - ' Write-Error "[api-server:maincloud] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"', + ' Write-Error "[api-server] 忽略旧进程清理瞬时失败 pid=$($process.Id): $($_.Exception.Message)"', ' }', '}', 'exit 0', @@ -97,7 +90,7 @@ function stopExistingWindowsApiServer() { ).trim(); if (output) { - console.log(`[api-server:maincloud] 已停止旧 api-server 进程: ${output}`); + console.log(`[api-server] 已停止旧 api-server 进程: ${output}`); } } @@ -105,13 +98,13 @@ try { stopExistingWindowsApiServer(); } catch (error) { console.error( - `[api-server:maincloud] 清理旧 api-server 进程失败: ${error.message}`, + `[api-server] 清理旧 api-server 进程失败: ${error.message}`, ); process.exit(1); } console.log( - `[api-server:maincloud] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`, + `[api-server] SpacetimeDB ${mergedEnv.GENARRATIVE_SPACETIME_DATABASE} @ ${mergedEnv.GENARRATIVE_SPACETIME_SERVER_URL}`, ); const child = spawn( @@ -125,13 +118,13 @@ const child = spawn( ); child.on('error', (error) => { - console.error(`[api-server:maincloud] 启动 cargo 失败: ${error.message}`); + console.error(`[api-server] 启动 cargo 失败: ${error.message}`); process.exit(1); }); child.on('exit', (code, signal) => { if (signal) { - console.error(`[api-server:maincloud] api-server 被信号终止: ${signal}`); + console.error(`[api-server] api-server 被信号终止: ${signal}`); process.exit(1); } diff --git a/scripts/deploy-rust-remote.sh b/scripts/deploy-rust-remote.sh index 9dcafc08..6d774ac8 100644 --- a/scripts/deploy-rust-remote.sh +++ b/scripts/deploy-rust-remote.sh @@ -1128,7 +1128,7 @@ if ! run_publish "${PUBLISH_LOG}" "${PUBLISH_ARGS[@]}"; then echo "[start] 当前 start.sh 使用的 CLI root: ${SPACETIME_ROOT_DIR}" >&2 spacetime --root-dir="${SPACETIME_ROOT_DIR}" login show >&2 || true echo "[start] 如果目标是本地库且可以清空数据:先执行 ./stop.sh,备份或删除 ${SPACETIME_ROOT_DIR},再重新执行 ./start.sh --clear-database。" >&2 - echo "[start] 如果目标是 Maincloud 或必须保留数据:请切换到创建该数据库的 SpacetimeDB 身份,或把 GENARRATIVE_SPACETIME_DATABASE 改为当前身份有权限的库。" >&2 + echo "[start] 如果必须保留数据:请切换到创建该数据库的 SpacetimeDB 身份,或把 GENARRATIVE_SPACETIME_DATABASE 改为当前身份有权限的库。" >&2 exit 1 fi else diff --git a/scripts/jenkins-deploy-release.sh b/scripts/jenkins-deploy-release.sh index 427e29f7..aa26a7ef 100644 --- a/scripts/jenkins-deploy-release.sh +++ b/scripts/jenkins-deploy-release.sh @@ -181,7 +181,6 @@ MIGRATION_IMPORT_TOKEN="" PRESERVED_MIGRATION_EXPORT_TOKEN="" PRESERVED_MIGRATION_IMPORT_TOKEN="" PRESERVED_SPACETIME_TOKEN="" -PRESERVED_SPACETIME_MAINCLOUD_TOKEN="" DEPLOY_COMPLETED="0" RESTORE_PREVIOUS_MIGRATION_BOOTSTRAP_SECRET_ON_FAILURE="0" DEPLOY_ITEMS=( @@ -402,7 +401,6 @@ normalize_release_env_files "${SOURCE_DIR}" PRESERVED_MIGRATION_EXPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_EXPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" PRESERVED_MIGRATION_IMPORT_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" PRESERVED_SPACETIME_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" -PRESERVED_SPACETIME_MAINCLOUD_TOKEN="$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" if [[ -x "${DEPLOY_DIR}/stop.sh" ]]; then echo "[jenkins-deploy] 先停止旧版本: ${DEPLOY_DIR}" @@ -464,14 +462,8 @@ elif [[ -n "${PRESERVED_MIGRATION_IMPORT_TOKEN}" ]] \ write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MIGRATION_IMPORT_TOKEN" "${PRESERVED_MIGRATION_IMPORT_TOKEN}" fi if [[ -n "${PRESERVED_SPACETIME_TOKEN}" ]] \ - && [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]] \ - && [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then - write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_TOKEN" "${PRESERVED_SPACETIME_TOKEN}" -fi -if [[ -n "${PRESERVED_SPACETIME_MAINCLOUD_TOKEN}" ]] \ - && [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]] \ && [[ -z "$(read_env_value "GENARRATIVE_SPACETIME_TOKEN" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" ]]; then - write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN" "${PRESERVED_SPACETIME_MAINCLOUD_TOKEN}" + write_env_override "${DEPLOY_DIR}/.env.local" "GENARRATIVE_SPACETIME_TOKEN" "${PRESERVED_SPACETIME_TOKEN}" fi DEPLOY_DATABASE="$(read_env_value "GENARRATIVE_SPACETIME_DATABASE" "${DEPLOY_DIR}/.env" "${DEPLOY_DIR}/.env.local")" diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index 091f3ebe..bb065993 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -8,27 +8,15 @@ export function parseArgs(argv) { process.env.GENARRATIVE_SPACETIME_MIGRATION_CHUNK_SIZE, 'GENARRATIVE_SPACETIME_MIGRATION_CHUNK_SIZE', ), - database: - process.env.GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE || - process.env.GENARRATIVE_SPACETIME_DATABASE || - '', + database: process.env.GENARRATIVE_SPACETIME_DATABASE || '', bootstrapSecret: process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET || '', includeTables: [], operatorIdentity: process.env.GENARRATIVE_SPACETIME_MIGRATION_OPERATOR_IDENTITY || '', passthrough: [], note: '', - server: - process.env.GENARRATIVE_SPACETIME_MAINCLOUD_SERVER || - process.env.GENARRATIVE_SPACETIME_SERVER || - '', - serverUrl: - process.env.GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL || - process.env.GENARRATIVE_SPACETIME_SERVER_URL || - '', - token: - process.env.GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN || - process.env.GENARRATIVE_SPACETIME_TOKEN || - '', + server: process.env.GENARRATIVE_SPACETIME_SERVER || '', + serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '', + token: process.env.GENARRATIVE_SPACETIME_TOKEN || '', }; for (let index = 0; index < argv.length; index += 1) { @@ -117,11 +105,7 @@ export function buildSpacetimeCallArgs(options, procedureName, input) { args.push(`--root-dir=${options.rootDir}`); } args.push('call'); - if (options.server) { - args.push('-s', options.server); - } else if (options.serverUrl) { - args.push('-s', options.serverUrl); - } + args.push('-s', resolveCliServer(options)); args.push(...options.passthrough); if (!options.passthrough.includes('--no-config')) { args.push('--no-config'); @@ -388,7 +372,7 @@ export function resolveServerUrl(options) { return options.serverUrl; } - const server = (options.server || 'maincloud').trim(); + const server = (options.server || 'dev').trim(); if (server.startsWith('http://') || server.startsWith('https://')) { return server; } @@ -398,13 +382,25 @@ export function resolveServerUrl(options) { if (server === 'local') { return 'http://127.0.0.1:3000'; } - if (!server || server === 'maincloud') { - return 'https://maincloud.spacetimedb.com'; + if (!server) { + return 'http://127.0.0.1:3101'; } throw new Error(`未知 SpacetimeDB server: ${server}。请改用 --server-url 显式传入地址。`); } +function resolveCliServer(options) { + if (options.serverUrl) { + return options.serverUrl; + } + + const server = (options.server || '').trim(); + if (!server || server === 'dev') { + return 'http://127.0.0.1:3101'; + } + return server; +} + function trimPreview(text) { const trimmed = text.trim(); if (trimmed.length <= 4000) { diff --git a/scripts/spacetime-publish-maincloud.sh b/scripts/spacetime-publish-maincloud.sh deleted file mode 100644 index 99a299f4..00000000 --- a/scripts/spacetime-publish-maincloud.sh +++ /dev/null @@ -1,283 +0,0 @@ -#!/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 -MIGRATE_ON_CONFLICT=1 -MIGRATION_DIR="" -MIGRATION_BOOTSTRAP_SECRET="" -MIGRATION_BOOTSTRAP_SECRET_MODE="auto" - -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 - npm run spacetime:publish:maincloud -- --no-migrate-on-conflict - npm run spacetime:publish:maincloud -- --no-migration-bootstrap-secret - -说明: - 发布 server-rs/crates/spacetime-module 到 SpacetimeDB Maincloud。 - 数据库名优先读取 --database,其次读取 GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE。 - 默认遇到 schema 冲突时会先导出迁移 JSON,再清库发布并导入回灌。 - 默认在构建 wasm 前随机生成迁移引导密钥,注入 GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET 并显示在控制台。 -EOF -} - -generate_migration_bootstrap_secret() { - node -e 'const crypto = require("crypto"); process.stdout.write(crypto.randomBytes(32).toString("hex"));' -} - -prepare_migration_bootstrap_secret() { - case "${MIGRATION_BOOTSTRAP_SECRET_MODE}" in - auto) - MIGRATION_BOOTSTRAP_SECRET="$(generate_migration_bootstrap_secret)" - ;; - manual) - if [[ "${#MIGRATION_BOOTSTRAP_SECRET}" -lt 16 ]]; then - echo "[spacetime:maincloud] 迁移引导密钥至少需要 16 个字符。" >&2 - exit 1 - fi - ;; - disabled) - unset GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET - echo "[spacetime:maincloud] 未启用迁移引导密钥。" - return - ;; - *) - echo "[spacetime:maincloud] 未知迁移引导密钥模式: ${MIGRATION_BOOTSTRAP_SECRET_MODE}" >&2 - exit 1 - ;; - esac - - export GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET="${MIGRATION_BOOTSTRAP_SECRET}" - echo "[spacetime:maincloud] 迁移引导密钥: ${MIGRATION_BOOTSTRAP_SECRET}" -} - -timestamp_slug() { - node -e 'process.stdout.write(new Date().toISOString().replace(/[:.]/g, "-"));' -} - -validate_spacetime_database_name() { - local database="$1" - - if [[ ! "${database}" =~ ^[a-z0-9]+(-[a-z0-9]+)*$ ]]; then - echo "[spacetime:maincloud] --database 必须匹配 SpacetimeDB 数据库名规则 ^[a-z0-9]+(-[a-z0-9]+)*$,只能使用小写字母、数字,并用单个短横线分隔: ${database}" >&2 - exit 1 - fi -} - -is_publish_conflict_output() { - local output="$1" - [[ "${output}" == *"conflict"* ]] \ - || [[ "${output}" == *"schema"* && "${output}" == *"clear"* ]] \ - || [[ "${output}" == *"manual migration"* ]] \ - || [[ "${output}" == *"default value annotation"* ]] \ - || [[ "${output}" == *"delete-data"* ]] -} - -run_publish() { - local output_file="$1" - shift - set +e - spacetime "$@" >"${output_file}" 2>&1 - local status=$? - set -e - cat "${output_file}" - return "${status}" -} - -run_conflict_migration_publish() { - local migration_root migration_file publish_log - - if [[ "${MIGRATION_BOOTSTRAP_SECRET_MODE}" == "disabled" ]]; then - echo "[spacetime:maincloud] schema 冲突需要迁移引导密钥;请去掉 --no-migration-bootstrap-secret 后重试。" >&2 - exit 1 - fi - - migration_root="${MIGRATION_DIR:-${REPO_ROOT}/tmp/spacetime-migrations/maincloud/${SPACETIME_DATABASE}}" - mkdir -p "${migration_root}" - migration_file="${migration_root}/$(timestamp_slug).json" - publish_log="$(mktemp)" - - echo "[spacetime:maincloud] 检测到 schema 冲突,开始导出旧库迁移 JSON: ${migration_file}" - node "${REPO_ROOT}/scripts/spacetime-export-migration-json.mjs" \ - --server "${SPACETIME_SERVER_ALIAS}" \ - --server-url "${SPACETIME_SERVER_URL}" \ - --database "${SPACETIME_DATABASE}" \ - --bootstrap-secret "${MIGRATION_BOOTSTRAP_SECRET}" \ - --out "${migration_file}" \ - --note "publish conflict export $(date -u +%Y-%m-%dT%H:%M:%SZ)" - - echo "[spacetime:maincloud] 清库发布新 SpacetimeDB wasm" - if ! run_publish "${publish_log}" publish "${SPACETIME_DATABASE}" --server "${SPACETIME_SERVER_ALIAS}" --bin-path "${MODULE_PATH}" --clear-database --yes; then - echo "[spacetime:maincloud] 清库发布失败,迁移 JSON 已保留: ${migration_file}" >&2 - rm -f "${publish_log}" - exit 1 - fi - rm -f "${publish_log}" - - echo "[spacetime:maincloud] 导入迁移 JSON 回灌数据" - if ! node "${REPO_ROOT}/scripts/spacetime-import-migration-json.mjs" \ - --server "${SPACETIME_SERVER_ALIAS}" \ - --server-url "${SPACETIME_SERVER_URL}" \ - --database "${SPACETIME_DATABASE}" \ - --bootstrap-secret "${MIGRATION_BOOTSTRAP_SECRET}" \ - --in "${migration_file}" \ - --note "publish conflict import $(date -u +%Y-%m-%dT%H:%M:%SZ)"; then - echo "[spacetime:maincloud] 导入失败,迁移 JSON 已保留: ${migration_file}" >&2 - exit 1 - fi - - echo "[spacetime:maincloud] schema 冲突迁移完成,迁移 JSON: ${migration_file}" -} - -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 - ;; - --no-migrate-on-conflict) - MIGRATE_ON_CONFLICT=0 - shift - ;; - --migration-dir) - MIGRATION_DIR="${2:?缺少 --migration-dir 的值}" - shift 2 - ;; - --migration-bootstrap-secret) - MIGRATION_BOOTSTRAP_SECRET="${2:?缺少 --migration-bootstrap-secret 的值}" - MIGRATION_BOOTSTRAP_SECRET_MODE="manual" - shift 2 - ;; - --no-migration-bootstrap-secret) - MIGRATION_BOOTSTRAP_SECRET="" - MIGRATION_BOOTSTRAP_SECRET_MODE="disabled" - 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 - -validate_spacetime_database_name "${SPACETIME_DATABASE}" - -echo "[spacetime:maincloud] SpacetimeDB 发布数据库: ${SPACETIME_DATABASE}" -echo "[spacetime:maincloud] SpacetimeDB server: ${SPACETIME_SERVER_ALIAS} (${SPACETIME_SERVER_URL})" - -if ! command -v cargo >/dev/null 2>&1; then - echo "[spacetime:maincloud] 缺少 cargo 命令。" >&2 - exit 1 -fi - -if ! command -v node >/dev/null 2>&1; then - echo "[spacetime:maincloud] 缺少 node 命令,无法生成迁移引导密钥。" >&2 - exit 1 -fi - -if ! command -v spacetime >/dev/null 2>&1; then - echo "[spacetime:maincloud] 缺少 spacetime CLI,请先安装并登录 Maincloud。" >&2 - exit 1 -fi - -prepare_migration_bootstrap_secret - -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}" -PUBLISH_LOG="$(mktemp)" -if ! run_publish "${PUBLISH_LOG}" "${PUBLISH_ARGS[@]}"; then - PUBLISH_OUTPUT="$(cat "${PUBLISH_LOG}")" - rm -f "${PUBLISH_LOG}" - if [[ "${CLEAR_DATABASE}" -eq 0 && "${MIGRATE_ON_CONFLICT}" -eq 1 ]] && is_publish_conflict_output "${PUBLISH_OUTPUT}"; then - run_conflict_migration_publish - else - echo "[spacetime:maincloud] 发布失败。" >&2 - exit 1 - fi -else - rm -f "${PUBLISH_LOG}" -fi - -cat <( where Fut: Future>, { - consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await?; + execute_billable_asset_operation_with_cost( + state, + owner_user_id, + asset_kind, + asset_id, + ASSET_OPERATION_POINTS_COST, + operation, + ) + .await +} + +/// 生图等特殊操作可声明独立光点成本,避免修改全局资产操作默认价格。 +pub(crate) async fn execute_billable_asset_operation_with_cost( + state: &AppState, + owner_user_id: &str, + asset_kind: &str, + asset_id: &str, + points_cost: u64, + operation: Fut, +) -> Result +where + Fut: Future>, +{ + let points_consumed = + consume_asset_operation_points(state, owner_user_id, asset_kind, asset_id, points_cost) + .await?; match operation.await { Ok(value) => Ok(value), Err(error) => { - refund_asset_operation_points(state, owner_user_id, asset_kind, asset_id).await; + if points_consumed { + refund_asset_operation_points( + state, + owner_user_id, + asset_kind, + asset_id, + points_cost, + ) + .await; + } Err(error) } } @@ -35,22 +69,36 @@ async fn consume_asset_operation_points( owner_user_id: &str, asset_kind: &str, asset_id: &str, -) -> Result<(), AppError> { + points_cost: u64, +) -> Result { let ledger_id = format!( "asset_operation_consume:{}:{}:{}", owner_user_id, asset_kind, asset_id ); - state + match state .spacetime_client() .consume_profile_wallet_points( owner_user_id.to_string(), - ASSET_OPERATION_POINTS_COST, + points_cost, ledger_id, current_utc_micros(), ) .await - .map(|_| ()) - .map_err(map_asset_operation_wallet_error) + { + Ok(_) => Ok(true), + Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { + // 中文注释:外部生图不应被 Maincloud 钱包短暂 503 阻断;此时跳过扣费,让业务链路继续,避免用户重复点击。 + tracing::warn!( + owner_user_id, + asset_kind, + asset_id, + error = %error, + "资产操作光点预扣因 SpacetimeDB 连接不可用而降级跳过" + ); + Ok(false) + } + Err(error) => Err(map_asset_operation_wallet_error(error)), + } } /// 外部生成或发布 mutation 失败后补偿退款;退款失败只记日志,避免覆盖原始业务错误。 @@ -59,6 +107,7 @@ async fn refund_asset_operation_points( owner_user_id: &str, asset_kind: &str, asset_id: &str, + points_cost: u64, ) { let ledger_id = format!( "asset_operation_refund:{}:{}:{}", @@ -68,7 +117,7 @@ async fn refund_asset_operation_points( .spacetime_client() .refund_profile_wallet_points( owner_user_id.to_string(), - ASSET_OPERATION_POINTS_COST, + points_cost, ledger_id, current_utc_micros(), ) @@ -104,6 +153,45 @@ pub(crate) fn map_asset_operation_wallet_error(error: SpacetimeClientError) -> A })) } +pub(crate) fn should_skip_asset_operation_billing_for_connectivity( + error: &SpacetimeClientError, +) -> bool { + match error { + SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout => true, + SpacetimeClientError::Build(message) + | SpacetimeClientError::Procedure(message) + | SpacetimeClientError::Runtime(message) => { + message.contains("503") + || message.contains("Service Unavailable") + || message.contains("Failed to connect") + || message.contains("WebSocket") + || message.contains("连接已断开") + || message.contains("连接在返回结果前已断开") + } + } +} + fn current_utc_micros() -> i64 { time::OffsetDateTime::now_utc().unix_timestamp_nanos() as i64 / 1_000 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn asset_operation_billing_skips_spacetime_connectivity_errors() { + assert_eq!(ASSET_OPERATION_POINTS_COST, 1); + assert!(should_skip_asset_operation_billing_for_connectivity( + &SpacetimeClientError::ConnectDropped + )); + assert!(should_skip_asset_operation_billing_for_connectivity( + &SpacetimeClientError::Runtime( + "Failed to connect: HTTP error: 503 Service Unavailable".to_string(), + ), + )); + assert!(!should_skip_asset_operation_billing_for_connectivity( + &SpacetimeClientError::Procedure("光点余额不足".to_string()), + )); + } +} diff --git a/server-rs/crates/api-server/src/assets.rs b/server-rs/crates/api-server/src/assets.rs index 14ab53af..1d07ae66 100644 --- a/server-rs/crates/api-server/src/assets.rs +++ b/server-rs/crates/api-server/src/assets.rs @@ -144,7 +144,7 @@ pub async fn get_asset_history( AssetHistoryListResponse { assets: entries .into_iter() - // 中文注释:Maincloud 旧 wasm 的历史素材 procedure 仍按类型返回,HTTP 门面必须兜底做账号隔离。 + // 中文注释:旧 wasm 的历史素材 procedure 仍按类型返回,HTTP 门面必须兜底做账号隔离。 .filter(|entry| { is_asset_history_owned_by( entry.owner_user_id.as_deref(), diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 236059fe..2ca5ffea 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -90,6 +90,9 @@ pub struct AppConfig { pub dashscope_reference_image_model: String, pub dashscope_cover_image_model: String, pub dashscope_image_request_timeout_ms: u64, + pub apimart_base_url: String, + pub apimart_api_key: Option, + pub apimart_image_request_timeout_ms: u64, pub draft_asset_generation_max_concurrent_requests: usize, pub ark_character_video_base_url: String, pub ark_character_video_api_key: Option, @@ -182,6 +185,9 @@ impl Default for AppConfig { dashscope_reference_image_model: "qwen-image-2.0".to_string(), dashscope_cover_image_model: "wan2.2-t2i-flash".to_string(), dashscope_image_request_timeout_ms: 150_000, + apimart_base_url: "https://api.apimart.ai/v1".to_string(), + apimart_api_key: None, + apimart_image_request_timeout_ms: 180_000, draft_asset_generation_max_concurrent_requests: 4, ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(), ark_character_video_api_key: None, @@ -415,24 +421,19 @@ 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", - "GENARRATIVE_SPACETIME_MAINCLOUD_SERVER_URL", - ]) { + if let Some(spacetime_server_url) = + read_first_non_empty_env(&["GENARRATIVE_SPACETIME_SERVER_URL"]) + { config.spacetime_server_url = spacetime_server_url; } - if let Some(spacetime_database) = read_first_non_empty_env(&[ - "GENARRATIVE_SPACETIME_DATABASE", - "GENARRATIVE_SPACETIME_MAINCLOUD_DATABASE", - ]) { + if let Some(spacetime_database) = + read_first_non_empty_env(&["GENARRATIVE_SPACETIME_DATABASE"]) + { config.spacetime_database = spacetime_database; } - config.spacetime_token = read_first_non_empty_env(&[ - "GENARRATIVE_SPACETIME_TOKEN", - "GENARRATIVE_SPACETIME_MAINCLOUD_TOKEN", - ]); + config.spacetime_token = read_first_non_empty_env(&["GENARRATIVE_SPACETIME_TOKEN"]); if let Some(spacetime_pool_size) = read_first_positive_u32_env(&["GENARRATIVE_SPACETIME_POOL_SIZE"]) { @@ -530,6 +531,18 @@ impl AppConfig { config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms; } + if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) { + config.apimart_base_url = apimart_base_url; + } + + config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]); + + if let Some(apimart_image_request_timeout_ms) = + read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"]) + { + config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms; + } + if let Some(max_concurrent_requests) = read_first_usize_env(&[ "GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS", "DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS", diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 7579d214..ffd92d65 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -1,4 +1,4 @@ -use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; +use platform_llm::{LlmClient, LlmError, LlmMessage, LlmStreamDelta, LlmTextRequest}; use serde_json::Value as JsonValue; use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; @@ -33,10 +33,63 @@ where { let llm_client = llm_client.ok_or_else(|| build_error(messages.model_unavailable.to_string()))?; + let user_prompt = user_prompt.into(); + let turn_output = match request_stream_creation_agent_json_turn( + llm_client, + system_prompt.clone(), + user_prompt.clone(), + enable_web_search, + &mut on_reply_update, + ) + .await + { + Ok(turn_output) => Ok(turn_output), + Err(CreationAgentJsonTurnFailure::Stream(error)) + if enable_web_search && is_web_search_tool_unavailable(&error) => + { + tracing::warn!( + error = %error, + "创作 Agent 联网搜索插件不可用,自动降级为无联网搜索重试" + ); + request_stream_creation_agent_json_turn( + llm_client, + system_prompt, + user_prompt, + false, + &mut on_reply_update, + ) + .await + } + Err(error) => Err(error), + }; + + turn_output.map_err(|error| match error { + CreationAgentJsonTurnFailure::Stream(_) => { + build_error(messages.generation_failed.to_string()) + } + CreationAgentJsonTurnFailure::Parse => build_error(messages.parse_failed.to_string()), + }) +} + +enum CreationAgentJsonTurnFailure { + Stream(LlmError), + Parse, +} + +async fn request_stream_creation_agent_json_turn( + llm_client: &LlmClient, + system_prompt: String, + user_prompt: String, + enable_web_search: bool, + on_reply_update: &mut F, +) -> Result +where + F: FnMut(&str), +{ let mut latest_reply_text = String::new(); let response = llm_client .stream_text( - build_creation_agent_llm_request(system_prompt, user_prompt.into(), enable_web_search), + build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search), |delta: &LlmStreamDelta| { if let Some(reply_progress) = extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) @@ -48,9 +101,9 @@ where }, ) .await - .map_err(|_| build_error(messages.generation_failed.to_string()))?; + .map_err(CreationAgentJsonTurnFailure::Stream)?; let parsed = parse_json_response_text(response.content.as_str()) - .map_err(|_| build_error(messages.parse_failed.to_string()))?; + .map_err(|_| CreationAgentJsonTurnFailure::Parse)?; let reply_text = read_reply_text(&parsed); if let Some(reply_text) = reply_text.as_deref() && reply_text != latest_reply_text @@ -61,6 +114,13 @@ where Ok(CreationAgentJsonTurnOutput { parsed }) } +fn is_web_search_tool_unavailable(error: &LlmError) -> bool { + let message = error.to_string(); + message.contains("ToolNotOpen") + || message.contains("has not activated web search") + || message.contains("未开通") +} + fn build_creation_agent_llm_request( system_prompt: String, user_prompt: String, @@ -168,11 +228,23 @@ fn read_reply_text(parsed: &JsonValue) -> Option { #[cfg(test)] mod tests { + use std::{ + fs, + io::{Read, Write}, + net::TcpListener, + sync::{Arc, Mutex}, + thread, + time::{Duration as StdDuration, SystemTime, UNIX_EPOCH}, + }; + + use platform_llm::{LlmConfig, LlmProvider}; + use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; use super::{ - build_creation_agent_llm_request, extract_reply_text_from_partial_json, - parse_json_response_text, + CreationAgentLlmTurnErrorMessages, build_creation_agent_llm_request, + extract_reply_text_from_partial_json, is_web_search_tool_unavailable, + parse_json_response_text, stream_creation_agent_json_turn, }; #[test] @@ -202,4 +274,214 @@ mod tests { assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses); assert_eq!(request.messages.len(), 2); } + + #[test] + fn detects_upstream_web_search_tool_unavailable_error() { + let error = platform_llm::LlmError::Upstream { + status_code: 502, + message: "Your account has not activated web search. code=ToolNotOpen".to_string(), + }; + + assert!(is_web_search_tool_unavailable(&error)); + } + + #[tokio::test] + async fn stream_turn_retries_without_web_search_when_tool_is_unavailable() { + let log_dir = std::env::temp_dir().join(format!( + "api-server-creation-agent-raw-log-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos() + )); + unsafe { + std::env::set_var("LLM_RAW_LOG_DIR", &log_dir); + } + let success_json = serde_json::json!({ + "replyText": "好,我们先把玩具王国定住。", + "progressPercent": 12, + "nextAnchorContent": { + "worldPromise": "玩具王国初步方向", + "playerFantasy": null, + "themeBoundary": null, + "playerEntryPoint": null, + "coreConflict": null, + "keyRelationships": null, + "hiddenLines": null, + "iconicElements": null + } + }) + .to_string(); + let server = spawn_capturing_mock_server(vec![ + MockResponse { + body: concat!( + "data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n", + "data: [DONE]\n\n" + ) + .to_string(), + }, + MockResponse { + body: format!( + "data: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": success_json + }) + ) + "data: {\"type\":\"response.completed\"}\n\n", + }, + ]); + let config = LlmConfig::new( + LlmProvider::Ark, + server.base_url, + "test-key".to_string(), + "test-model".to_string(), + 30_000, + 0, + 1, + ) + .expect("LLM config should build"); + let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build"); + let mut visible_replies = Vec::new(); + + let output = stream_creation_agent_json_turn( + Some(&llm_client), + "系统提示".to_string(), + "用户提示", + true, + CreationAgentLlmTurnErrorMessages { + model_unavailable: "模型不可用", + generation_failed: "生成失败", + parse_failed: "解析失败", + }, + |text| visible_replies.push(text.to_string()), + |message| message, + ) + .await + .expect("web search fallback should succeed"); + + assert_eq!( + output.parsed["replyText"].as_str(), + Some("好,我们先把玩具王国定住。") + ); + assert_eq!(visible_replies, vec!["好,我们先把玩具王国定住。"]); + + let requests = server.requests.lock().expect("requests lock").clone(); + assert_eq!(requests.len(), 2); + assert!(requests[0].contains("\"tools\"")); + assert!(requests[0].contains("\"web_search\"")); + assert!(!requests[1].contains("\"tools\"")); + + unsafe { + std::env::remove_var("LLM_RAW_LOG_DIR"); + } + if log_dir.exists() { + fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed"); + } + } + + struct MockResponse { + body: String, + } + + struct CapturingMockServer { + base_url: String, + requests: Arc>>, + } + + fn spawn_capturing_mock_server(responses: Vec) -> CapturingMockServer { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + let requests = Arc::new(Mutex::new(Vec::new())); + let requests_for_thread = Arc::clone(&requests); + + thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().expect("request should connect"); + let request_text = read_request(&mut stream); + requests_for_thread + .lock() + .expect("requests lock") + .push(request_text); + write_sse_response(&mut stream, response); + } + }); + + CapturingMockServer { + base_url: format!("http://{address}"), + requests, + } + } + + fn read_request(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(StdDuration::from_secs(1))) + .expect("read timeout should be set"); + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + let mut expected_total = None; + + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(bytes_read) => { + buffer.extend_from_slice(&chunk[..bytes_read]); + + if expected_total.is_none() + && let Some(header_end) = find_header_end(&buffer) + { + let content_length = + read_content_length(&buffer[..header_end]).unwrap_or(0); + expected_total = Some(header_end + content_length); + } + + if let Some(total_bytes) = expected_total + && buffer.len() >= total_bytes + { + break; + } + } + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + || error.kind() == std::io::ErrorKind::TimedOut => + { + break; + } + Err(error) => panic!("mock server failed to read request: {error}"), + } + } + + String::from_utf8_lossy(buffer.as_slice()).to_string() + } + + fn write_sse_response(stream: &mut std::net::TcpStream, response: MockResponse) { + let raw_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.body.len(), + response.body + ); + + stream + .write_all(raw_response.as_bytes()) + .expect("mock response should be written"); + stream.flush().expect("mock response should flush"); + } + + fn find_header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + } + + fn read_content_length(headers: &[u8]) -> Option { + let text = String::from_utf8_lossy(headers); + text.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + return value.trim().parse::().ok(); + } + None + }) + } } diff --git a/server-rs/crates/api-server/src/custom_world.rs b/server-rs/crates/api-server/src/custom_world.rs index 704a7f0c..678de17d 100644 --- a/server-rs/crates/api-server/src/custom_world.rs +++ b/server-rs/crates/api-server/src/custom_world.rs @@ -185,17 +185,22 @@ pub async fn generate_custom_world_profile( ); // 中文注释:profile 生成需要外部 LLM,必须留在 Axum/api-server;SpacetimeDB reducer 只接收确定结果。 - let result = generate_custom_world_foundation_draft(llm_client, &session, |_| {}) - .await - .map_err(|message| { - custom_world_error_response( - &request_context, - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "custom-world-profile", - "message": message, - })), - ) - })?; + let result = generate_custom_world_foundation_draft( + llm_client, + &session, + state.config.creation_agent_llm_web_search_enabled, + |_| {}, + ) + .await + .map_err(|message| { + custom_world_error_response( + &request_context, + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "custom-world-profile", + "message": message, + })), + ) + })?; let mut profile = serde_json::from_str::(&result.draft_profile_json).map_err(|error| { custom_world_error_response( @@ -1775,26 +1780,31 @@ fn spawn_custom_world_draft_foundation_job( Err(error) => Err(format!("已保存底稿序列化失败:{error}")), } } else { - generate_custom_world_foundation_draft(&llm_client, &session, move |progress| { - let progress_state = progress_state.clone(); - let session_id = progress_session_id.clone(); - let owner_user_id = progress_owner_user_id.clone(); - let operation_id = progress_operation_id.clone(); - tokio::spawn(async move { - let _ = upsert_custom_world_draft_foundation_progress( - &progress_state, - &session_id, - &owner_user_id, - &operation_id, - "running", - progress.phase_label.as_str(), - progress.phase_detail.as_str(), - progress.progress, - None, - ) - .await; - }); - }) + generate_custom_world_foundation_draft( + &llm_client, + &session, + state.config.creation_agent_llm_web_search_enabled, + move |progress| { + let progress_state = progress_state.clone(); + let session_id = progress_session_id.clone(); + let owner_user_id = progress_owner_user_id.clone(); + let operation_id = progress_operation_id.clone(); + tokio::spawn(async move { + let _ = upsert_custom_world_draft_foundation_progress( + &progress_state, + &session_id, + &owner_user_id, + &operation_id, + "running", + progress.phase_label.as_str(), + progress.phase_detail.as_str(), + progress.progress, + None, + ) + .await; + }); + }, + ) .await }; diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index 90b79300..44b60817 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -10,6 +10,7 @@ use platform_llm::{LlmClient, LlmMessage, LlmTextRequest}; use serde_json::{Map as JsonMap, Value as JsonValue, json}; use shared_contracts::runtime::ExecuteCustomWorldAgentActionRequest; use spacetime_client::CustomWorldAgentSessionRecord; +use tracing::warn; use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; @@ -35,6 +36,7 @@ pub struct CustomWorldFoundationDraftProgress { pub async fn generate_custom_world_foundation_draft( llm_client: &LlmClient, session: &CustomWorldAgentSessionRecord, + enable_web_search: bool, mut on_progress: impl FnMut(CustomWorldFoundationDraftProgress) + Send, ) -> Result { let setting_text = build_foundation_generation_seed_text(session); @@ -51,6 +53,7 @@ pub async fn generate_custom_world_foundation_draft( |response_text| build_custom_world_framework_json_repair_prompt(response_text), "agent-foundation-framework-json-repair", "世界框架阶段没有返回有效内容。", + enable_web_search, ) .await?; normalize_framework_shape(&mut framework, setting_text.as_str()); @@ -61,6 +64,7 @@ pub async fn generate_custom_world_foundation_draft( "playable", FOUNDATION_DRAFT_PLAYABLE_COUNT, (16, 30), + enable_web_search, &mut on_progress, ) .await?; @@ -72,6 +76,7 @@ pub async fn generate_custom_world_foundation_draft( "story", FOUNDATION_DRAFT_STORY_COUNT, (30, 44), + enable_web_search, &mut on_progress, ) .await?; @@ -82,6 +87,7 @@ pub async fn generate_custom_world_foundation_draft( &framework, FOUNDATION_DRAFT_LANDMARK_COUNT, (44, 66), + enable_web_search, &mut on_progress, ) .await?; @@ -94,6 +100,7 @@ pub async fn generate_custom_world_foundation_draft( &playable_outlines, "narrative", (66, 76), + enable_web_search, &mut on_progress, ) .await?; @@ -104,6 +111,7 @@ pub async fn generate_custom_world_foundation_draft( &playable_narrative, "dossier", (76, 84), + enable_web_search, &mut on_progress, ) .await?; @@ -114,6 +122,7 @@ pub async fn generate_custom_world_foundation_draft( &story_outlines, "narrative", (84, 92), + enable_web_search, &mut on_progress, ) .await?; @@ -124,6 +133,7 @@ pub async fn generate_custom_world_foundation_draft( &story_narrative, "dossier", (92, 96), + enable_web_search, &mut on_progress, ) .await?; @@ -171,22 +181,19 @@ async fn request_foundation_json_stage( repair_prompt_builder: F, repair_debug_label: &str, empty_response_message: &str, + enable_web_search: bool, ) -> Result where F: Fn(&str) -> String, { - let response = llm_client - .request_text( - LlmTextRequest::new(vec![ - LlmMessage::system(FOUNDATION_JSON_ONLY_SYSTEM_PROMPT), - LlmMessage::user(user_prompt), - ]) - .with_model(CREATION_TEMPLATE_LLM_MODEL) - .with_responses_api() - .with_web_search(true), - ) - .await - .map_err(|error| format!("{debug_label} LLM 请求失败:{error}"))?; + let response = request_foundation_text_with_optional_search_fallback( + llm_client, + FOUNDATION_JSON_ONLY_SYSTEM_PROMPT, + user_prompt.as_str(), + debug_label, + enable_web_search, + ) + .await?; let text = response.content.trim(); if text.is_empty() { return Err(empty_response_message.to_string()); @@ -211,12 +218,69 @@ where } } +async fn request_foundation_text_with_optional_search_fallback( + llm_client: &LlmClient, + system_prompt: &str, + user_prompt: &str, + debug_label: &str, + enable_web_search: bool, +) -> Result { + match request_foundation_text(llm_client, system_prompt, user_prompt, enable_web_search).await { + Ok(response) => Ok(response), + Err(error) if enable_web_search && should_retry_foundation_without_web_search(&error) => { + warn!( + error = %error, + debug_label, + "foundation draft 联网搜索增强不可用或超时,自动降级为无联网搜索重试" + ); + request_foundation_text(llm_client, system_prompt, user_prompt, false) + .await + .map_err(|retry_error| format!("{debug_label} LLM 请求失败:{retry_error}")) + } + Err(error) => Err(format!("{debug_label} LLM 请求失败:{error}")), + } +} + +async fn request_foundation_text( + llm_client: &LlmClient, + system_prompt: &str, + user_prompt: &str, + enable_web_search: bool, +) -> Result { + llm_client + .request_text( + LlmTextRequest::new(vec![ + LlmMessage::system(system_prompt), + LlmMessage::user(user_prompt), + ]) + .with_model(CREATION_TEMPLATE_LLM_MODEL) + .with_responses_api() + .with_web_search(enable_web_search), + ) + .await +} + +fn should_retry_foundation_without_web_search(error: &platform_llm::LlmError) -> bool { + match error { + platform_llm::LlmError::Timeout { .. } | platform_llm::LlmError::Connectivity { .. } => { + true + } + platform_llm::LlmError::Upstream { message, .. } => { + message.contains("ToolNotOpen") + || message.contains("has not activated web search") + || message.contains("未开通") + } + _ => false, + } +} + async fn generate_foundation_role_outline_entries( llm_client: &LlmClient, framework: &JsonValue, role_type: &str, total_count: usize, progress_range: (u32, u32), + enable_web_search: bool, on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send), ) -> Result, String> { let mut merged_entries = Vec::new(); @@ -275,6 +339,7 @@ async fn generate_foundation_role_outline_entries( ) .as_str(), "角色框架名单阶段没有返回有效内容。", + enable_web_search, ) .await?; let key = role_key(role_type); @@ -305,6 +370,7 @@ async fn generate_foundation_landmark_seed_entries( framework: &JsonValue, total_count: usize, progress_range: (u32, u32), + enable_web_search: bool, on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send), ) -> Result, String> { let mut merged_entries = Vec::new(); @@ -352,6 +418,7 @@ async fn generate_foundation_landmark_seed_entries( ) .as_str(), "地点框架名单阶段没有返回有效内容。", + enable_web_search, ) .await?; merged_entries.extend(array_field(&raw, "landmarks").into_iter().take(batch_count)); @@ -486,6 +553,7 @@ async fn expand_foundation_role_entries( base_entries: &[JsonValue], stage: &str, progress_range: (u32, u32), + enable_web_search: bool, on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send), ) -> Result, String> { let mut merged_entries = Vec::new(); @@ -540,6 +608,7 @@ async fn expand_foundation_role_entries( ) .as_str(), "角色档案补全阶段没有返回有效内容。", + enable_web_search, ) .await?; merged_entries.extend(array_field(&raw, role_key(role_type))); @@ -2047,7 +2116,7 @@ mod tests { net::TcpListener, sync::{Arc, Mutex}, thread, - time::Duration as StdDuration, + time::{Duration as StdDuration, SystemTime, UNIX_EPOCH}, }; use platform_llm::{DEFAULT_REQUEST_TIMEOUT_MS, LlmConfig, LlmProvider}; @@ -2383,6 +2452,80 @@ mod tests { ); } + #[test] + fn foundation_search_fallback_handles_tool_unavailable_and_timeout() { + let tool_error = platform_llm::LlmError::Upstream { + status_code: 404, + message: "Your account has not activated web search. code=ToolNotOpen".to_string(), + }; + let timeout_error = platform_llm::LlmError::Timeout { attempts: 2 }; + + assert!(should_retry_foundation_without_web_search(&tool_error)); + assert!(should_retry_foundation_without_web_search(&timeout_error)); + assert!(!should_retry_foundation_without_web_search( + &platform_llm::LlmError::EmptyResponse + )); + } + + #[tokio::test] + async fn foundation_json_stage_retries_without_web_search_when_tool_unavailable() { + let log_dir = std::env::temp_dir().join(format!( + "api-server-foundation-raw-log-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos() + )); + unsafe { + std::env::set_var("LLM_RAW_LOG_DIR", &log_dir); + } + let request_capture = Arc::new(Mutex::new(Vec::new())); + let server_url = spawn_mock_server_with_statuses( + request_capture.clone(), + vec![ + MockHttpResponse { + status_code: 404, + body: r#"{"error":{"code":"ToolNotOpen","message":"Your account has not activated web search."}}"#.to_string(), + }, + MockHttpResponse { + status_code: 200, + body: llm_response(r#"{"name":"无搜索底稿"}"#), + }, + ], + ); + let llm_client = build_test_llm_client(server_url); + + let parsed = request_foundation_json_stage( + &llm_client, + "请生成 JSON".to_string(), + "agent-foundation-test", + |_| "修复 JSON".to_string(), + "agent-foundation-test-json-repair", + "空响应", + true, + ) + .await + .expect("web search fallback should succeed"); + + assert_eq!(parsed.get("name"), Some(&json!("无搜索底稿"))); + let requests = request_capture + .lock() + .expect("request capture should lock") + .clone(); + assert_eq!(requests.len(), 2); + assert!(requests[0].contains("\"tools\"")); + assert!(requests[0].contains("\"web_search\"")); + assert!(!requests[1].contains("\"tools\"")); + + unsafe { + std::env::remove_var("LLM_RAW_LOG_DIR"); + } + if log_dir.exists() { + std::fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed"); + } + } + #[tokio::test] async fn role_outline_missing_asset_fields_are_filled_locally_before_details() { let request_capture = Arc::new(Mutex::new(Vec::::new())); @@ -2471,7 +2614,7 @@ mod tests { let llm_client = build_test_llm_client(server_url); let session = build_test_session(); - let result = generate_custom_world_foundation_draft(&llm_client, &session, |_| {}) + let result = generate_custom_world_foundation_draft(&llm_client, &session, false, |_| {}) .await .expect("draft generation should succeed"); let draft_profile = serde_json::from_str::(&result.draft_profile_json) @@ -2739,18 +2882,8 @@ mod tests { fn llm_response(content: &str) -> String { json!({ "id": "resp_01", - "model": "test-model", - "output": [ - { - "type": "message", - "content": [ - { - "type": "output_text", - "text": content, - } - ], - } - ], + "model": CREATION_TEMPLATE_LLM_MODEL, + "output_text": content, "status": "completed" }) .to_string() @@ -2820,6 +2953,27 @@ mod tests { fn spawn_mock_server( request_capture: Arc>>, response_bodies: Vec, + ) -> String { + spawn_mock_server_with_statuses( + request_capture, + response_bodies + .into_iter() + .map(|body| MockHttpResponse { + status_code: 200, + body, + }) + .collect(), + ) + } + + struct MockHttpResponse { + status_code: u16, + body: String, + } + + fn spawn_mock_server_with_statuses( + request_capture: Arc>>, + responses: Vec, ) -> String { let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); let address = listener @@ -2827,10 +2981,13 @@ mod tests { .expect("listener should expose address"); thread::spawn(move || { - let mut response_queue = VecDeque::from(response_bodies); + let mut response_queue = VecDeque::from(responses); for _ in 0..32 { - let response_body = response_queue.pop_front().unwrap_or_else(|| { - llm_response(r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索","backstoryReveal":{"publicSummary":"议会遮掩者。","chapters":[{"affinityRequired":15,"title":"议会","summary":"议会出面。"},{"affinityRequired":30,"title":"封锁","summary":"封锁港口。"},{"affinityRequired":60,"title":"旧案","summary":"旧案松动。"},{"affinityRequired":90,"title":"对质","summary":"灯塔对质。"}]},"skills":[{"name":"封港令","summary":"调动巡海封锁","style":"压制"}],"initialItems":[{"name":"议会印信","category":"道具","quantity":1,"rarity":"rare","description":"可调动巡海队。","tags":["权力"]}]}]}"#) + let response = response_queue.pop_front().unwrap_or_else(|| { + MockHttpResponse { + status_code: 200, + body: llm_response(r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索","backstoryReveal":{"publicSummary":"议会遮掩者。","chapters":[{"affinityRequired":15,"title":"议会","summary":"议会出面。"},{"affinityRequired":30,"title":"封锁","summary":"封锁港口。"},{"affinityRequired":60,"title":"旧案","summary":"旧案松动。"},{"affinityRequired":90,"title":"对质","summary":"灯塔对质。"}]},"skills":[{"name":"封港令","summary":"调动巡海封锁","style":"压制"}],"initialItems":[{"name":"议会印信","category":"道具","quantity":1,"rarity":"rare","description":"可调动巡海队。","tags":["权力"]}]}]}"#), + } }); let (mut stream, _) = listener.accept().expect("request should connect"); let request_text = read_request(&mut stream); @@ -2838,7 +2995,7 @@ mod tests { .lock() .expect("request capture should lock") .push(request_text); - write_response(&mut stream, response_body); + write_response(&mut stream, response); } }); @@ -2886,11 +3043,18 @@ mod tests { String::from_utf8(buffer).expect("request should be utf-8") } - fn write_response(stream: &mut std::net::TcpStream, body: String) { + fn write_response(stream: &mut std::net::TcpStream, response: MockHttpResponse) { + let status_text = if response.status_code == 200 { + "OK" + } else { + "ERROR" + }; let raw_response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body + "HTTP/1.1 {} {}\r\nContent-Type: application/json; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.status_code, + status_text, + response.body.len(), + response.body ); stream .write_all(raw_response.as_bytes()) diff --git a/server-rs/crates/api-server/src/http_error.rs b/server-rs/crates/api-server/src/http_error.rs index 3f398f6e..d9e08bef 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -34,7 +34,6 @@ impl AppError { self.code } - #[cfg(test)] pub fn status_code(&self) -> StatusCode { self.status_code } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 7ea6db43..538d14cf 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -75,9 +75,10 @@ use crate::{app::build_router, config::AppConfig, state::AppState}; #[tokio::main] async fn main() -> Result<(), std::io::Error> { - // 运行本地开发与联调时,优先从仓库根目录的 .env / .env.local 加载变量,避免手工逐项导出 OSS 配置。 + // 运行本地开发与联调时,优先从仓库根目录加载本地变量,避免手工逐项导出 OSS / APIMart 配置。 let _ = dotenvy::from_filename(".env"); let _ = dotenvy::from_filename(".env.local"); + let _ = dotenvy::from_filename(".env.secrets.local"); // 统一先从配置对象读取监听地址,避免后续把环境变量读取散落到入口和路由层。 let config = AppConfig::from_env(); diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index 90a5aaae..a5e28c08 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -47,7 +47,7 @@ use shared_contracts::{ PuzzleWorkProfileResponse, PuzzleWorkSummaryResponse, PuzzleWorksResponse, }, }; -use shared_kernel::build_prefixed_uuid_id; +use shared_kernel::{build_prefixed_uuid_id, format_timestamp_micros}; use spacetime_client::{ PuzzleAgentMessageRecord, PuzzleAgentMessageSubmitRecordInput, PuzzleAgentSessionCreateRecordInput, PuzzleAgentSessionRecord, @@ -69,7 +69,10 @@ use tokio::time::sleep; use crate::{ ai_generation_drafts::{AiGenerationDraftContext, AiGenerationDraftWriter}, api_response::json_success_body, - asset_billing::execute_billable_asset_operation, + asset_billing::{ + execute_billable_asset_operation, execute_billable_asset_operation_with_cost, + should_skip_asset_operation_billing_for_connectivity, + }, auth::AuthenticatedAccessToken, http_error::AppError, platform_errors::map_oss_error, @@ -93,9 +96,14 @@ 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_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; +const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview"; +const PUZZLE_IMAGE_GENERATION_POINTS_COST: u64 = 2; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; +#[cfg(test)] const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; +const PUZZLE_APIMART_GENERATED_IMAGE_SIZE: &str = "1:1"; +const PUZZLE_APIMART_GEMINI_RESOLUTION: &str = "1K"; pub async fn create_puzzle_agent_session( State(state): State, @@ -459,6 +467,7 @@ pub async fn execute_puzzle_agent_action( session_id = %session_id, owner_user_id = %owner_user_id, action = %action, + image_model = resolve_puzzle_image_model(payload.image_model.as_deref()).request_model_name(), prompt_chars = payload .prompt_text .as_deref() @@ -492,11 +501,12 @@ pub async fn execute_puzzle_agent_action( Ok(next_session_id) => next_session_id, Err(response) => return Err(response), }; - let session = execute_billable_asset_operation( + let session = execute_billable_asset_operation_with_cost( &state, &owner_user_id, "puzzle_initial_image", &billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, async { compile_puzzle_draft_with_initial_cover( &state, @@ -504,10 +514,10 @@ pub async fn execute_puzzle_agent_action( owner_user_id.clone(), prompt_text, payload.reference_image_src.as_deref(), + payload.image_model.as_deref(), now, ) .await - .map_err(map_puzzle_compile_error) }, ) .await @@ -542,7 +552,7 @@ pub async fn execute_puzzle_agent_action( let session = match save_result { Ok(session) => Ok(session), Err(error) if is_missing_puzzle_form_draft_procedure_error(&error) => { - // 中文注释:Maincloud 旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。 + // 中文注释:旧 wasm 缺少该自动保存 procedure 时,返回当前 session,避免填表页被非关键错误打断。 tracing::warn!( provider = PUZZLE_AGENT_API_BASE_PROVIDER, session_id = %session_id, @@ -586,18 +596,23 @@ pub async fn execute_puzzle_agent_action( "message": message, })) }); - let session = execute_billable_asset_operation( + let session = execute_billable_asset_operation_with_cost( &state, &owner_user_id, "puzzle_generated_image", &billing_asset_id, + PUZZLE_IMAGE_GENERATION_POINTS_COST, async { let levels_json = levels_json?; - let session = state - .spacetime_client() - .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) - .await - .map_err(map_puzzle_client_error)?; + let session = get_puzzle_session_for_image_generation( + &state, + session_id.clone(), + owner_user_id.clone(), + &payload, + levels_json.as_deref(), + now, + ) + .await?; let mut draft = session.draft.clone().ok_or_else(|| { AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ "provider": PUZZLE_AGENT_API_BASE_PROVIDER, @@ -623,16 +638,12 @@ pub async fn execute_puzzle_agent_action( &target_level.level_name, &prompt, payload.reference_image_src.as_deref(), + payload.image_model.as_deref(), candidate_count, candidate_start_index, ) .await - .map_err(|message| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": PUZZLE_AGENT_API_BASE_PROVIDER, - "message": message, - })) - })?; + .map_err(map_puzzle_generation_endpoint_error)?; let candidates_json = serde_json::to_string( &candidates .iter() @@ -645,18 +656,41 @@ pub async fn execute_puzzle_agent_action( "message": format!("拼图候选图序列化失败:{error}"), })) })?; - state + let save_result = state .spacetime_client() .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { - session_id: session.session_id, + session_id: session.session_id.clone(), owner_user_id: owner_user_id.clone(), - level_id: Some(target_level.level_id), + level_id: Some(target_level.level_id.clone()), levels_json, candidates_json, saved_at_micros: now, }) - .await - .map_err(map_puzzle_client_error) + .await; + match save_result { + Ok(session) => Ok(session), + Err(error) + if should_skip_asset_operation_billing_for_connectivity(&error) => + { + // 中文注释:APIMart/OSS 已生成真实图片时,Maincloud 短暂 503 不应让前端看不到本次图片;先返回内存合成快照,待后续操作恢复正常持久化。 + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session.session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图图片已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" + ); + let fallback_session = + replace_puzzle_session_draft_snapshot(session, draft, now); + Ok(apply_generated_puzzle_candidates_to_session_snapshot( + fallback_session, + target_level.level_id.as_str(), + candidates, + now, + )) + } + Err(error) => Err(map_puzzle_client_error(error)), + } }, ) .await @@ -2107,7 +2141,7 @@ async fn create_seeded_puzzle_session_when_form_save_missing( return Ok(session_id.to_string()); } - // 中文注释:旧 Maincloud 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。 + // 中文注释:旧 wasm 缺自动保存 procedure 时,空 session 无法被编译;这里重建带表单 seed 的 session 保证生成主链可继续。 let replacement_session_id = build_prefixed_uuid_id("puzzle-session-"); let replacement = state .spacetime_client() @@ -2202,6 +2236,140 @@ fn parse_puzzle_level_records_from_module_json( .collect()) } +async fn get_puzzle_session_for_image_generation( + state: &AppState, + session_id: String, + owner_user_id: String, + payload: &ExecutePuzzleAgentActionRequest, + normalized_levels_json: Option<&str>, + now: i64, +) -> Result { + match state + .spacetime_client() + .get_puzzle_agent_session(session_id.clone(), owner_user_id.clone()) + .await + { + Ok(session) => Ok(session), + Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { + // 中文注释:结果页已经带有当前草稿快照;Maincloud 读取 session 短暂 503 时不应阻断外部生图。 + let fallback_session = build_puzzle_session_snapshot_from_action_payload( + session_id.as_str(), + payload, + normalized_levels_json, + now, + )?; + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %session_id, + owner_user_id = %owner_user_id, + error = %error, + "拼图图片生成读取 session 因 SpacetimeDB 连接不可用而降级使用前端草稿快照" + ); + Ok(fallback_session) + } + Err(error) => Err(map_puzzle_client_error(error)), + } +} + +fn build_puzzle_session_snapshot_from_action_payload( + session_id: &str, + payload: &ExecutePuzzleAgentActionRequest, + normalized_levels_json: Option<&str>, + now: i64, +) -> Result { + let levels_json = normalized_levels_json.ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "spacetimedb", + "message": "SpacetimeDB 暂不可用,且请求缺少拼图关卡快照,无法继续生成图片", + })) + })?; + let levels = parse_puzzle_level_records_from_module_json(levels_json)?; + let first_level = levels.first().cloned().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图草稿缺少可编辑关卡", + })) + })?; + let work_title = payload + .work_title + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(first_level.level_name.as_str()) + .to_string(); + let work_description = payload + .work_description + .as_deref() + .map(str::trim) + .unwrap_or_default() + .to_string(); + let summary = payload + .summary + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(first_level.picture_description.as_str()) + .to_string(); + let theme_tags = payload.theme_tags.clone().unwrap_or_default(); + let anchor_pack = map_puzzle_domain_anchor_pack(module_puzzle::empty_anchor_pack()); + let draft = PuzzleResultDraftRecord { + work_title, + work_description, + level_name: first_level.level_name.clone(), + summary, + theme_tags, + forbidden_directives: Vec::new(), + creator_intent: None, + anchor_pack: anchor_pack.clone(), + candidates: first_level.candidates.clone(), + selected_candidate_id: first_level.selected_candidate_id.clone(), + cover_image_src: first_level.cover_image_src.clone(), + cover_asset_id: first_level.cover_asset_id.clone(), + generation_status: first_level.generation_status.clone(), + levels, + form_draft: None, + }; + + Ok(PuzzleAgentSessionRecord { + session_id: session_id.to_string(), + seed_text: String::new(), + current_turn: 0, + progress_percent: 94, + stage: "ready_to_publish".to_string(), + anchor_pack, + draft: Some(draft), + messages: Vec::new(), + last_assistant_reply: None, + published_profile_id: None, + suggested_actions: Vec::new(), + result_preview: None, + updated_at: format_timestamp_micros(now), + }) +} + +fn map_puzzle_domain_anchor_pack( + anchor_pack: module_puzzle::PuzzleAnchorPack, +) -> PuzzleAnchorPackRecord { + PuzzleAnchorPackRecord { + theme_promise: map_puzzle_domain_anchor_item(anchor_pack.theme_promise), + visual_subject: map_puzzle_domain_anchor_item(anchor_pack.visual_subject), + visual_mood: map_puzzle_domain_anchor_item(anchor_pack.visual_mood), + composition_hooks: map_puzzle_domain_anchor_item(anchor_pack.composition_hooks), + tags_and_forbidden: map_puzzle_domain_anchor_item(anchor_pack.tags_and_forbidden), + } +} + +fn map_puzzle_domain_anchor_item( + anchor: module_puzzle::PuzzleAnchorItem, +) -> PuzzleAnchorItemRecord { + PuzzleAnchorItemRecord { + key: anchor.key, + label: anchor.label, + value: anchor.value, + status: anchor.status.as_str().to_string(), + } +} + fn serialize_puzzle_levels_response( request_context: &RequestContext, levels: &[PuzzleDraftLevelResponse], @@ -2303,18 +2471,21 @@ async fn compile_puzzle_draft_with_initial_cover( owner_user_id: String, prompt_text: Option<&str>, reference_image_src: Option<&str>, + image_model: Option<&str>, now: i64, -) -> Result { +) -> Result { let compiled_session = state .spacetime_client() .compile_puzzle_agent_draft(session_id.clone(), owner_user_id.clone(), now) - .await?; - let draft = compiled_session - .draft - .clone() - .ok_or_else(|| SpacetimeClientError::Runtime("拼图结果页草稿尚未生成".to_string()))?; - let target_level = select_puzzle_level_for_api(&draft, None) - .map_err(|error| SpacetimeClientError::Runtime(error.message().to_string()))?; + .await + .map_err(map_puzzle_compile_error)?; + let draft = compiled_session.draft.clone().ok_or_else(|| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图结果页草稿尚未生成", + })) + })?; + let target_level = select_puzzle_level_for_api(&draft, None)?; let image_prompt = resolve_puzzle_draft_cover_prompt( prompt_text, &target_level.picture_description, @@ -2328,25 +2499,35 @@ async fn compile_puzzle_draft_with_initial_cover( &target_level.level_name, &image_prompt, reference_image_src, + image_model, 1, target_level.candidates.len(), ) - .await - .map_err(SpacetimeClientError::Runtime)?; + .await?; let selected_candidate_id = candidates .iter() .find(|candidate| candidate.selected) .or_else(|| candidates.first()) .map(|candidate| candidate.candidate_id.clone()) - .ok_or_else(|| SpacetimeClientError::Runtime("拼图候选图生成结果为空".to_string()))?; + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": "拼图候选图生成结果为空", + })) + })?; let candidates_json = serde_json::to_string( &candidates .iter() .map(to_puzzle_generated_image_candidate) .collect::>(), ) - .map_err(|error| SpacetimeClientError::Runtime(format!("拼图候选图序列化失败:{error}")))?; - state + .map_err(|error| { + AppError::from_status(StatusCode::BAD_REQUEST).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图候选图序列化失败:{error}"), + })) + })?; + let (saved_session, save_used_fallback) = state .spacetime_client() .save_puzzle_generated_images(PuzzleGeneratedImagesSaveRecordInput { session_id: compiled_session.session_id.clone(), @@ -2356,8 +2537,34 @@ async fn compile_puzzle_draft_with_initial_cover( candidates_json, saved_at_micros: current_utc_micros(), }) - .await?; - state + .await + .map_err(map_puzzle_client_error) + .map(|session| (session, false)) + .or_else(|error| { + if is_spacetimedb_connectivity_app_error(&error) { + // 中文注释:首图已落 OSS 时,Maincloud 短暂不可用先返回本地快照,避免整次 APIMart 生图被判失败。 + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %compiled_session.session_id, + owner_user_id = %owner_user_id, + message = %error.body_text(), + "拼图首图已生成但 SpacetimeDB 草稿回写不可用,降级返回本次生成快照" + ); + let session = apply_generated_puzzle_candidates_to_session_snapshot( + compiled_session.clone(), + target_level.level_id.as_str(), + candidates.clone(), + now, + ); + Ok((session, true)) + } else { + Err(error) + } + })?; + if save_used_fallback { + return Ok(saved_session); + } + match state .spacetime_client() .select_puzzle_cover_image(PuzzleSelectCoverImageRecordInput { session_id, @@ -2367,6 +2574,93 @@ async fn compile_puzzle_draft_with_initial_cover( selected_at_micros: current_utc_micros(), }) .await + { + Ok(session) => Ok(session), + Err(error) if should_skip_asset_operation_billing_for_connectivity(&error) => { + tracing::warn!( + provider = PUZZLE_AGENT_API_BASE_PROVIDER, + session_id = %saved_session.session_id, + error = %error, + "拼图首图选定回写因 SpacetimeDB 连接不可用而降级使用已生成快照" + ); + Ok(saved_session) + } + Err(error) => Err(map_puzzle_client_error(error)), + } +} + +fn apply_generated_puzzle_candidates_to_session_snapshot( + mut session: PuzzleAgentSessionRecord, + target_level_id: &str, + candidates: Vec, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + let Some(draft) = session.draft.as_mut() else { + return session; + }; + let Some(target_index) = draft + .levels + .iter() + .position(|level| level.level_id == target_level_id) + .or_else(|| (!draft.levels.is_empty()).then_some(0)) + else { + return session; + }; + let mut candidates = candidates + .into_iter() + .take(1) + .map(|mut candidate| { + candidate.selected = true; + candidate + }) + .collect::>(); + let Some(selected) = candidates.first().cloned() else { + return session; + }; + let level = &mut draft.levels[target_index]; + level.candidates.clear(); + level.candidates.append(&mut candidates); + level.selected_candidate_id = Some(selected.candidate_id.clone()); + level.cover_image_src = Some(selected.image_src.clone()); + level.cover_asset_id = Some(selected.asset_id.clone()); + level.generation_status = "ready".to_string(); + if target_index == 0 { + sync_puzzle_primary_draft_fields_from_level(draft); + } + session.progress_percent = session.progress_percent.max(94); + session.stage = "ready_to_publish".to_string(); + session.last_assistant_reply = Some("拼图图片已经生成,并已替换当前正式图。".to_string()); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +fn sync_puzzle_primary_draft_fields_from_level(draft: &mut PuzzleResultDraftRecord) { + let Some(primary_level) = draft.levels.first() else { + return; + }; + draft.level_name = primary_level.level_name.clone(); + draft.candidates = primary_level.candidates.clone(); + draft.selected_candidate_id = primary_level.selected_candidate_id.clone(); + draft.cover_image_src = primary_level.cover_image_src.clone(); + draft.cover_asset_id = primary_level.cover_asset_id.clone(); + draft.generation_status = primary_level.generation_status.clone(); +} + +fn replace_puzzle_session_draft_snapshot( + mut session: PuzzleAgentSessionRecord, + draft: PuzzleResultDraftRecord, + updated_at_micros: i64, +) -> PuzzleAgentSessionRecord { + session.draft = Some(draft); + session.updated_at = format_timestamp_micros(updated_at_micros); + session +} + +fn is_spacetimedb_connectivity_app_error(error: &AppError) -> bool { + matches!( + error.status_code(), + StatusCode::SERVICE_UNAVAILABLE | StatusCode::GATEWAY_TIMEOUT + ) } fn ensure_non_empty( @@ -2401,6 +2695,11 @@ fn puzzle_bad_request(request_context: &RequestContext, provider: &str, message: fn map_puzzle_client_error(error: SpacetimeClientError) -> AppError { let status = match &error { + SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE, + SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT, + error if should_skip_asset_operation_billing_for_connectivity(error) => { + StatusCode::SERVICE_UNAVAILABLE + } SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, SpacetimeClientError::Procedure(message) if message.contains("不存在") @@ -2441,18 +2740,34 @@ fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { let message = error.to_string(); - let provider = if message.contains("DashScope") || message.contains("dashscope") { - "dashscope" + let provider = if message.contains("APIMart") + || message.contains("apimart") + || message.contains("APIMART") + { + "apimart" } else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") { "puzzle-assets" } else { "spacetimedb" }; - let status = if matches!(error, SpacetimeClientError::Runtime(_)) + let status = if provider == "apimart" + && (message.contains("APIMART_API_KEY") + || message.contains("APIMART_BASE_URL") + || message.contains("未配置")) + { + StatusCode::SERVICE_UNAVAILABLE + } else if matches!( + error, + SpacetimeClientError::ConnectDropped | SpacetimeClientError::Timeout + ) || should_skip_asset_operation_billing_for_connectivity(&error) + { + StatusCode::SERVICE_UNAVAILABLE + } else if matches!(error, SpacetimeClientError::Runtime(_)) && (message.contains("生成") || message.contains("上游") - || message.contains("DashScope") - || message.contains("dashscope") + || message.contains("APIMart") + || message.contains("apimart") + || message.contains("APIMART") || message.contains("参考图") || message.contains("图片") || message.contains("OSS") @@ -2476,6 +2791,8 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { { StatusCode::BAD_GATEWAY } + SpacetimeClientError::ConnectDropped => StatusCode::SERVICE_UNAVAILABLE, + SpacetimeClientError::Timeout => StatusCode::GATEWAY_TIMEOUT, SpacetimeClientError::Runtime(_) => StatusCode::BAD_REQUEST, _ => StatusCode::BAD_GATEWAY, } @@ -2529,13 +2846,16 @@ fn puzzle_sse_error_event_message(message: String) -> Event { Event::default().event("error").data(payload) } -fn map_puzzle_generation_app_error(error: AppError) -> String { - let body_text = error.body_text(); +fn map_puzzle_generation_endpoint_error(error: AppError) -> AppError { if error.code() == "UPSTREAM_ERROR" { - format!("拼图图片生成失败:{body_text}") - } else { - body_text + let body_text = error.body_text(); + return AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": PUZZLE_AGENT_API_BASE_PROVIDER, + "message": format!("拼图图片生成失败:{body_text}"), + })); } + + error } async fn generate_puzzle_image_candidates( @@ -2545,17 +2865,17 @@ async fn generate_puzzle_image_candidates( level_name: &str, prompt: &str, reference_image_src: Option<&str>, + image_model: Option<&str>, candidate_count: u32, candidate_start_index: usize, -) -> Result, String> { +) -> Result, AppError> { let count = candidate_count.clamp(1, 1); - let settings = - require_puzzle_dashscope_settings(state).map_err(map_puzzle_generation_app_error)?; - let http_client = - build_puzzle_dashscope_http_client(&settings).map_err(map_puzzle_generation_app_error)?; + let resolved_model = resolve_puzzle_image_model(image_model); let actual_prompt = build_puzzle_image_prompt(level_name, prompt); + let http_client = build_puzzle_image_http_client(state, resolved_model)?; tracing::info!( - provider = "dashscope", + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), session_id, level_name, prompt_chars = prompt.chars().count(), @@ -2570,41 +2890,26 @@ async fn generate_puzzle_image_candidates( .map(str::trim) .filter(|value| !value.is_empty()) { - Some(source) => Some( - resolve_puzzle_reference_image_as_data_url(state, &http_client, source) - .await - .map_err(map_puzzle_generation_app_error)?, - ), + Some(source) => { + Some(resolve_puzzle_reference_image_as_data_url(state, &http_client, source).await?) + } None => None, }; - // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。 + // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。 // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 - let generated = match reference_image.as_deref() { - Some(reference_image) => { - create_puzzle_image_to_image_generation( - &http_client, - &settings, - actual_prompt.as_str(), - PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_GENERATED_IMAGE_SIZE, - count, - reference_image, - ) - .await - } - None => { - create_puzzle_text_to_image_generation( - &http_client, - &settings, - actual_prompt.as_str(), - PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_GENERATED_IMAGE_SIZE, - count, - ) - .await - } - } - .map_err(map_puzzle_generation_app_error)?; + let settings = require_puzzle_apimart_settings(state)?; + let generated = create_puzzle_apimart_image_generation( + &http_client, + &settings, + resolved_model, + actual_prompt.as_str(), + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_APIMART_GENERATED_IMAGE_SIZE, + count, + reference_image.as_deref(), + ) + .await + .map_err(map_puzzle_generation_endpoint_error)?; let mut items = Vec::with_capacity(generated.images.len()); for (index, image) in generated.images.into_iter().enumerate() { @@ -2623,14 +2928,14 @@ async fn generate_puzzle_image_candidates( current_utc_micros(), ) .await - .map_err(map_puzzle_generation_app_error)?; + .map_err(map_puzzle_generation_endpoint_error)?; items.push(PuzzleGeneratedImageCandidateResponse { candidate_id, image_src: asset.image_src, asset_id: asset.asset_id, prompt: prompt.to_string(), actual_prompt: Some(actual_prompt.clone()), - source_type: "generated".to_string(), + source_type: resolved_model.candidate_source_type().to_string(), // 单图生成结果总是直接成为当前正式图。 selected: index == 0, }); @@ -2650,10 +2955,149 @@ async fn generate_puzzle_image_candidates( .collect()) } -struct PuzzleDashScopeSettings { +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn puzzle_generated_image_size_is_square_1_1() { + assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024"); + assert_eq!(PUZZLE_APIMART_GENERATED_IMAGE_SIZE, "1:1"); + } + + #[test] + fn puzzle_apimart_request_uses_selected_model_and_reference_images() { + let body = build_puzzle_apimart_image_request_body( + PuzzleImageModel::Gemini31FlashPreview, + "一只猫在雨夜灯牌下回头。", + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_APIMART_GENERATED_IMAGE_SIZE, + 4, + Some("data:image/png;base64,abcd"), + ); + + assert_eq!(body["model"], PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW); + assert_eq!(body["size"], PUZZLE_APIMART_GENERATED_IMAGE_SIZE); + assert_eq!(body["resolution"], PUZZLE_APIMART_GEMINI_RESOLUTION); + assert_eq!(body["n"], 1); + assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd"); + assert!( + body["prompt"] + .as_str() + .unwrap_or_default() + .contains("文字水印") + ); + } + + #[test] + fn puzzle_compile_error_preserves_apimart_unavailable_status() { + let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( + "APIMART_API_KEY 未配置".to_string(), + )); + + let response = error.into_response(); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + } + + #[test] + fn puzzle_image_generation_builds_fallback_session_from_levels_snapshot() { + let levels_json = serde_json::to_string(&vec![json!({ + "level_id": "puzzle-level-1", + "level_name": "雨夜猫街", + "picture_description": "一只猫在雨夜灯牌下回头。", + "candidates": [], + "selected_candidate_id": null, + "cover_image_src": null, + "cover_asset_id": null, + "generation_status": "idle", + })]) + .expect("levels json"); + let payload = ExecutePuzzleAgentActionRequest { + action: "generate_puzzle_images".to_string(), + prompt_text: None, + reference_image_src: None, + image_model: Some(PUZZLE_IMAGE_MODEL_GPT_IMAGE_2.to_string()), + candidate_count: Some(1), + candidate_id: None, + level_id: Some("puzzle-level-1".to_string()), + work_title: Some("暖灯猫街作品".to_string()), + work_description: Some("一套雨夜猫街主题拼图。".to_string()), + picture_description: None, + level_name: None, + summary: Some("当前关卡画面。".to_string()), + theme_tags: Some(vec!["猫咪".to_string(), "雨夜".to_string()]), + levels_json: Some(levels_json.clone()), + }; + + let session = build_puzzle_session_snapshot_from_action_payload( + "puzzle-session-1", + &payload, + Some(levels_json.as_str()), + 1_713_686_401_234_567, + ) + .expect("fallback session"); + + let draft = session.draft.expect("draft"); + assert_eq!(session.stage, "ready_to_publish"); + assert_eq!(draft.work_title, "暖灯猫街作品"); + assert_eq!(draft.theme_tags, vec!["猫咪", "雨夜"]); + assert_eq!(draft.levels[0].level_id, "puzzle-level-1"); + assert_eq!( + draft.levels[0].picture_description, + "一只猫在雨夜灯牌下回头。" + ); + } + + #[test] + fn freeze_boundary_sync_only_matches_freeze_invalid_operation() { + let invalid_operation = + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "操作不合法", + })); + let other_error = AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "spacetimedb", + "message": "光点余额不足", + })); + + assert!(should_sync_puzzle_freeze_boundary(&invalid_operation, true)); + assert!(!should_sync_puzzle_freeze_boundary( + &invalid_operation, + false + )); + assert!(!should_sync_puzzle_freeze_boundary(&other_error, true)); + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PuzzleImageModel { + GptImage2, + Gemini31FlashPreview, +} + +impl PuzzleImageModel { + fn provider_name(self) -> &'static str { + "apimart" + } + + fn request_model_name(self) -> &'static str { + match self { + Self::GptImage2 => PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, + Self::Gemini31FlashPreview => PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW, + } + } + + fn candidate_source_type(self) -> &'static str { + match self { + Self::GptImage2 => "generated:gpt-image-2", + Self::Gemini31FlashPreview => "generated:nanobanana2", + } + } +} + +struct PuzzleApimartSettings { base_url: String, api_key: String, - reference_image_model: String, request_timeout_ms: u64, } @@ -2678,50 +3122,60 @@ struct GeneratedPuzzleAssetResponse { asset_id: String, } -fn require_puzzle_dashscope_settings( - state: &AppState, -) -> Result { - let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); +fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel { + match value.map(str::trim).filter(|value| !value.is_empty()) { + Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => PuzzleImageModel::Gemini31FlashPreview, + _ => PuzzleImageModel::GptImage2, + } +} + +fn require_puzzle_apimart_settings(state: &AppState) -> Result { + let base_url = state.config.apimart_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 未配置", + "provider": "apimart", + "message": "APIMart 图片生成地址未配置", + "reason": "APIMART_BASE_URL 未配置", })), ); } let api_key = state .config - .dashscope_api_key + .apimart_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 未配置", + "provider": "apimart", + "message": "APIMart 图片生成密钥未配置", + "reason": "APIMART_API_KEY 未配置", })) })?; - Ok(PuzzleDashScopeSettings { + Ok(PuzzleApimartSettings { base_url: base_url.to_string(), api_key: api_key.to_string(), - reference_image_model: state.config.dashscope_reference_image_model.clone(), - request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1), + request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1), }) } -fn build_puzzle_dashscope_http_client( - settings: &PuzzleDashScopeSettings, +fn build_puzzle_image_http_client( + state: &AppState, + image_model: PuzzleImageModel, ) -> Result { + let provider = image_model.provider_name(); + let request_timeout_ms = state.config.apimart_image_request_timeout_ms; + reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms)) + .timeout(Duration::from_millis(request_timeout_ms.max(1))) .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "dashscope", - "message": format!("构造拼图 DashScope HTTP 客户端失败:{error}"), + "provider": provider, + "message": format!("构造拼图图片生成 HTTP 客户端失败:{error}"), })) }) } @@ -2741,55 +3195,139 @@ fn to_puzzle_generated_image_candidate( } } -async fn create_puzzle_text_to_image_generation( +async fn create_puzzle_apimart_image_generation( http_client: &reqwest::Client, - settings: &PuzzleDashScopeSettings, + settings: &PuzzleApimartSettings, + image_model: PuzzleImageModel, prompt: &str, negative_prompt: &str, size: &str, candidate_count: u32, + reference_image: Option<&str>, ) -> Result { + let request_body = build_puzzle_apimart_image_request_body( + image_model, + prompt, + negative_prompt, + size, + candidate_count, + reference_image, + ); let response = http_client - .post(format!( - "{}/services/aigc/text2image/image-synthesis", - settings.base_url - )) + .post(format!("{}/images/generations", 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(&build_puzzle_text_to_image_request_body( - prompt, - negative_prompt, - size, - candidate_count, - )) + .json(&request_body) .send() .await .map_err(|error| { - map_puzzle_dashscope_request_error(format!("创建拼图图片生成任务失败:{error}")) + map_puzzle_apimart_request_error(format!("创建拼图 APIMart 图片生成任务失败:{error}")) })?; let status = response.status(); let response_text = response.text().await.map_err(|error| { - map_puzzle_dashscope_request_error(format!("读取拼图图片生成响应失败:{error}")) + map_puzzle_apimart_request_error(format!("读取拼图 APIMart 图片生成响应失败:{error}")) })?; if !status.is_success() { - return Err(map_puzzle_dashscope_upstream_error( + return Err(map_puzzle_apimart_upstream_error( status, response_text.as_str(), - "创建拼图图片生成任务失败", + "创建拼图 APIMart 图片生成任务失败", )); } - let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图图片生成响应失败")?; + + let payload = + parse_puzzle_json_payload(response_text.as_str(), "解析拼图 APIMart 图片生成响应失败")?; + let image_urls = extract_puzzle_image_urls(&payload); + if !image_urls.is_empty() { + return download_puzzle_images_from_urls( + http_client, + format!("apimart-{}", current_utc_micros()), + image_urls, + candidate_count, + ) + .await; + } + 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", + "provider": "apimart", + "message": "拼图 APIMart 图片生成未返回 task_id 或图片地址", })) })?; + + wait_puzzle_apimart_generated_images( + http_client, + settings, + task_id.as_str(), + candidate_count, + "拼图 APIMart 图片生成任务失败", + ) + .await +} + +fn build_puzzle_apimart_image_request_body( + image_model: PuzzleImageModel, + prompt: &str, + negative_prompt: &str, + size: &str, + candidate_count: u32, + reference_image: Option<&str>, +) -> Value { + let mut body = Map::from_iter([ + ( + "model".to_string(), + Value::String(image_model.request_model_name().to_string()), + ), + ( + "prompt".to_string(), + Value::String(build_puzzle_apimart_prompt(prompt, negative_prompt)), + ), + ("n".to_string(), json!(candidate_count.clamp(1, 1))), + ("size".to_string(), Value::String(size.to_string())), + ]); + body.insert( + "resolution".to_string(), + Value::String( + match image_model { + PuzzleImageModel::Gemini31FlashPreview => PUZZLE_APIMART_GEMINI_RESOLUTION, + _ => "1k", + } + .to_string(), + ), + ); + + if let Some(reference_image) = reference_image + .map(str::trim) + .filter(|value| !value.is_empty()) + { + body.insert("image_urls".to_string(), json!([reference_image])); + } + + Value::Object(body) +} + +fn build_puzzle_apimart_prompt(prompt: &str, negative_prompt: &str) -> String { + let prompt = prompt.trim(); + let negative_prompt = negative_prompt.trim(); + if negative_prompt.is_empty() { + return prompt.to_string(); + } + + format!("{prompt}\n避免:{negative_prompt}") +} + +async fn wait_puzzle_apimart_generated_images( + http_client: &reqwest::Client, + settings: &PuzzleApimartSettings, + task_id: &str, + candidate_count: u32, + failure_message: &str, +) -> Result { let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); + sleep(Duration::from_secs(10)).await; while Instant::now() < deadline { let poll_response = http_client @@ -2801,87 +3339,84 @@ async fn create_puzzle_text_to_image_generation( .send() .await .map_err(|error| { - map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}")) + map_puzzle_apimart_request_error(format!( + "查询拼图 APIMart 图片生成任务失败:{error}" + )) })?; let poll_status = poll_response.status(); let poll_text = poll_response.text().await.map_err(|error| { - map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}")) + map_puzzle_apimart_request_error(format!( + "读取拼图 APIMart 图片生成任务响应失败:{error}" + )) })?; if !poll_status.is_success() { - return Err(map_puzzle_dashscope_upstream_error( + return Err(map_puzzle_apimart_upstream_error( poll_status, poll_text.as_str(), - "查询拼图图片生成任务失败", + "查询拼图 APIMart 图片生成任务失败", )); } + let poll_payload = - parse_puzzle_json_payload(poll_text.as_str(), "解析拼图图片生成任务响应失败")?; - let task_status = find_first_puzzle_string_by_key(&poll_payload, "task_status") + parse_puzzle_json_payload(poll_text.as_str(), "解析拼图 APIMart 图片生成任务响应失败")?; + let task_status = find_first_puzzle_string_by_key(&poll_payload, "status") .unwrap_or_default() .trim() - .to_string(); - if task_status == "SUCCEEDED" { + .to_ascii_lowercase(); + if matches!(task_status.as_str(), "completed" | "succeeded" | "success") { 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": "拼图图片生成成功但未返回图片地址", + "provider": "apimart", + "message": "拼图 APIMart 图片生成成功但未返回图片地址", })), ); } - let mut images = Vec::with_capacity(image_urls.len()); - for image_url in image_urls - .into_iter() - .take(candidate_count.clamp(1, 1) as usize) - { - images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); - } - return Ok(PuzzleGeneratedImages { task_id, images }); + + return download_puzzle_images_from_urls( + http_client, + task_id.to_string(), + image_urls, + candidate_count, + ) + .await; } - if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { - return Err(map_puzzle_dashscope_upstream_error( + if matches!( + task_status.as_str(), + "failed" | "error" | "canceled" | "cancelled" + ) { + return Err(map_puzzle_apimart_upstream_error( poll_status, poll_text.as_str(), - "拼图图片生成任务失败", + failure_message, )); } - sleep(Duration::from_secs(2)).await; + sleep(Duration::from_secs(3)).await; } Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", - "message": "拼图图片生成超时或未返回图片地址", + "provider": "apimart", + "message": "拼图 APIMart 图片生成超时或未返回图片地址", })), ) } -fn build_puzzle_text_to_image_request_body( - prompt: &str, - negative_prompt: &str, - size: &str, +async fn download_puzzle_images_from_urls( + http_client: &reqwest::Client, + task_id: String, + image_urls: Vec, candidate_count: u32, -) -> Value { - let parameters = Map::from_iter([ - ("n".to_string(), json!(candidate_count.clamp(1, 1))), - ("size".to_string(), Value::String(size.to_string())), - ("prompt_extend".to_string(), Value::Bool(true)), - ("watermark".to_string(), Value::Bool(false)), - ]); - let mut input = Map::from_iter([("prompt".to_string(), Value::String(prompt.to_string()))]); - if !negative_prompt.trim().is_empty() { - input.insert( - "negative_prompt".to_string(), - Value::String(negative_prompt.trim().to_string()), - ); +) -> Result { + let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize); + for image_url in image_urls + .into_iter() + .take(candidate_count.clamp(1, 1) as usize) + { + images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); } - - json!({ - "model": PUZZLE_TEXT_TO_IMAGE_MODEL, - "input": input, - "parameters": parameters, - }) + Ok(PuzzleGeneratedImages { task_id, images }) } async fn resolve_puzzle_reference_image_as_data_url( @@ -2945,9 +3480,7 @@ async fn resolve_puzzle_reference_image_as_data_url( .get(signed.signed_url) .send() .await - .map_err(|error| { - map_puzzle_dashscope_request_error(format!("读取拼图参考图失败:{error}")) - })?; + .map_err(|error| map_puzzle_image_request_error(format!("读取拼图参考图失败:{error}")))?; let status = response.status(); let content_type = response .headers() @@ -2956,7 +3489,7 @@ async fn resolve_puzzle_reference_image_as_data_url( .unwrap_or("image/png") .to_string(); let body = response.bytes().await.map_err(|error| { - map_puzzle_dashscope_request_error(format!("读取拼图参考图内容失败:{error}")) + map_puzzle_image_request_error(format!("读取拼图参考图内容失败:{error}")) })?; if !status.is_success() { return Err( @@ -2984,185 +3517,12 @@ async fn resolve_puzzle_reference_image_as_data_url( )) } -async fn create_puzzle_image_to_image_generation( - http_client: &reqwest::Client, - settings: &PuzzleDashScopeSettings, - prompt: &str, - negative_prompt: &str, - size: &str, - candidate_count: u32, - reference_image: &str, -) -> Result { - let mut content = vec![json!({ "image": reference_image })]; - content.push(json!({ "text": prompt })); - let mut parameters = Map::from_iter([ - ("n".to_string(), json!(candidate_count.clamp(1, 1))), - ("size".to_string(), Value::String(size.to_string())), - ("prompt_extend".to_string(), Value::Bool(true)), - ("watermark".to_string(), Value::Bool(false)), - ]); - if !negative_prompt.trim().is_empty() { - parameters.insert( - "negative_prompt".to_string(), - Value::String(negative_prompt.trim().to_string()), - ); - } - - let response = http_client - .post(format!( - "{}/services/aigc/multimodal-generation/generation", - settings.base_url - )) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .json(&json!({ - "model": settings.reference_image_model.as_str(), - "input": { - "messages": [ - { - "role": "user", - "content": content, - } - ], - }, - "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( - status, - response_text.as_str(), - "创建拼图参考图生成任务失败", - )); - } - let payload = parse_puzzle_json_payload(response_text.as_str(), "解析拼图参考图生成响应失败")?; - let image_urls = extract_puzzle_image_urls(&payload); - if image_urls.is_empty() { - 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 或图片地址", - })) - })?; - return wait_puzzle_generated_images( - http_client, - settings, - task_id.as_str(), - candidate_count, - "拼图参考图生成任务失败", - ) - .await; - } - let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize); - for image_url in image_urls - .into_iter() - .take(candidate_count.clamp(1, 1) as usize) - { - images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); - } - Ok(PuzzleGeneratedImages { - task_id: format!("puzzle-ref-{}", current_utc_micros()), - images, - }) -} - -async fn wait_puzzle_generated_images( - http_client: &reqwest::Client, - settings: &PuzzleDashScopeSettings, - task_id: &str, - candidate_count: u32, - failure_message: &str, -) -> Result { - 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_status, - 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, 1) as usize) - { - images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); - } - return Ok(PuzzleGeneratedImages { - task_id: task_id.to_string(), - images, - }); - } - if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { - return Err(map_puzzle_dashscope_upstream_error( - poll_status, - poll_text.as_str(), - failure_message, - )); - } - 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}")) + map_puzzle_image_request_error(format!("下载拼图正式图片失败:{error}")) })?; let status = response.status(); let content_type = response @@ -3172,12 +3532,12 @@ async fn download_puzzle_remote_image( .unwrap_or("image/jpeg") .to_string(); let bytes = response.bytes().await.map_err(|error| { - map_puzzle_dashscope_request_error(format!("读取拼图正式图片内容失败:{error}")) + map_puzzle_image_request_error(format!("读取拼图正式图片内容失败:{error}")) })?; if !status.is_success() { return Err( AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", + "provider": "puzzle-image", "message": "下载拼图正式图片失败", "status": status.as_u16(), })), @@ -3258,26 +3618,44 @@ async fn persist_puzzle_generated_asset( ) .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)?; + .await; + match asset_object { + Ok(asset_object) => { + if let Err(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 + { + handle_puzzle_asset_spacetime_index_error( + error, + owner_user_id, + session_id, + candidate_id, + "绑定拼图资产对象到实体", + )?; + } + } + Err(error) => handle_puzzle_asset_spacetime_index_error( + error, + owner_user_id, + session_id, + candidate_id, + "确认拼图资产对象", + )?, + } Ok(GeneratedPuzzleAssetResponse { image_src: put_result.legacy_public_path, @@ -3285,6 +3663,30 @@ async fn persist_puzzle_generated_asset( }) } +fn handle_puzzle_asset_spacetime_index_error( + error: SpacetimeClientError, + owner_user_id: &str, + session_id: &str, + candidate_id: &str, + stage: &str, +) -> Result<(), AppError> { + if should_skip_asset_operation_billing_for_connectivity(&error) { + // 中文注释:OSS 已经持有真实图片,资产索引的 SpacetimeDB 短暂失败只影响历史检索,不应阻断本次生图展示。 + tracing::warn!( + provider = "spacetimedb", + owner_user_id, + session_id, + candidate_id, + stage, + error = %error, + "拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过" + ); + return Ok(()); + } + + Err(map_puzzle_asset_spacetime_error(error)) +} + fn build_puzzle_asset_metadata( owner_user_id: &str, session_id: &str, @@ -3302,7 +3704,7 @@ fn build_puzzle_asset_metadata( 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", + "provider": "apimart", "message": format!("{fallback_message}:{error}"), })) }) @@ -3380,10 +3782,8 @@ fn collect_puzzle_strings_by_key(payload: &Value, target_key: &str, results: &mu } Value::Object(object) => { for (key, value) in object { - if key == target_key - && let Some(text) = value.as_str() - { - results.push(text.to_string()); + if key == target_key { + collect_puzzle_string_values(value, results); } collect_puzzle_strings_by_key(value, target_key, results); } @@ -3392,6 +3792,18 @@ fn collect_puzzle_strings_by_key(payload: &Value, target_key: &str, results: &mu } } +fn collect_puzzle_string_values(payload: &Value, results: &mut Vec) { + match payload { + Value::String(text) => results.push(text.to_string()), + Value::Array(items) => { + for item in items { + collect_puzzle_string_values(item, results); + } + } + _ => {} + } +} + fn normalize_puzzle_downloaded_image_mime_type(content_type: &str) -> String { let mime_type = content_type .split(';') @@ -3415,14 +3827,21 @@ fn puzzle_mime_to_extension(mime_type: &str) -> &str { } } -fn map_puzzle_dashscope_request_error(message: String) -> AppError { +fn map_puzzle_image_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", + "provider": "puzzle-image", "message": message, })) } -fn map_puzzle_dashscope_upstream_error( +fn map_puzzle_apimart_request_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": message, + })) +} + +fn map_puzzle_apimart_upstream_error( upstream_status: reqwest::StatusCode, raw_text: &str, fallback_message: &str, @@ -3430,15 +3849,15 @@ fn map_puzzle_dashscope_upstream_error( let message = parse_puzzle_api_error_message(raw_text, fallback_message); let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800); tracing::warn!( - provider = "dashscope", + provider = "apimart", upstream_status = upstream_status.as_u16(), message = %message, raw_excerpt = %raw_excerpt, - "拼图 DashScope 上游请求失败" + "拼图 APIMart 上游请求失败" ); AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", + "provider": "apimart", "upstreamStatus": upstream_status.as_u16(), "message": message, "rawExcerpt": raw_excerpt, diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index 46aa2d46..60f0e9fe 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -1938,6 +1938,16 @@ fn format_rfc3339(value: OffsetDateTime) -> Result { format_shared_rfc3339(value) } +#[allow(dead_code)] +fn current_auth_user_created_at() -> String { + format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| default_auth_user_created_at()) +} + +#[allow(dead_code)] +fn default_auth_user_created_at() -> String { + "1970-01-01T00:00:00Z".to_string() +} + fn parse_phone_code_time(value: &str, field_label: &str) -> Result { parse_rfc3339(value) .map_err(|error| PhoneAuthError::Store(format!("短信验证码{field_label}解析失败:{error}"))) diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index d46d2e29..89dfab68 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -13,6 +13,8 @@ pub struct CreatePuzzleAgentSessionRequest { pub picture_description: Option, #[serde(default)] pub reference_image_src: Option, + #[serde(default)] + pub image_model: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -33,6 +35,8 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub reference_image_src: Option, #[serde(default)] + pub image_model: Option, + #[serde(default)] pub candidate_count: Option, #[serde(default)] pub candidate_id: Option, diff --git a/src/components/common/PublishShareModal.test.tsx b/src/components/common/PublishShareModal.test.tsx new file mode 100644 index 00000000..2190c59f --- /dev/null +++ b/src/components/common/PublishShareModal.test.tsx @@ -0,0 +1,65 @@ +/* @vitest-environment jsdom */ + +import { + fireEvent, + render, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import * as clipboardService from '../../services/clipboard'; +import { PublishShareModal } from './PublishShareModal'; +import { + buildPublishShareText, + type PublishShareModalPayload, +} from './publishShareModalModel'; + +vi.mock('../../services/clipboard', () => ({ + copyTextToClipboard: vi.fn(), +})); + +const payload: PublishShareModalPayload = { + title: '暖灯猫街', + publicWorkCode: 'PZ-00000001', + stage: 'puzzle-gallery-detail', +}; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('PublishShareModal', () => { + test('builds the publish share text with title, code and public url', () => { + const text = buildPublishShareText(payload); + + expect(text).toContain('邀请你来玩《暖灯猫街》'); + expect(text).toContain('作品号:PZ-00000001'); + expect(text).toContain('/gallery/puzzle/detail?work=PZ-00000001'); + }); + + test('renders share text and channel icons, then copies from main button', async () => { + vi.mocked(clipboardService.copyTextToClipboard).mockResolvedValue(true); + + render( + {}} />, + ); + + const dialog = screen.getByRole('dialog', { name: '分享给朋友' }); + expect(within(dialog).getByText(/邀请你来玩《暖灯猫街》/u)).toBeTruthy(); + expect(within(dialog).getByRole('button', { name: '分享' })).toBeTruthy(); + expect(within(dialog).getByRole('button', { name: '分享到微信' })).toBeTruthy(); + expect(within(dialog).getByRole('button', { name: '分享到QQ' })).toBeTruthy(); + expect(within(dialog).getByRole('button', { name: '分享到抖音' })).toBeTruthy(); + + fireEvent.click(within(dialog).getByRole('button', { name: '分享' })); + + expect(clipboardService.copyTextToClipboard).toHaveBeenCalledWith( + expect.stringContaining('作品号:PZ-00000001'), + ); + await waitFor(() => { + expect(within(dialog).getByRole('button', { name: '已复制' })).toBeTruthy(); + }); + }); +}); diff --git a/src/components/common/PublishShareModal.tsx b/src/components/common/PublishShareModal.tsx new file mode 100644 index 00000000..d2a1caec --- /dev/null +++ b/src/components/common/PublishShareModal.tsx @@ -0,0 +1,145 @@ +import { Check, Copy, MessageCircle, Music2 } from 'lucide-react'; +import { useEffect, useMemo, useRef, useState } from 'react'; + +import { copyTextToClipboard } from '../../services/clipboard'; +import { + buildPublishShareText, + type PublishShareModalPayload, +} from './publishShareModalModel'; +import { UnifiedModal } from './UnifiedModal'; + +type PublishShareModalProps = { + open: boolean; + payload: PublishShareModalPayload | null; + onClose: () => void; +}; + +const SHARE_CHANNELS = [ + { + id: 'wechat', + label: '微信', + icon: MessageCircle, + className: 'bg-emerald-500 text-white', + }, + { + id: 'qq', + label: 'QQ', + icon: MessageCircle, + className: 'bg-sky-500 text-white', + }, + { + id: 'douyin', + label: '抖音', + icon: Music2, + className: 'bg-slate-950 text-white', + }, +] as const; + +/** + * 发布完成后的分享弹窗。 + * 目前各渠道先统一复制分享文本,后续如接入微信/QQ/抖音 SDK,可以只替换这里的渠道点击逻辑。 + */ +export function PublishShareModal({ + open, + payload, + onClose, +}: PublishShareModalProps) { + const [copyState, setCopyState] = useState<'idle' | 'copied' | 'failed'>( + 'idle', + ); + const resetTimerRef = useRef(null); + const shareText = useMemo( + () => (payload ? buildPublishShareText(payload) : ''), + [payload], + ); + + useEffect( + () => () => { + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + }, + [], + ); + + useEffect(() => { + setCopyState('idle'); + }, [payload?.publicWorkCode]); + + const copyShareText = () => { + if (!shareText) { + return; + } + + void copyTextToClipboard(shareText).then((copied) => { + setCopyState(copied ? 'copied' : 'failed'); + if (resetTimerRef.current !== null) { + window.clearTimeout(resetTimerRef.current); + } + resetTimerRef.current = window.setTimeout(() => { + resetTimerRef.current = null; + setCopyState('idle'); + }, 1400); + }); + }; + + return ( + + {SHARE_CHANNELS.map((channel) => { + const Icon = channel.icon; + + return ( + + ); + })} + + } + > +
+
+ {shareText} +
+
+ +
+ ); +} diff --git a/src/components/common/publishShareModalModel.ts b/src/components/common/publishShareModalModel.ts new file mode 100644 index 00000000..3c360d36 --- /dev/null +++ b/src/components/common/publishShareModalModel.ts @@ -0,0 +1,30 @@ +import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; +import type { SelectionStage } from '../platform-entry/platformEntryTypes'; + +export type PublishShareModalPayload = { + title: string; + publicWorkCode: string; + stage: SelectionStage; +}; + +function buildShareUrl(payload: PublishShareModalPayload) { + const sharePath = buildPublicWorkStagePath( + payload.stage, + payload.publicWorkCode, + ); + + return typeof window === 'undefined' + ? sharePath + : new URL(sharePath, window.location.origin).href; +} + +export function buildPublishShareText(payload: PublishShareModalPayload) { + const publicWorkCode = payload.publicWorkCode.trim(); + const title = payload.title.trim() || '我的作品'; + + return `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${buildShareUrl({ + ...payload, + publicWorkCode, + title, + })}`; +} diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 9d15155f..54b7e11d 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -108,11 +108,10 @@ test('creation hub reflects updated draft title summary and counts after rerende const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u }); const match3dButton = screen.getByRole('button', { name: /抓大鹅/u }); expect( - puzzleButton.compareDocumentPosition(rpgButton) & + rpgButton.compareDocumentPosition(puzzleButton) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); - expect((rpgButton as HTMLButtonElement).disabled).toBe(true); - expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0); + expect((rpgButton as HTMLButtonElement).disabled).toBe(false); expect((match3dButton as HTMLButtonElement).disabled).toBe(false); expect( within(match3dButton).getAllByText('经典消除玩法').length, @@ -320,6 +319,55 @@ test('creation hub shows delete action for persisted rpg drafts', () => { expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); }); +test('creation hub published work delete action is available beside share without opening card', async () => { + const user = userEvent.setup(); + const onDeletePuzzle = vi.fn(); + const onOpenPuzzleDetail = vi.fn(); + + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={onOpenPuzzleDetail} + onDeletePuzzle={onDeletePuzzle} + />, + ); + + expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '删除' })); + + expect(onDeletePuzzle).toHaveBeenCalledWith( + expect.objectContaining({ profileId: 'puzzle-profile-delete' }), + ); + expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); +}); + test('creation hub opens persisted rpg drafts by card click', async () => { const user = userEvent.setup(); const openedItems: CustomWorldWorkSummary[] = []; diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index a8f4f8af..932285eb 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -43,7 +43,6 @@ test('creation hub draft card renders compiled work summary fields', () => { expect(html).toContain('玩家是失职返乡的守灯人'); expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); expect(html).toContain('角色扮演'); - expect(html).toContain('敬请期待'); expect(html).toContain('拼图'); expect(html).toContain('创意礼物,生活分享'); expect(html).not.toContain('大鱼吃小鱼'); diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx index 6add7234..ee5c3df7 100644 --- a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx +++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx @@ -1,5 +1,6 @@ import { ArrowRight } from 'lucide-react'; +import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig'; import { getVisiblePlatformCreationTypes, type PlatformCreationTypeId, @@ -27,13 +28,15 @@ export function CustomWorldCreationStartCard({
- 新建作品 + {NEW_WORK_ENTRY_CONFIG.startCard.title}
- 直接选择游戏创作模板,立刻进入对应的共创工作台。 + {NEW_WORK_ENTRY_CONFIG.startCard.description}
- {busy ? '正在开启' : '选择模板'} + {busy + ? NEW_WORK_ENTRY_CONFIG.startCard.busyBadge + : NEW_WORK_ENTRY_CONFIG.startCard.idleBadge}
diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index fd992a1b..9ab94276 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -268,68 +268,70 @@ export function CustomWorldWorkCard({
- {!isPublished && onDelete ? ( - - ) : null} - {isPublished ? ( - - ) : null} +
+ {onDelete ? ( + + ) : null} + {isPublished ? ( + + ) : null} +
diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index 95ca9844..a9bf5e86 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -1,5 +1,6 @@ import { ArrowRight } from 'lucide-react'; +import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig'; import { UnifiedModal } from '../common/UnifiedModal'; import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes'; @@ -86,8 +87,8 @@ export function PlatformEntryCreationTypeModal({ return ( void; +}; + async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) { return requestRpgRuntimeJson< ProfileSaveArchiveResumeResponse @@ -348,7 +351,10 @@ function mapPublicWorkDetailToMatch3DWork( workId: entry.workId, profileId: entry.profileId, ownerUserId: entry.ownerUserId, - sourceSessionId: null, + sourceSessionId: + 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' + ? entry.sourceSessionId + : null, gameName: entry.worldName, themeText: entry.themeTags[0] ?? '经典消除', summary: entry.summaryText, @@ -406,7 +412,10 @@ function mapPublicWorkDetailToPuzzleWork( workId: entry.workId, profileId: entry.profileId, ownerUserId: entry.ownerUserId, - sourceSessionId: null, + sourceSessionId: + 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' + ? entry.sourceSessionId + : null, authorDisplayName: entry.authorDisplayName, levelName: entry.worldName, summary: entry.summaryText, @@ -658,6 +667,7 @@ function buildPuzzleCompileActionFromFormPayload( ...(workDescription ? { workDescription } : {}), ...(pictureDescription ? { pictureDescription } : {}), referenceImageSrc: payload?.referenceImageSrc || null, + imageModel: payload?.imageModel ?? null, candidateCount: 1, }; } @@ -690,6 +700,7 @@ function buildPuzzleFormPayloadFromSession( workDescription, pictureDescription, referenceImageSrc: null, + imageModel: null, }; } @@ -717,6 +728,10 @@ function buildPuzzleFormPayloadFromAction( payload.action === 'compile_puzzle_draft' ? (payload.referenceImageSrc ?? null) : null, + imageModel: + payload.action === 'compile_puzzle_draft' + ? (payload.imageModel ?? null) + : null, }; } @@ -944,8 +959,9 @@ export function PlatformEntryFlowShellImpl({ const [match3dProfile, setMatch3DProfile] = useState(null); const [match3dRun, setMatch3DRun] = useState(null); - const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] = - useState<'match3d-result' | 'work-detail'>('match3d-result'); + const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] = useState< + 'match3d-result' | 'work-detail' + >('match3d-result'); const [isMatch3DLoadingLibrary, setIsMatch3DLoadingLibrary] = useState(false); const [bigFishRun, setBigFishRun] = useState(null); @@ -999,8 +1015,14 @@ export function PlatformEntryFlowShellImpl({ const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< string | null >(null); - const [claimingPuzzlePointIncentiveProfileId, setClaimingPuzzlePointIncentiveProfileId] = - useState(null); + const [pendingDeleteCreationWork, setPendingDeleteCreationWork] = + useState(null); + const [ + claimingPuzzlePointIncentiveProfileId, + setClaimingPuzzlePointIncentiveProfileId, + ] = useState(null); + const [publishSharePayload, setPublishSharePayload] = + useState(null); const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish'); const [profilePlayStats, setProfilePlayStats] = useState(null); @@ -1276,6 +1298,50 @@ export function PlatformEntryFlowShellImpl({ () => agentResultPreview?.qualityFindings ?? [], [agentResultPreview], ); + + const openPublishShareModal = useCallback( + (payload: PublishShareModalPayload) => { + const publicWorkCode = payload.publicWorkCode.trim(); + if (!publicWorkCode) { + return; + } + + setPublishSharePayload({ + ...payload, + publicWorkCode, + title: payload.title.trim() || '我的作品', + }); + }, + [], + ); + + const openRpgPublishShareModal = useCallback( + async (profile: CustomWorldProfile | null | undefined) => { + const profileId = profile?.id?.trim(); + if (!profileId) { + return; + } + const profileName = profile?.name?.trim() || '我的作品'; + + const galleryEntries = await platformBootstrap + .refreshPublishedGallery() + .catch(() => [] as CustomWorldGalleryCard[]); + const galleryEntry = galleryEntries.find( + (entry) => entry.profileId === profileId, + ); + const publicWorkCode = galleryEntry?.publicWorkCode?.trim(); + if (!publicWorkCode) { + return; + } + + openPublishShareModal({ + title: galleryEntry?.worldName || profileName, + publicWorkCode, + stage: 'work-detail', + }); + }, + [openPublishShareModal, platformBootstrap], + ); const agentResultPreviewSourceLabel = useMemo(() => { if (!agentResultPreview?.source) { return null; @@ -1301,7 +1367,11 @@ export function PlatformEntryFlowShellImpl({ ); return mergePlatformPublicGalleryEntries( platformBootstrap.publishedGalleryEntries, - [...bigFishPublicEntries, ...match3dPublicEntries, ...puzzlePublicEntries], + [ + ...bigFishPublicEntries, + ...match3dPublicEntries, + ...puzzlePublicEntries, + ], ).slice(0, 6); }, [ isBigFishCreationVisible, @@ -1340,6 +1410,13 @@ export function PlatformEntryFlowShellImpl({ const resultViewError = autosaveCoordinator.customWorldAutoSaveError ?? sessionController.customWorldError; + const isSelectedPublicWorkOwned = Boolean( + authUi?.user?.id && + selectedPublicWorkDetail?.ownerUserId === authUi.user.id, + ); + const selectedPublicWorkActionMode = isSelectedPublicWorkOwned + ? 'edit' + : 'remix'; useEffect(() => { if ( @@ -1367,6 +1444,37 @@ export function PlatformEntryFlowShellImpl({ [authUi], ); + const requestDeleteCreationWork = useCallback( + (confirmation: DeleteCreationWorkConfirmation) => { + if (deletingCreationWorkId) { + return; + } + + runProtectedAction(() => { + setPendingDeleteCreationWork(confirmation); + }); + }, + [deletingCreationWorkId, runProtectedAction], + ); + + const closeDeleteCreationWorkConfirmation = useCallback(() => { + if (deletingCreationWorkId) { + return; + } + + setPendingDeleteCreationWork(null); + }, [deletingCreationWorkId]); + + const confirmDeleteCreationWork = useCallback(() => { + const confirmation = pendingDeleteCreationWork; + if (!confirmation || deletingCreationWorkId) { + return; + } + + setPendingDeleteCreationWork(null); + confirmation.run(); + }, [deletingCreationWorkId, pendingDeleteCreationWork]); + const prepareCreationLaunch = useCallback(() => { if (sessionController.isCreatingAgentSession) { return false; @@ -1426,6 +1534,11 @@ export function PlatformEntryFlowShellImpl({ if (payload.action === 'big_fish_publish_game') { void refreshBigFishShelf(); void refreshBigFishGallery(); + openPublishShareModal({ + title: response.session.draft?.title ?? '大鱼吃小鱼', + publicWorkCode: buildBigFishPublicWorkCode(response.session.sessionId), + stage: 'big-fish-runtime', + }); } if (payload.action !== 'big_fish_compile_draft') { return; @@ -1603,6 +1716,11 @@ export function PlatformEntryFlowShellImpl({ buildPuzzlePublicWorkCode(galleryDetail.item.profileId), ), ); + openPublishShareModal({ + title: galleryDetail.item.workTitle || galleryDetail.item.levelName, + publicWorkCode: buildPuzzlePublicWorkCode(galleryDetail.item.profileId), + stage: 'puzzle-gallery-detail', + }); } }, beforeExecuteAction: ({ payload }) => { @@ -1737,6 +1855,7 @@ export function PlatformEntryFlowShellImpl({ workTitle: payload.workTitle ?? payload.seedText ?? '', workDescription: payload.workDescription ?? '', pictureDescription: payload.pictureDescription ?? '', + imageModel: payload.imageModel ?? null, }); setPuzzleOperation(response.operation); puzzleFlow.setSession(response.session); @@ -1797,6 +1916,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); setDeletingCreationWorkId(null); setClaimingPuzzlePointIncentiveProfileId(null); + setPublishSharePayload(null); setProfilePlayStats(null); setProfilePlayStatsError(null); setIsProfilePlayStatsOpen(false); @@ -1834,11 +1954,7 @@ export function PlatformEntryFlowShellImpl({ const handleCreationHubCreateType = useCallback( (type: PlatformCreationTypeId) => { - if ( - type === 'rpg' || - type === 'airp' || - type === 'visual-novel' - ) { + if (type === 'airp' || type === 'visual-novel') { return; } @@ -1846,6 +1962,13 @@ export function PlatformEntryFlowShellImpl({ return; } + if (type === 'rpg') { + runProtectedAction(() => { + void sessionController.openRpgAgentWorkspace(); + }); + return; + } + if (type === 'big-fish') { runProtectedAction(() => { void openBigFishAgentWorkspace(); @@ -1872,6 +1995,7 @@ export function PlatformEntryFlowShellImpl({ openPuzzleAgentWorkspace, prepareCreationLaunch, runProtectedAction, + sessionController, ], ); @@ -2057,17 +2181,12 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); try { - const item = - detailItem ?? (await getPuzzleGalleryDetail(profileId)).item; - const { run } = await startPuzzleRun({ - profileId: item.profileId, - levelId: levelId ?? null, - }); + const item = detailItem ?? (await getPuzzleGalleryDetail(profileId)).item; + const run = startLocalPuzzleRun(item, levelId ?? null); setSelectedPuzzleDetail(item); setPuzzleRun(run); setPuzzleRuntimeReturnStage(returnStage); setSelectionStage('puzzle-runtime'); - void platformBootstrap.refreshSaveArchives(); pushAppHistoryPath( buildPublicWorkStagePath( 'puzzle-runtime', @@ -2086,7 +2205,6 @@ export function PlatformEntryFlowShellImpl({ }, [ isPuzzleBusy, - platformBootstrap, resolvePuzzleErrorMessage, setIsPuzzleBusy, setPuzzleError, @@ -2215,7 +2333,7 @@ export function PlatformEntryFlowShellImpl({ coverAssetId: draft.coverAssetId, levels: draft.levels ?? [], }); - const { run } = await startPuzzleRun({ profileId: item.profileId }); + const run = startLocalPuzzleRun(item); setSelectedPuzzleDetail(item); setPuzzleRun(run); setPuzzleRuntimeReturnStage('puzzle-result'); @@ -2293,10 +2411,7 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleBusy(true); setPuzzleError(null); try { - const { run } = isLocalPuzzleRun(puzzleRun) - ? { run: swapLocalPuzzlePieces(puzzleRun, payload) } - : await swapPuzzlePieces(puzzleRun.runId, payload); - setPuzzleRun(run); + setPuzzleRun(swapLocalPuzzlePieces(puzzleRun, payload)); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '交换拼图块失败。')); } finally { @@ -2315,10 +2430,7 @@ export function PlatformEntryFlowShellImpl({ setIsPuzzleBusy(true); setPuzzleError(null); try { - const { run } = isLocalPuzzleRun(puzzleRun) - ? { run: dragLocalPuzzlePiece(puzzleRun, payload) } - : await dragPuzzlePieceOrGroup(puzzleRun.runId, payload); - setPuzzleRun(run); + setPuzzleRun(dragLocalPuzzlePiece(puzzleRun, payload)); } catch (error) { setPuzzleError(resolvePuzzleErrorMessage(error, '拖动拼图块失败。')); } finally { @@ -2337,7 +2449,7 @@ export function PlatformEntryFlowShellImpl({ } const timerId = window.setInterval(() => { - // 中文注释:正式 run 的棋盘交互也在前端即时裁决,倒计时展示同样走本地时钟;超时落库仍由 onTimeExpired 拉取后端快照完成。 + // 中文注释:拼图运行态的棋盘交互和倒计时展示都在前端即时裁决,超时后只刷新本地失败态。 setPuzzleRun((currentRun) => currentRun ? refreshLocalPuzzleTimer(currentRun) : currentRun, ); @@ -2352,30 +2464,11 @@ export function PlatformEntryFlowShellImpl({ return; } - if (isLocalPuzzleRun(puzzleRun)) { - setPuzzleRun((currentRun) => - currentRun ? setLocalPuzzlePaused(currentRun, paused) : currentRun, - ); - return; - } - - try { - const { run } = await updatePuzzleRunPause(puzzleRun.runId, { - paused, - }); - setPuzzleRun((currentRun) => - currentRun - ? mergePuzzleServiceRuntimeState(currentRun, run) - : currentRun, - ); - void platformBootstrap.refreshProfileDashboard(); - } catch (error) { - setPuzzleError( - resolvePuzzleErrorMessage(error, '更新拼图计时状态失败。'), - ); - } + setPuzzleRun((currentRun) => + currentRun ? setLocalPuzzlePaused(currentRun, paused) : currentRun, + ); }, - [platformBootstrap, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError], + [puzzleRun], ); const syncPuzzleRuntimeTimeout = useCallback(async () => { @@ -2386,27 +2479,10 @@ export function PlatformEntryFlowShellImpl({ return; } - if (isLocalPuzzleRun(puzzleRun)) { - setPuzzleRun((currentRun) => - currentRun ? refreshLocalPuzzleTimer(currentRun) : currentRun, - ); - return; - } - - try { - const { run } = await getPuzzleRun(puzzleRun.runId); - setPuzzleRun((currentRun) => - currentRun - ? mergePuzzleServiceRuntimeState(currentRun, run) - : currentRun, - ); - void platformBootstrap.refreshSaveArchives(); - } catch (error) { - setPuzzleError( - resolvePuzzleErrorMessage(error, '同步拼图失败状态失败。'), - ); - } - }, [platformBootstrap, puzzleRun, resolvePuzzleErrorMessage, setPuzzleError]); + setPuzzleRun((currentRun) => + currentRun ? refreshLocalPuzzleTimer(currentRun) : currentRun, + ); + }, [puzzleRun]); const usePuzzleProp = useCallback( async (propKind: PuzzleRuntimePropKind) => { @@ -2421,36 +2497,21 @@ export function PlatformEntryFlowShellImpl({ return null; } - if (isLocalPuzzleRun(puzzleRun)) { - const currentRun = puzzleRunRef.current ?? puzzleRun; - if (!currentRun.currentLevel) { - return null; - } - const nextRun = - propKind === 'extendTime' - ? extendLocalPuzzleTime(currentRun) - : propKind === 'freezeTime' + const currentRun = puzzleRunRef.current ?? puzzleRun; + if (!currentRun.currentLevel) { + return null; + } + const nextRun = + propKind === 'extendTime' + ? extendLocalPuzzleTime(currentRun) + : propKind === 'freezeTime' ? applyLocalPuzzleFreezeTime(currentRun) : setLocalPuzzlePaused(currentRun, propKind === 'reference'); - puzzleRunRef.current = nextRun; - setPuzzleRun(nextRun); - return nextRun; - } - - const { run } = await consumePuzzleRuntimeProp(puzzleRun.runId, { - propKind, - }); - const nextRun = mergePuzzleServiceRuntimeState( - puzzleRunRef.current ?? puzzleRun, - run, - ); puzzleRunRef.current = nextRun; setPuzzleRun(nextRun); - void platformBootstrap.refreshProfileDashboard(); - void platformBootstrap.refreshSaveArchives(); return nextRun; }, - [platformBootstrap, puzzleRun], + [puzzleRun], ); const restartPuzzleCurrentLevel = useCallback(async () => { @@ -2460,33 +2521,13 @@ export function PlatformEntryFlowShellImpl({ } setPuzzleError(null); - const restartLevelId = resolvePuzzleRestartLevelId( - puzzleRun, - selectedPuzzleDetail, - ); - if (isLocalPuzzleRun(puzzleRun)) { - const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun); - puzzleRunRef.current = nextRun; - setPuzzleRun(nextRun); - return; - } - - await startPuzzleRunFromProfile( - currentLevel.profileId, - puzzleRuntimeReturnStage, - selectedPuzzleDetail?.profileId === currentLevel.profileId - ? selectedPuzzleDetail - : undefined, - false, - restartLevelId, - ); + const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun); + puzzleRunRef.current = nextRun; + setPuzzleRun(nextRun); }, [ isPuzzleBusy, puzzleRun, - puzzleRuntimeReturnStage, - selectedPuzzleDetail, setPuzzleError, - startPuzzleRunFromProfile, ]); const resumePuzzleSaveArchive = useCallback( @@ -2524,13 +2565,14 @@ export function PlatformEntryFlowShellImpl({ gameState.currentLevelId.trim() ? gameState.currentLevelId : null; - await startPuzzleRunFromProfile( - profileId, - 'platform', - undefined, - false, - levelId, - ); + const item = selectedPuzzleDetail?.profileId === profileId + ? selectedPuzzleDetail + : await getPuzzleGalleryDetail(profileId).then((response) => response.item); + const nextRun = startLocalPuzzleRun(item, levelId); + setSelectedPuzzleDetail(item); + setPuzzleRun(nextRun); + setPuzzleRuntimeReturnStage('platform'); + setSelectionStage('puzzle-runtime'); } catch (error) { platformBootstrap.setSaveError( resolvePuzzleErrorMessage(error, '恢复拼图存档失败。'), @@ -2542,9 +2584,10 @@ export function PlatformEntryFlowShellImpl({ [ isPuzzleBusy, platformBootstrap, + selectedPuzzleDetail, resolvePuzzleErrorMessage, setPuzzleError, - startPuzzleRunFromProfile, + setSelectionStage, ], ); @@ -2607,60 +2650,81 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError, ]); - const advancePuzzleLevel = useCallback(async (target?: { - profileId?: string; - levelId?: string | null; - }) => { - if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) { - return; - } - - const currentLevel = puzzleRun.currentLevel; - if (!currentLevel || currentLevel.status !== 'cleared') { - return; - } - - setIsPuzzleBusy(true); - setIsPuzzleNextLevelGenerating(true); - setPuzzleError(null); - - try { - const targetProfileId = target?.profileId?.trim(); - if ( - targetProfileId && - targetProfileId !== currentLevel.profileId && - puzzleRun.nextLevelMode === 'similarWorks' - ) { - await startPuzzleRunFromProfile( - targetProfileId, - 'puzzle-gallery-detail', - undefined, - false, - null, - ); + const advancePuzzleLevel = useCallback( + async (target?: { profileId?: string; levelId?: string | null }) => { + if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) { return; } - const { run } = isLocalPuzzleRun(puzzleRun) - ? { run: advanceLocalPuzzleLevel(puzzleRun) } - : await advancePuzzleNextLevel(puzzleRun.runId); - setPuzzleRun(run); - if (!isLocalPuzzleRun(puzzleRun)) { - void platformBootstrap.refreshSaveArchives(); + const currentLevel = puzzleRun.currentLevel; + if (!currentLevel || currentLevel.status !== 'cleared') { + return; } - } catch (error) { - setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。')); - } finally { - setIsPuzzleNextLevelGenerating(false); - setIsPuzzleBusy(false); + + setIsPuzzleBusy(true); + setIsPuzzleNextLevelGenerating(true); + setPuzzleError(null); + + try { + const nextRun = advanceLocalPuzzleLevel( + puzzleRun, + selectedPuzzleDetail, + target, + ); + setPuzzleRun(nextRun); + } catch (error) { + setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。')); + } finally { + setIsPuzzleNextLevelGenerating(false); + setIsPuzzleBusy(false); + } + }, + [ + isPuzzleBusy, + isPuzzleLeaderboardBusy, + puzzleRun, + resolvePuzzleErrorMessage, + selectedPuzzleDetail, + ], + ); + + const remodelCurrentPuzzleRuntimeWork = useCallback((profileId: string) => { + const targetProfileId = profileId.trim(); + if (!targetProfileId || isPublicWorkDetailBusy || isPuzzleBusy) { + return; } + + runProtectedAction(() => { + setIsPublicWorkDetailBusy(true); + setIsPuzzleBusy(true); + setPuzzleError(null); + setPublicWorkDetailError(null); + + void remixPuzzleGalleryWork(targetProfileId) + .then((response) => { + puzzleFlow.setSession(response.session); + setPuzzleOperation(null); + setPuzzleRun(null); + enterCreateTab(); + setSelectionStage('puzzle-result'); + }) + .catch((error) => { + setPuzzleError(resolvePuzzleErrorMessage(error, '改造拼图作品失败。')); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + setIsPuzzleBusy(false); + }); + }); }, [ + enterCreateTab, + isPublicWorkDetailBusy, isPuzzleBusy, - isPuzzleLeaderboardBusy, - platformBootstrap, - puzzleRun, + puzzleFlow, resolvePuzzleErrorMessage, - selectedPuzzleDetail, - startPuzzleRunFromProfile, + runProtectedAction, + setIsPuzzleBusy, + setPuzzleError, + setSelectionStage, ]); const leaveAgentWorkspace = useCallback(() => { @@ -2736,34 +2800,32 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - const confirmed = window.confirm( - `确认删除作品《${entry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`, - ); - if (!confirmed) { - return; - } + requestDeleteCreationWork({ + id: entry.profileId, + title: entry.worldName, + detail: '删除后会从你的作品列表和公开广场中移除。', + run: () => { + setDeletingCreationWorkId(entry.profileId); + platformBootstrap.setPlatformError(null); - setDeletingCreationWorkId(entry.profileId); - platformBootstrap.setPlatformError(null); - - void deleteRpgEntryWorldProfile(entry.profileId) - .then(async (entries) => { - platformBootstrap.setSavedCustomWorldEntries(entries); - await platformBootstrap.refreshCustomWorldWorks().catch(() => []); - await platformBootstrap.refreshPublishedGallery().catch(() => []); - }) - .catch((error) => { - platformBootstrap.setPlatformError( - resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'), - ); - }) - .finally(() => { - setDeletingCreationWorkId(null); - }); + void deleteRpgEntryWorldProfile(entry.profileId) + .then(async (entries) => { + platformBootstrap.setSavedCustomWorldEntries(entries); + await platformBootstrap.refreshCustomWorldWorks().catch(() => []); + await platformBootstrap.refreshPublishedGallery().catch(() => []); + }) + .catch((error) => { + platformBootstrap.setPlatformError( + resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }, }); }, - [deletingCreationWorkId, platformBootstrap, runProtectedAction], + [deletingCreationWorkId, platformBootstrap, requestDeleteCreationWork], ); const handleDeletePublishedWork = useCallback( @@ -2772,47 +2834,51 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - const confirmed = window.confirm( - `确认删除作品《${work.title}》吗?删除后会从你的作品列表和公开广场中移除。`, - ); - if (!confirmed) { - return; - } - setDeletingCreationWorkId(work.workId); - platformBootstrap.setPlatformError(null); + requestDeleteCreationWork({ + id: work.workId, + title: work.title, + detail: + work.status === 'published' + ? '删除后会从你的作品列表和公开广场中移除。' + : '删除后会从你的作品列表中移除。', + run: () => { + setDeletingCreationWorkId(work.workId); + platformBootstrap.setPlatformError(null); - const deleteTask = - work.sourceType === 'published_profile' && work.profileId - ? deleteRpgEntryWorldProfile(work.profileId).then( - async (entries) => { - platformBootstrap.setSavedCustomWorldEntries(entries); - await platformBootstrap - .refreshCustomWorldWorks() - .catch(() => []); - }, - ) - : work.sourceType === 'agent_session' && work.sessionId - ? deleteRpgCreationAgentSession(work.sessionId).then((items) => { - platformBootstrap.setCustomWorldWorkEntries(items); - }) - : Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。')); + const deleteTask = + work.sourceType === 'published_profile' && work.profileId + ? deleteRpgEntryWorldProfile(work.profileId).then( + async (entries) => { + platformBootstrap.setSavedCustomWorldEntries(entries); + await platformBootstrap + .refreshCustomWorldWorks() + .catch(() => []); + }, + ) + : work.sourceType === 'agent_session' && work.sessionId + ? deleteRpgCreationAgentSession(work.sessionId).then( + (items) => { + platformBootstrap.setCustomWorldWorkEntries(items); + }, + ) + : Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。')); - void deleteTask - .then(async () => { - await platformBootstrap.refreshPublishedGallery().catch(() => []); - }) - .catch((error) => { - platformBootstrap.setPlatformError( - resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'), - ); - }) - .finally(() => { - setDeletingCreationWorkId(null); - }); + void deleteTask + .then(async () => { + await platformBootstrap.refreshPublishedGallery().catch(() => []); + }) + .catch((error) => { + platformBootstrap.setPlatformError( + resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }, }); }, - [deletingCreationWorkId, platformBootstrap, runProtectedAction], + [deletingCreationWorkId, platformBootstrap, requestDeleteCreationWork], ); const handleDeleteBigFishWork = useCallback( @@ -2821,37 +2887,39 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - const confirmed = window.confirm( - `确认删除作品《${work.title}》吗?删除后会从你的作品列表中移除。`, - ); - if (!confirmed) { - return; - } + requestDeleteCreationWork({ + id: work.workId, + title: work.title, + detail: + work.status === 'published' + ? '删除后会从你的作品列表和公开广场中移除。' + : '删除后会从你的作品列表中移除。', + run: () => { + setDeletingCreationWorkId(work.workId); + setBigFishError(null); - setDeletingCreationWorkId(work.workId); - setBigFishError(null); - - void deleteBigFishWork(work.sourceSessionId) - .then(async (response) => { - setBigFishWorks(response.items); - await refreshBigFishGallery().catch(() => []); - }) - .catch((error) => { - setBigFishError( - resolveBigFishErrorMessage(error, '删除大鱼吃小鱼作品失败。'), - ); - }) - .finally(() => { - setDeletingCreationWorkId(null); - }); + void deleteBigFishWork(work.sourceSessionId) + .then(async (response) => { + setBigFishWorks(response.items); + await refreshBigFishGallery().catch(() => []); + }) + .catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '删除大鱼吃小鱼作品失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }, }); }, [ deletingCreationWorkId, refreshBigFishGallery, + requestDeleteCreationWork, resolveBigFishErrorMessage, - runProtectedAction, + setBigFishError, ], ); @@ -2861,40 +2929,42 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - const displayName = - work.workTitle?.trim() || work.levelName.trim() || '未命名拼图'; - const confirmed = window.confirm( - `确认删除作品《${displayName}》吗?删除后会从你的作品列表和公开广场中移除。`, - ); - if (!confirmed) { - return; - } + const displayName = + work.workTitle?.trim() || work.levelName.trim() || '未命名拼图'; + requestDeleteCreationWork({ + id: work.workId, + title: displayName, + detail: + work.publicationStatus === 'published' + ? '删除后会从你的作品列表和公开广场中移除。' + : '删除后会从你的作品列表中移除。', + run: () => { + setDeletingCreationWorkId(work.workId); + setPuzzleFormDraftPayload(null); + setPuzzleError(null); - setDeletingCreationWorkId(work.workId); - setPuzzleFormDraftPayload(null); - setPuzzleError(null); - - void deletePuzzleWork(work.profileId) - .then((response) => { - setPuzzleWorks(response.items); - void refreshPuzzleGallery(); - }) - .catch((error) => { - setPuzzleError( - resolvePuzzleErrorMessage(error, '删除拼图作品失败。'), - ); - }) - .finally(() => { - setDeletingCreationWorkId(null); - }); + void deletePuzzleWork(work.profileId) + .then((response) => { + setPuzzleWorks(response.items); + void refreshPuzzleGallery(); + }) + .catch((error) => { + setPuzzleError( + resolvePuzzleErrorMessage(error, '删除拼图作品失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }, }); }, [ deletingCreationWorkId, refreshPuzzleGallery, + requestDeleteCreationWork, resolvePuzzleErrorMessage, - runProtectedAction, + setPuzzleError, ], ); @@ -2904,37 +2974,38 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - const confirmed = window.confirm( - `确认删除作品《${work.gameName}》吗?删除后会从你的作品列表中移除。`, - ); - if (!confirmed) { - return; - } + requestDeleteCreationWork({ + id: work.workId, + title: work.gameName, + detail: + work.publicationStatus === 'published' + ? '删除后会从你的作品列表和公开广场中移除。' + : '删除后会从你的作品列表中移除。', + run: () => { + setDeletingCreationWorkId(work.workId); + setMatch3DError(null); - setDeletingCreationWorkId(work.workId); - setMatch3DError(null); - - void deleteMatch3DWork(work.profileId) - .then((response) => { - setMatch3DWorks(response.items); - void refreshMatch3DGallery(); - }) - .catch((error) => { - setMatch3DError( - resolveMatch3DErrorMessage(error, '删除抓大鹅作品失败。'), - ); - }) - .finally(() => { - setDeletingCreationWorkId(null); - }); + void deleteMatch3DWork(work.profileId) + .then((response) => { + setMatch3DWorks(response.items); + void refreshMatch3DGallery(); + }) + .catch((error) => { + setMatch3DError( + resolveMatch3DErrorMessage(error, '删除抓大鹅作品失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }, }); }, [ deletingCreationWorkId, refreshMatch3DGallery, + requestDeleteCreationWork, resolveMatch3DErrorMessage, - runProtectedAction, setMatch3DError, ], ); @@ -3004,14 +3075,10 @@ export function PlatformEntryFlowShellImpl({ .then((response) => { const updatedWork = response.item; setPuzzleWorks((current) => - current.map((item) => - mergePuzzleWorkSummary(item, updatedWork), - ), + current.map((item) => mergePuzzleWorkSummary(item, updatedWork)), ); setPuzzleGalleryEntries((current) => - current.map((item) => - mergePuzzleWorkSummary(item, updatedWork), - ), + current.map((item) => mergePuzzleWorkSummary(item, updatedWork)), ); setSelectedPuzzleDetail((current) => current ? mergePuzzleWorkSummary(current, updatedWork) : current, @@ -3370,12 +3437,15 @@ export function PlatformEntryFlowShellImpl({ ); const openMatch3DDraft = useCallback( - async (item: Match3DWorkSummary) => { + async ( + item: Match3DWorkSummary, + options: { forceDraft?: boolean } = {}, + ) => { setMatch3DRun(null); setMatch3DError(null); setMatch3DProfile(null); - if (item.publicationStatus === 'published') { + if (item.publicationStatus === 'published' && !options.forceDraft) { openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(item)); return; } @@ -3385,7 +3455,9 @@ export function PlatformEntryFlowShellImpl({ return; } - const restoredSession = await match3dFlow.restoreDraft(item.sourceSessionId); + const restoredSession = await match3dFlow.restoreDraft( + item.sourceSessionId, + ); if (!restoredSession) { await refreshMatch3DShelf().catch(() => undefined); return; @@ -3410,6 +3482,19 @@ export function PlatformEntryFlowShellImpl({ ], ); + const openBigFishDraft = useCallback( + async (item: BigFishWorkSummary) => { + setBigFishRun(null); + const restoredSession = await bigFishFlow.restoreDraft( + item.sourceSessionId, + ); + if (!restoredSession) { + await refreshBigFishShelf().catch(() => undefined); + } + }, + [bigFishFlow, refreshBigFishShelf], + ); + const startBigFishRunFromWork = useCallback( async ( item: BigFishWorkSummary, @@ -3480,7 +3565,9 @@ export function PlatformEntryFlowShellImpl({ if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) { const work = mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail); if (!work) { - setPublicWorkDetailError('当前拼图作品信息不完整,暂时无法进入玩法。'); + setPublicWorkDetailError( + '当前拼图作品信息不完整,暂时无法进入玩法。', + ); return; } setPublicWorkDetailError(null); @@ -3496,7 +3583,9 @@ export function PlatformEntryFlowShellImpl({ if (isMatch3DGalleryEntry(selectedPublicWorkDetail)) { const work = mapPublicWorkDetailToMatch3DWork(selectedPublicWorkDetail); if (!work) { - setPublicWorkDetailError('当前抓大鹅作品信息不完整,暂时无法进入玩法。'); + setPublicWorkDetailError( + '当前抓大鹅作品信息不完整,暂时无法进入玩法。', + ); return; } setPublicWorkDetailError(null); @@ -3634,12 +3723,94 @@ export function PlatformEntryFlowShellImpl({ ], ); + const editOwnedPublicWork = useCallback( + (entry: PlatformPublicGalleryCard) => { + if (isPublicWorkDetailBusy) { + return; + } + + runProtectedAction(() => { + setPublicWorkDetailError(null); + + // 中文注释:自有公开作品必须恢复原草稿,不能复用 remix 复制链路。 + if (isBigFishGalleryEntry(entry)) { + const work = mapPublicWorkDetailToBigFishWork(entry); + if (!work?.sourceSessionId?.trim()) { + setPublicWorkDetailError( + '这份大鱼吃小鱼作品缺少原草稿会话,暂时无法编辑。', + ); + return; + } + void openBigFishDraft(work); + return; + } + + if (isPuzzleGalleryEntry(entry)) { + const work = + selectedPuzzleDetail?.profileId === entry.profileId + ? selectedPuzzleDetail + : mapPublicWorkDetailToPuzzleWork(entry); + if (!work?.sourceSessionId?.trim()) { + setPublicWorkDetailError( + '这份拼图作品缺少原草稿会话,暂时无法编辑。', + ); + return; + } + void openPuzzleDraft(work); + return; + } + + if (isMatch3DGalleryEntry(entry)) { + const work = mapPublicWorkDetailToMatch3DWork(entry); + if (!work?.sourceSessionId?.trim()) { + setPublicWorkDetailError( + '这份抓大鹅作品缺少原草稿会话,暂时无法编辑。', + ); + return; + } + void openMatch3DDraft(work, { forceDraft: true }); + return; + } + + const editEntry = + selectedDetailEntry?.profileId === entry.profileId + ? selectedDetailEntry + : null; + if (!editEntry) { + setPublicWorkDetailError('作品详情尚未读取完成。'); + return; + } + + void detailNavigation.openSavedCustomWorldEditor(editEntry); + }); + }, + [ + detailNavigation, + isPublicWorkDetailBusy, + openBigFishDraft, + openMatch3DDraft, + openPuzzleDraft, + runProtectedAction, + selectedDetailEntry, + selectedPuzzleDetail, + ], + ); + const remixSelectedPublicWork = useCallback(() => { if (!selectedPublicWorkDetail) { return; } + if (isSelectedPublicWorkOwned) { + editOwnedPublicWork(selectedPublicWorkDetail); + return; + } remixPublicWork(selectedPublicWorkDetail); - }, [remixPublicWork, selectedPublicWorkDetail]); + }, [ + editOwnedPublicWork, + isSelectedPublicWorkOwned, + remixPublicWork, + selectedPublicWorkDetail, + ]); const handlePublicCodeSearch = useCallback( async (keyword: string) => { @@ -3960,19 +4131,6 @@ export function PlatformEntryFlowShellImpl({ void handlePublicCodeSearch(publicWorkCode); }, [handlePublicCodeSearch, initialPublicWorkCode]); - const openBigFishDraft = useCallback( - async (item: BigFishWorkSummary) => { - setBigFishRun(null); - const restoredSession = await bigFishFlow.restoreDraft( - item.sourceSessionId, - ); - if (!restoredSession) { - await refreshBigFishShelf().catch(() => undefined); - } - }, - [bigFishFlow, refreshBigFishShelf], - ); - useEffect(() => { if (selectionStage === 'platform') { if (isBigFishCreationVisible) { @@ -4263,6 +4421,7 @@ export function PlatformEntryFlowShellImpl({ isMatch3DBusy } error={publicWorkDetailError} + actionMode={selectedPublicWorkActionMode} visibleCoverCount={resolveVisiblePuzzleDetailCoverCount( selectedPublicWorkDetail, puzzleRun, @@ -4304,6 +4463,9 @@ export function PlatformEntryFlowShellImpl({ } isBusy={detailNavigation.isMutatingDetail} error={detailNavigation.detailError} + actionMode={ + detailNavigation.isSelectedWorldOwned ? 'edit' : 'remix' + } onBack={() => { detailNavigation.setDetailError(null); clearSelectedPublicWorkAuthor(); @@ -4316,9 +4478,13 @@ export function PlatformEntryFlowShellImpl({ }} onStart={handleStartSelectedWorld} onRemix={() => { - remixPublicWork( - mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry), - ); + const publicWorkEntry = + mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry); + if (detailNavigation.isSelectedWorldOwned) { + editOwnedPublicWork(publicWorkEntry); + return; + } + remixPublicWork(publicWorkEntry); }} /> ) : ( @@ -4572,7 +4738,9 @@ export function PlatformEntryFlowShellImpl({ className="flex h-full min-h-0 flex-col" > } + fallback={ + + } > { setMatch3DProfile(profile); @@ -4650,7 +4828,9 @@ export function PlatformEntryFlowShellImpl({ error={match3dError} onBack={() => { if (match3dRun?.runId && match3dRun.status === 'running') { - void stopMatch3DRun(match3dRun.runId).catch(() => undefined); + void stopMatch3DRun(match3dRun.runId).catch( + () => undefined, + ); } setSelectionStage(match3dRuntimeReturnStage); }} @@ -4681,7 +4861,9 @@ export function PlatformEntryFlowShellImpl({ onClickItem={(payload) => { const runId = payload.runId ?? match3dRun?.runId; if (!runId) { - return Promise.reject(new Error('抓大鹅运行态缺少 runId。')); + return Promise.reject( + new Error('抓大鹅运行态缺少 runId。'), + ); } return clickMatch3DItem(runId, payload); }} @@ -4885,6 +5067,11 @@ export function PlatformEntryFlowShellImpl({ onBack={() => { setSelectionStage(puzzleRuntimeReturnStage); }} + onRemodelWork={ + selectedPuzzleDetail?.publicationStatus === 'published' + ? remodelCurrentPuzzleRuntimeWork + : undefined + } onSwapPieces={(payload) => { void swapPuzzlePiecesInRun(payload); }} @@ -5025,7 +5212,9 @@ export function PlatformEntryFlowShellImpl({ sessionController.agentSession?.stage !== 'published' ? async () => { try { - await enterWorldCoordinator.publishCurrentResult(); + const publishedProfile = + await enterWorldCoordinator.publishCurrentResult(); + void openRpgPublishShareModal(publishedProfile); } catch (error) { sessionController.setCustomWorldError( resolveRpgCreationErrorMessage( @@ -5169,7 +5358,9 @@ export function PlatformEntryFlowShellImpl({ setShowCreationTypeModal(false); }} onSelectRpg={() => { - // RPG 创作入口当前为敬请期待;保留回调防御,避免旧入口绕过锁定态。 + runProtectedAction(() => { + void sessionController.openRpgAgentWorkspace(); + }); }} onSelectBigFish={() => { runProtectedAction(() => { @@ -5187,6 +5378,48 @@ export function PlatformEntryFlowShellImpl({ }); }} /> + setPublishSharePayload(null)} + /> + + + + + } + > +
+ {pendingDeleteCreationWork?.detail} +
+
{(searchedPublicUser || publicSearchError) && ( { expect(onLike).toHaveBeenCalledTimes(1); }); +test('PlatformWorkDetailView switches remix action label for owned work edit', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: '作品编辑' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull(); +}); + test('PlatformWorkDetailView cycles puzzle level cover slides', () => { vi.useFakeTimers(); const { container } = render( diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx index ffb6f413..f3850a21 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -8,6 +8,7 @@ import { Gamepad2, GitFork, Heart, + PencilLine, Play, Share2, } from 'lucide-react'; @@ -38,6 +39,7 @@ export interface PlatformWorkDetailViewProps { onLike: () => void; onStart: () => void; onRemix: () => void; + actionMode?: 'remix' | 'edit'; } function formatCompactCount(value: number) { @@ -78,6 +80,7 @@ export function PlatformWorkDetailView({ onLike, onStart, onRemix, + actionMode = 'remix', }: PlatformWorkDetailViewProps) { const coverSlides = useMemo( () => resolvePlatformWorldCoverSlides(entry), @@ -111,6 +114,8 @@ export function PlatformWorkDetailView({ [entry], ); const stats = resolvePlatformWorldStats(entry); + const workActionLabel = actionMode === 'edit' ? '作品编辑' : '作品改造'; + const WorkActionIcon = actionMode === 'edit' ? PencilLine : GitFork; const statItems = [ { label: '游玩', @@ -425,8 +430,8 @@ export function PlatformWorkDetailView({ onClick={onRemix} disabled={isBusy} > - - 作品改造 + + {workActionLabel}
diff --git a/src/components/puzzle-agent/PuzzleImageModelPicker.tsx b/src/components/puzzle-agent/PuzzleImageModelPicker.tsx new file mode 100644 index 00000000..c8cb4e31 --- /dev/null +++ b/src/components/puzzle-agent/PuzzleImageModelPicker.tsx @@ -0,0 +1,84 @@ +import { useEffect, useRef, useState } from 'react'; + +import { + getPuzzleImageModelLabel, + normalizePuzzleImageModel, + PUZZLE_IMAGE_MODEL_OPTIONS, + type PuzzleImageModelId, +} from './puzzleImageModelOptions'; + +type PuzzleImageModelPickerProps = { + value: PuzzleImageModelId; + disabled?: boolean; + onChange: (value: PuzzleImageModelId) => void; +}; + +export function PuzzleImageModelPicker({ + value, + disabled = false, + onChange, +}: PuzzleImageModelPickerProps) { + const [isOpen, setIsOpen] = useState(false); + const rootRef = useRef(null); + const normalizedValue = normalizePuzzleImageModel(value); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handlePointerDown = (event: PointerEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + window.addEventListener('pointerdown', handlePointerDown); + return () => window.removeEventListener('pointerdown', handlePointerDown); + }, [isOpen]); + + return ( +
+ + + {isOpen ? ( +
+ {PUZZLE_IMAGE_MODEL_OPTIONS.map((option) => ( + + ))} +
+ ) : null} +
+ ); +} diff --git a/src/components/puzzle-agent/puzzleImageModelOptions.ts b/src/components/puzzle-agent/puzzleImageModelOptions.ts new file mode 100644 index 00000000..12498083 --- /dev/null +++ b/src/components/puzzle-agent/puzzleImageModelOptions.ts @@ -0,0 +1,30 @@ +export const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; +export const PUZZLE_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; + +export type PuzzleImageModelId = + | typeof PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 + | typeof PUZZLE_IMAGE_MODEL_NANOBANANA2; + +export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{ + id: PuzzleImageModelId; + label: string; +}> = [ + { id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' }, + { id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' }, +]; + +export function normalizePuzzleImageModel( + value: string | null | undefined, +): PuzzleImageModelId { + return ( + PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === value)?.id ?? + PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 + ); +} + +export function getPuzzleImageModelLabel(model: PuzzleImageModelId) { + return ( + PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ?? + 'gpt-image-2' + ); +} diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index 5c4b5d49..53e15cd7 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -232,16 +232,29 @@ describe('PuzzleResultView', () => { fireEvent.change(within(dialog).getByLabelText('画面描述'), { target: { value: '一只猫在雨夜灯牌下回头。' }, }); - fireEvent.click(within(dialog).getByRole('button', { name: /重新生成画面/u })); + fireEvent.click( + within(dialog).getByRole('button', { name: /重新生成画面/u }), + ); + const confirmDialog = screen.getByRole('dialog', { + name: '确认消耗光点', + }); + expect(within(confirmDialog).getByText('消耗 2 光点')).toBeTruthy(); + fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' })); expect(onExecuteAction).toHaveBeenCalledWith({ action: 'generate_puzzle_images', levelId: 'puzzle-level-1', promptText: '一只猫在雨夜灯牌下回头。', referenceImageSrc: undefined, + imageModel: 'gpt-image-2', candidateCount: 1, + workTitle: '暖灯猫街作品', + workDescription: '一套雨夜猫街主题拼图。', + summary: '一只猫在雨夜灯牌下回头。', + themeTags: ['猫咪', '雨夜', '暖灯'], levelsJson: expect.any(String), }); + expect(screen.getByRole('progressbar', { name: '画面生成进度' })).toBeTruthy(); const generatePayload = onExecuteAction.mock.calls[0]![0]; expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([ expect.objectContaining({ @@ -295,9 +308,14 @@ describe('PuzzleResultView', () => { fireEvent.click(screen.getByRole('button', { name: /新增关卡/u })); const dialog = screen.getByRole('dialog', { name: '关卡详情' }); - expect(within(dialog).getByRole('button', { name: /生成画面/u })).toBeTruthy(); + expect( + within(dialog).getByRole('button', { name: /生成画面/u }), + ).toBeTruthy(); + expect(within(dialog).getByText('消耗2光点')).toBeTruthy(); expect(within(dialog).queryByText('画面图')).toBeNull(); - expect(within(dialog).queryByRole('button', { name: /关卡测试/u })).toBeNull(); + expect( + within(dialog).queryByRole('button', { name: /关卡测试/u }), + ).toBeNull(); fireEvent.click(screen.getByLabelText('关闭')); expect(screen.getAllByText('第2关').length).toBeGreaterThan(0); @@ -352,13 +370,24 @@ describe('PuzzleResultView', () => { target: { value: '新关卡里有一座发光钟楼。' }, }); fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u })); + fireEvent.click( + within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole( + 'button', + { name: '确定' }, + ), + ); expect(onExecuteAction).toHaveBeenCalledWith({ action: 'generate_puzzle_images', levelId: 'puzzle-level-1775000000000-2', promptText: '新关卡里有一座发光钟楼。', referenceImageSrc: undefined, + imageModel: 'gpt-image-2', candidateCount: 1, + workTitle: '暖灯猫街作品', + workDescription: '一套雨夜猫街主题拼图。', + summary: '新关卡里有一座发光钟楼。', + themeTags: ['猫咪', '雨夜', '暖灯'], levelsJson: expect.any(String), }); @@ -452,13 +481,59 @@ describe('PuzzleResultView', () => { }); fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u })); + fireEvent.click( + within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole( + 'button', + { name: '确定' }, + ), + ); expect(onExecuteAction).toHaveBeenLastCalledWith({ action: 'generate_puzzle_images', levelId: 'puzzle-level-1', promptText: '屋檐下的猫与暖灯街角。', referenceImageSrc: '/generated-puzzle-assets/history/image.png', + imageModel: 'gpt-image-2', candidateCount: 1, + workTitle: '暖灯猫街作品', + workDescription: '一套雨夜猫街主题拼图。', + summary: '屋檐下的猫与暖灯街角。', + themeTags: ['猫咪', '雨夜', '暖灯'], levelsJson: expect.any(String), }); }); + + test('passes the selected image model when regenerating a level image', () => { + const onExecuteAction = vi.fn(); + + render( + {}} + onExecuteAction={onExecuteAction} + />, + ); + + fireEvent.click(screen.getByText('雨夜猫街')); + const dialog = screen.getByRole('dialog', { name: '关卡详情' }); + fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' })); + fireEvent.click( + within(dialog).getByRole('menuitemradio', { name: 'gpt-image-2' }), + ); + fireEvent.click( + within(dialog).getByRole('button', { name: /重新生成画面/u }), + ); + fireEvent.click( + within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole( + 'button', + { name: '确定' }, + ), + ); + + expect(onExecuteAction).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'generate_puzzle_images', + imageModel: 'gpt-image-2', + }), + ); + }); }); diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index 741fd40a..aa31afca 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -26,6 +26,11 @@ import { } from '../../services/puzzle-works/puzzleAssetClient'; import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; import { useAuthUi } from '../auth/AuthUiContext'; +import { + PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, + type PuzzleImageModelId, +} from '../puzzle-agent/puzzleImageModelOptions'; +import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker'; import { ResolvedAssetImage } from '../ResolvedAssetImage'; type PuzzleResultViewProps = { @@ -51,6 +56,8 @@ type DraftEditState = { const PUZZLE_MIN_THEME_TAG_COUNT = 3; const PUZZLE_MAX_THEME_TAG_COUNT = 6; const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600; +const PUZZLE_IMAGE_GENERATION_POINT_COST = 2; +const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 30; function normalizeThemeTagInput(value: string) { return [ @@ -80,7 +87,9 @@ function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) { ); } -function buildFallbackLevelFromDraft(draft: PuzzleResultDraft): PuzzleDraftLevel { +function buildFallbackLevelFromDraft( + draft: PuzzleResultDraft, +): PuzzleDraftLevel { return { levelId: 'puzzle-level-1', levelName: draft.levelName || '', @@ -143,7 +152,9 @@ function createDraftEditState(draft: PuzzleResultDraft): DraftEditState { }; } -function createBlankPuzzleLevel(existingLevels: PuzzleDraftLevel[]): PuzzleDraftLevel { +function createBlankPuzzleLevel( + existingLevels: PuzzleDraftLevel[], +): PuzzleDraftLevel { const nextIndex = existingLevels.length + 1; return { levelId: `puzzle-level-${Date.now()}-${nextIndex}`, @@ -200,7 +211,9 @@ function buildPublishReady( ...(levels.length > 0 ? [] : ['至少需要一个拼图关卡。']), ...levels.flatMap((level, index) => [ ...(level.levelName.trim() ? [] : [`第${index + 1}关名称不能为空。`]), - ...(resolveLevelFormalImageSrc(level) ? [] : [`第${index + 1}关缺少正式图。`]), + ...(resolveLevelFormalImageSrc(level) + ? [] + : [`第${index + 1}关缺少正式图。`]), ]), ]; @@ -574,6 +587,7 @@ function PuzzleLevelDetailDialog({ levelId: string, promptText?: string | null, referenceImageSrc?: string | null, + imageModel?: PuzzleImageModelId | null, ) => void; onLevelChange: (nextLevel: PuzzleDraftLevel) => void; onStartTestRun?: (level: PuzzleDraftLevel) => void; @@ -581,10 +595,33 @@ function PuzzleLevelDetailDialog({ const platformTheme = useAuthUi()?.platformTheme ?? 'light'; const [referenceImageSrc, setReferenceImageSrc] = useState(''); const [referenceImageLabel, setReferenceImageLabel] = useState(''); - const [referenceImageError, setReferenceImageError] = useState(null); + const [referenceImageError, setReferenceImageError] = useState( + null, + ); const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false); + const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false); + const [isGenerationProgressActive, setIsGenerationProgressActive] = + useState(false); + const [generationCountdown, setGenerationCountdown] = useState(0); + const generationBusySeenRef = useRef(false); + const [imageModel, setImageModel] = useState( + PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, + ); const formalImageSrc = resolveLevelFormalImageSrc(level); const hasFormalImage = Boolean(formalImageSrc); + const isGenerationProgressVisible = isGenerationProgressActive; + const generationSecondsLeft = isBusy + ? Math.max(generationCountdown, 1) + : generationCountdown; + const generationProgressPercent = Math.max( + 6, + Math.round( + ((PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS - + Math.max(generationSecondsLeft, 0)) / + PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS) * + 100, + ), + ); const handleReferenceImageChange = async ( event: ChangeEvent, @@ -609,6 +646,59 @@ function PuzzleLevelDetailDialog({ } }; + useEffect(() => { + if (!isGenerationProgressActive) { + return; + } + + if (generationCountdown <= 0) { + if (!isBusy) { + setIsGenerationProgressActive(false); + } + return; + } + + const timer = window.setTimeout(() => { + setGenerationCountdown((current) => Math.max(0, current - 1)); + }, 1000); + + return () => window.clearTimeout(timer); + }, [generationCountdown, isBusy, isGenerationProgressActive]); + + useEffect(() => { + if (isGenerationProgressActive && isBusy) { + generationBusySeenRef.current = true; + return; + } + + if ( + isGenerationProgressActive && + !isBusy && + generationBusySeenRef.current + ) { + generationBusySeenRef.current = false; + setIsGenerationProgressActive(false); + setGenerationCountdown(0); + } + + if (!isBusy) { + setIsCostConfirmOpen(false); + } + }, [isBusy, isGenerationProgressActive]); + + const executeGeneration = () => { + setIsCostConfirmOpen(false); + setIsGenerationProgressActive(true); + generationBusySeenRef.current = false; + setGenerationCountdown(PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS); + onGenerate( + level.levelId, + level.pictureDescription.trim() || undefined, + referenceImageSrc || undefined, + imageModel, + ); + }; + if (typeof document === 'undefined') { return null; } @@ -704,6 +794,11 @@ function PuzzleLevelDetailDialog({ className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none" aria-label="画面描述" /> +