From acc55d0e1329ffc853a23523995b7ceb746b0b27 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sat, 2 May 2026 17:56:42 +0800 Subject: [PATCH] 1 --- ...USTOM_WORLD_CREATION_HUB_PRD_2026-04-13.md | 12 +- ...ORK_DETAIL_OWNED_EDIT_ACTION_2026-05-02.md | 29 + ..._APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md | 30 +- .../PUZZLE_FORM_CREATION_FLOW_2026-04-29.md | 13 +- ...AND_FRONTEND_RULES_ALIGNMENT_2026-04-29.md | 18 +- ...ME_FIRST_EXIT_REMODEL_PROMPT_2026-05-02.md | 38 + docs/technical/README.md | 4 + ...ON_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md | 45 + .../WORK_PUBLISH_SHARE_PANEL_2026-05-02.md | 56 + ...4-000001-upstream_status_failed.input.json | 18 + ...4-000001-upstream_status_failed.output.txt | 1 + ...4-000002-upstream_status_failed.input.json | 18 + ...4-000002-upstream_status_failed.output.txt | 1 + ...03-66824-000003-request_timeout.input.json | 18 + ...03-66824-000003-request_timeout.output.txt | 1 + .../src/contracts/puzzleAgentActions.ts | 4 + scripts/api-server-maincloud.mjs | 1 + .../crates/api-server/src/asset_billing.rs | 104 +- .../crates/api-server/src/custom_world.rs | 72 +- .../src/custom_world_foundation_draft.rs | 228 +++- server-rs/crates/api-server/src/http_error.rs | 4 + server-rs/crates/api-server/src/main.rs | 3 +- server-rs/crates/api-server/src/puzzle.rs | 1091 ++++++++--------- .../common/PublishShareModal.test.tsx | 65 + src/components/common/PublishShareModal.tsx | 145 +++ .../common/publishShareModalModel.ts | 30 + ...ustomWorldCreationHub.interaction.test.tsx | 49 + .../custom-world-home/CustomWorldWorkCard.tsx | 126 +- .../PlatformEntryFlowShellImpl.tsx | 627 +++++++--- .../PlatformWorkDetailView.test.tsx | 18 + .../platform-entry/PlatformWorkDetailView.tsx | 9 +- .../PuzzleAgentWorkspace.interaction.test.tsx | 8 +- .../puzzle-agent/PuzzleAgentWorkspace.tsx | 11 +- .../puzzle-agent/puzzleImageModelOptions.ts | 7 +- .../puzzle-result/PuzzleResultView.test.tsx | 43 +- .../puzzle-result/PuzzleResultView.tsx | 175 ++- .../PuzzleRuntimeShell.test.tsx | 69 ++ .../puzzle-runtime/PuzzleRuntimeShell.tsx | 129 +- ...gEntryFlowShell.agent.interaction.test.tsx | 189 +++ .../rpg-entry/rpgEntryWorldPresentation.ts | 4 + 40 files changed, 2582 insertions(+), 931 deletions(-) create mode 100644 docs/technical/PUBLIC_WORK_DETAIL_OWNED_EDIT_ACTION_2026-05-02.md create mode 100644 docs/technical/PUZZLE_RUNTIME_FIRST_EXIT_REMODEL_PROMPT_2026-05-02.md create mode 100644 docs/technical/RPG_FOUNDATION_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md create mode 100644 docs/technical/WORK_PUBLISH_SHARE_PANEL_2026-05-02.md create mode 100644 logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.input.json create mode 100644 logs/llm-raw/1777646477026-66824-000001-upstream_status_failed.output.txt create mode 100644 logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.input.json create mode 100644 logs/llm-raw/1777646793980-66824-000002-upstream_status_failed.output.txt create mode 100644 logs/llm-raw/1777646955503-66824-000003-request_timeout.input.json create mode 100644 logs/llm-raw/1777646955503-66824-000003-request_timeout.output.txt create mode 100644 src/components/common/PublishShareModal.test.tsx create mode 100644 src/components/common/PublishShareModal.tsx create mode 100644 src/components/common/publishShareModalModel.ts 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/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 index 2bdf08ff..48c7877a 100644 --- a/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md +++ b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md @@ -13,38 +13,43 @@ ## 模型选项 -拼图图片生成支持三个选项: +拼图图片生成支持两个选项: | 前端显示 | 请求值 | 上游 | | --- | --- | --- | -| 原模型 | `original` | 继续使用现有 DashScope 文生图 / 图生图链路 | | `gpt-image-2` | `gpt-image-2` | APIMart `/v1/images/generations` | | `nanobanana2` | `gemini-3.1-flash-image-preview` | APIMart `/v1/images/generations` | -默认值为 `original`。前端只负责展示和传递所选模型,不能把模型路由逻辑、上游请求体拼装或 API Key 暴露到浏览器。 +默认值为 `gpt-image-2`。前端只负责展示和传递所选模型,不能把模型路由逻辑、上游请求体拼装或 API Key 暴露到浏览器。历史草稿或旧请求中的空值、`original`、未知值统一按 `gpt-image-2` 处理,不再把拼图生图路由回 DashScope 原模型。 ## 前端交互 1. 拼图创作表单的“画面描述”输入框左下角显示当前调用模型。 2. 拼图结果页关卡详情的“画面描述”输入框左下角同样显示当前调用模型。 -3. 点击模型标识弹出轻量选择菜单,支持 `原模型`、`gpt-image-2`、`nanobanana2` 三项。 +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` 或未知值统一回落为原 DashScope 链路; + - 空值、`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`;原 DashScope 继续使用像素写法 `1024*1024`。`gemini-3.1-flash-image-preview` 额外带 `resolution = "1K"`,对齐约 1024px 的拼图正方形素材。 -6. APIMart 生成成功后仍下载远程图片,沿用现有 OSS 私有对象、`asset_object` 和 `asset_entity_binding` 写入流程。 -7. APIMart 错误统一映射为 `502 UPSTREAM_ERROR`,`details.provider = "apimart"`,保留上游状态码、业务 message 和截断后的 raw excerpt。 +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 仍按既有降级策略处理。 ## 环境变量 @@ -60,10 +65,11 @@ APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 ## 验收 -1. 创作表单和关卡详情的画面描述框左下角能切换 `原模型`、`gpt-image-2`、`nanobanana2`。 +1. 创作表单和关卡详情的画面描述框左下角能切换 `gpt-image-2`、`nanobanana2`,默认显示 `gpt-image-2`。 2. 点击“生成草稿”时,后端首图生成使用当前表单选择的模型。 3. 点击“生成画面 / 重新生成画面”时,后端当前关卡图片生成使用关卡详情选择的模型。 -4. 选择 `original` 时,现有 DashScope 请求体、尺寸和错误处理保持兼容。 +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. 不改 SpacetimeDB 表结构,因此无需更新 `migration.rs` 或重新生成 bindings。 -7. 后端改动后运行对应 Rust 测试,并按项目约束用 `npm run api-server:maincloud` 重启验证。 +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..445175eb 100644 --- a/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md +++ b/docs/technical/PUZZLE_FORM_CREATION_FLOW_2026-04-29.md @@ -13,10 +13,11 @@ 3. 初始表单输入自动保存到 session 的 `draft_json` 与 `puzzle_work_profile` 投影。保存字段只包含 `workTitle`、`workDescription`、`pictureDescription`、可推断标签和一个 `generationStatus = idle` 的默认关卡;草稿设置阶段默认关卡名称必须为空,不得写入“第一关”“第1关”或作品名称作为默认值。参考图只保存在当前前端会话内,不落入 SpacetimeDB。 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`。 -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 重新授权后发布;禁止为绕过冲突直接清库,除非明确接受数据丢失。 +6. 生成草稿按钮文本右侧固定展示 `消耗2光点`,实际扣费由后端 `compile_puzzle_draft` 统一预扣,前端不自行判断余额。 +7. 点击拼图入口始终创建新草稿,不复用上一次未完成 session;恢复旧草稿只通过“我的创作”中的草稿卡进入。 +8. 若 Maincloud 仍运行旧 wasm,缺少 `save_puzzle_form_draft` procedure,前端提交生成或生成失败页重试时不得继续复用空 `seedText` 的表单 session,必须用当前表单 payload 新建带真实 seed 的 session 再执行 `compile_puzzle_draft`。 +9. api-server 也要兼容旧 wasm:`save_puzzle_form_draft` 缺失时,自动保存 action 降级返回当前 session;`compile_puzzle_draft` 前置保存缺失且当前 session 为空 seed 时,创建一条带表单 seed 的替代 session 后继续编译,避免再次暴露 `No such procedure`。 +10. 正式修复仍是发布最新 SpacetimeDB wasm。当前 Maincloud `xushi-p4wfr` 的迁移操作员表为空,但旧库引导密钥来自旧 wasm,本次临时生成的新引导密钥无法授权导出迁移,需使用已有迁移操作员 token 或数据库 owner 重新授权后发布;禁止为绕过冲突直接清库,除非明确接受数据丢失。 1. 作品名称为必填字段,保存到 `workTitle`,兼容写入旧 `seedText`,同时作为作品级 `workTitle` 的真相源。 2. 作品描述为必填字段,保存到 `workDescription`,作为作品详情页、作品列表和发布资料中的 `summary` 真相源。 @@ -96,6 +97,8 @@ 6. api-server 处理 `generate_puzzle_images` 时,若 action 带有 `levelsJson`,必须用这份关卡快照覆盖本次生成的草稿关卡视图后再定位 `levelId`。若请求明确传入 `levelId` 但关卡列表中不存在该关卡,必须返回错误,不得静默回退第一关。 7. 历史拼图素材入口只在已有正式图的 `画面图` 区域右下角展示,不再放在 `画面描述` 输入区;本地上传参考图入口仍保留在画面描述输入区右下角。 8. 历史拼图素材列表必须由服务端按当前登录账号过滤,只返回 `asset_kind = puzzle_cover_image` 且 `owner_user_id = 当前账号` 的资产;不得依赖前端过滤,也不得展示其他账号素材。 +9. 关卡详情“生成画面 / 重新生成画面”按钮文本右侧展示 `消耗2光点`;点击后弹出消耗确认弹窗,确认后才调用 `generate_puzzle_images`。 +10. 确认开始生成后,关卡详情底部生图按钮位置切换为 30 秒进度条,标注 `预计剩余 xx 秒`,用于覆盖 APIMart 生图常见等待时间;接口提前结束或失败时由父级 busy/error 状态接管。 画面描述区域不再展示候选图实际 prompt 或“请生成一张适合……”之类内部提示词模块。参考图入口保留在画面描述编辑区域内,便于重新生成时继续带入。结果页编辑关卡画面描述时只同步该关卡 `pictureDescription`;作品描述只在作品信息 Tab 编辑,作品详情页不得再回退使用画面描述。 @@ -105,5 +108,5 @@ 2. 点击确认后进入拼图草稿生成进度页,并自动完成草稿编译、首图生成、正式图选择。 3. 首图生成请求使用玩家画面描述作为 prompt;上传参考图时走图生图;作品详情页展示玩家作品描述。 4. 结果页包含“拼图关卡”和“作品信息”两个 Tab;关卡列表默认至少一关,支持新增、删除和进入关卡详情。 -5. 关卡详情页支持生成或重新生成画面;已有正式图后显示吸底“关卡测试”入口。 +5. 关卡详情页支持生成或重新生成画面;生图前确认消耗 `2` 光点,确认后展示 30 秒预计剩余进度条;已有正式图后显示吸底“关卡测试”入口。 6. 发布、作品测试、自动保存作品名称、作品描述、作品标签和关卡列表仍可用。 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_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 a9cdf785..9f6a241e 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -4,12 +4,15 @@ ## 文档列表 +- [WORK_PUBLISH_SHARE_PANEL_2026-05-02.md](./WORK_PUBLISH_SHARE_PANEL_2026-05-02.md):记录作品发布完成后“分享给朋友”面板的标题、分享文本、复制按钮、微信/QQ/抖音渠道 icon 与四类作品发布链路接入口径。 - [PRODUCT_NAMING_BAIMENG_RENAME_2026-05-01.md](./PRODUCT_NAMING_BAIMENG_RENAME_2026-05-01.md):冻结当前对外中文命名,产品展示名统一为“百梦”,消费单位为“光点”,公开账号标识为“百梦号”,创作侧称谓为“百梦主”。 +- [PUBLIC_WORK_DETAIL_OWNED_EDIT_ACTION_2026-05-02.md](./PUBLIC_WORK_DETAIL_OWNED_EDIT_ACTION_2026-05-02.md):记录公开作品详情页按 `ownerUserId` 识别本人作品,将左下角“作品改造”切换为“作品编辑”并恢复原草稿而不复制新作品的规则。 - [NEW_WORK_ENTRY_CONFIG_2026-05-01.md](./NEW_WORK_ENTRY_CONFIG_2026-05-01.md):记录新建作品入口开放状态、隐藏状态和展示文案统一收口到 `src/config/newWorkEntryConfig.ts` 的配置规则。 - [PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md](./PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md):记录拼图图片生成接入 APIMart `gpt-image-2`、`gemini-3.1-flash-image-preview`,以及画面描述框内模型切换与后端路由边界。 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 - [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 可跑链路。 +- [RPG_FOUNDATION_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md](./RPG_FOUNDATION_DRAFT_LLM_SEARCH_FALLBACK_2026-05-01.md):记录 RPG foundation draft 分批 LLM 生成遇到 `ToolNotOpen` 或搜索增强超时时,按配置启用搜索并自动降级为无搜索重试的修复口径。 - [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、前端即时反馈/后端权威确认协议,以及可并行开发包。 @@ -35,6 +38,7 @@ - [PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md](./PUZZLE_LEADERBOARD_FRONTEND_LEVEL_AND_RPG_COMING_SOON_2026-04-30.md):记录拼图第二关排行榜提交以前端当前关卡为准、不被 SpacetimeDB 旧 run 快照误杀,以及 RPG 创作入口改为敬请期待的落地边界。 - [PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md](./PUZZLE_NEXT_LEVEL_AND_SIMILAR_WORK_HANDOFF_2026-04-30.md):记录拼图通关后优先同作品下一关、无下一关时按 RPG/build 标签语义相似度返回三个候选作品,并在跨作品时只切换到候选作品第 1 张图、运行时关卡序号继续累进的落地规则。 - [PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md](./PUZZLE_FAILURE_EXTENSION_AND_SAVE_ARCHIVE_2026-05-01.md):记录拼图失败后重新开始/付费续时,以及进入作品与过关后同步存档页投影的落地规则。 +- [PUZZLE_RUNTIME_FIRST_EXIT_REMODEL_PROMPT_2026-05-02.md](./PUZZLE_RUNTIME_FIRST_EXIT_REMODEL_PROMPT_2026-05-02.md):记录拼图运行态首次点击左上返回时弹出作品改造引导,并复用现有拼图 remix 进入结果页改造草稿的交互边界。 - [PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md](./PUZZLE_RUNTIME_TIMER_AND_PROPS_2026-04-29.md):记录拼图关卡切割、倒计时、失败态和三个运行时道具的统一规则;2026-05-01 起关卡切割与限时按第 1-10 关配置,并从第 11 关按第 5-10 关六关循环。 - [RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md](./RPG_SCENE_ACT_PREVIEW_BOOTSTRAP_FIX_2026-04-30.md):记录编辑器幕预览卡在“正在载入这一幕”时的启动态根因,收口预览本地运行态装配与禁持久化首段 story 注入。 - [PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md](./PUZZLE_RESULT_AUTOSAVE_AND_TAG_GATE_FIX_2026-04-28.md):记录拼图结果页名称与标签编辑自动保存、发布门槛统一到 `3~6` 标签,以及前端发布校验不再被旧 session blocker 卡死的修复口径。 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/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/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/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 98ecbba7..39131649 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -65,6 +65,10 @@ export type PuzzleAgentActionRequest = referenceImageSrc?: string | null; imageModel?: string | null; candidateCount?: number; + workTitle?: string; + workDescription?: string; + summary?: string; + themeTags?: string[]; levelsJson?: string; } | { diff --git a/scripts/api-server-maincloud.mjs b/scripts/api-server-maincloud.mjs index 92709e10..bc50d9f0 100644 --- a/scripts/api-server-maincloud.mjs +++ b/scripts/api-server-maincloud.mjs @@ -37,6 +37,7 @@ 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'; diff --git a/server-rs/crates/api-server/src/asset_billing.rs b/server-rs/crates/api-server/src/asset_billing.rs index 18b2ef8f..bb70b714 100644 --- a/server-rs/crates/api-server/src/asset_billing.rs +++ b/server-rs/crates/api-server/src/asset_billing.rs @@ -19,11 +19,45 @@ pub(crate) async fn execute_billable_asset_operation( 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/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 df2aab05..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,13 +2882,9 @@ mod tests { fn llm_response(content: &str) -> String { json!({ "id": "resp_01", - "choices": [ - { - "message": { - "content": content, - } - } - ] + "model": CREATION_TEMPLATE_LLM_MODEL, + "output_text": content, + "status": "completed" }) .to_string() } @@ -2814,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 @@ -2821,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); @@ -2832,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); } }); @@ -2880,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 eb3f3bb4..d9e08bef 100644 --- a/server-rs/crates/api-server/src/http_error.rs +++ b/server-rs/crates/api-server/src/http_error.rs @@ -34,6 +34,10 @@ impl AppError { self.code } + pub fn status_code(&self) -> StatusCode { + self.status_code + } + pub fn message(&self) -> &str { &self.message } diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 6e4f76df..5b19818c 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -76,9 +76,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 81cd3b8c..08e7484d 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -51,7 +51,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, @@ -74,7 +74,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, prompt::puzzle::{ @@ -97,10 +100,9 @@ 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_ORIGINAL: &str = "original"; 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"; const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; const PUZZLE_APIMART_GENERATED_IMAGE_SIZE: &str = "1:1"; @@ -468,7 +470,7 @@ pub async fn execute_puzzle_agent_action( session_id = %session_id, owner_user_id = %owner_user_id, action = %action, - image_model = payload.image_model.as_deref().unwrap_or(PUZZLE_IMAGE_MODEL_ORIGINAL), + image_model = resolve_puzzle_image_model(payload.image_model.as_deref()).request_model_name(), prompt_chars = payload .prompt_text .as_deref() @@ -502,11 +504,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, @@ -518,7 +521,6 @@ pub async fn execute_puzzle_agent_action( now, ) .await - .map_err(map_puzzle_compile_error) }, ) .await @@ -597,18 +599,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, @@ -639,12 +646,7 @@ pub async fn execute_puzzle_agent_action( 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() @@ -657,18 +659,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 @@ -2313,6 +2338,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], @@ -2416,17 +2575,19 @@ async fn compile_puzzle_draft_with_initial_cover( 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, @@ -2444,22 +2605,31 @@ async fn compile_puzzle_draft_with_initial_cover( 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(), @@ -2469,8 +2639,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, @@ -2480,6 +2676,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( @@ -2514,6 +2797,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("不存在") @@ -2559,18 +2847,26 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { || message.contains("APIMART") { "apimart" - } else if message.contains("DashScope") || message.contains("dashscope") { - "dashscope" } 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") @@ -2597,6 +2893,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, } @@ -2650,13 +2948,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( @@ -2669,12 +2970,11 @@ async fn generate_puzzle_image_candidates( image_model: Option<&str>, candidate_count: u32, candidate_start_index: usize, -) -> Result, String> { +) -> Result, AppError> { let count = candidate_count.clamp(1, 1); 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) - .map_err(map_puzzle_generation_app_error)?; + let http_client = build_puzzle_image_http_client(state, resolved_model)?; tracing::info!( provider = resolved_model.provider_name(), image_model = resolved_model.request_model_name(), @@ -2692,62 +2992,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,参考图读取与外部生图都必须停留在 api-server。 // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 - let generated = match resolved_model { - PuzzleImageModel::Original => { - let settings = require_puzzle_dashscope_settings(state) - .map_err(map_puzzle_generation_app_error)?; - 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 - } - } - } - PuzzleImageModel::GptImage2 | PuzzleImageModel::Gemini31FlashPreview => { - let settings = - require_puzzle_apimart_settings(state).map_err(map_puzzle_generation_app_error)?; - 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_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() { @@ -2766,7 +3030,7 @@ 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, @@ -2868,10 +3132,10 @@ async fn build_local_next_puzzle_run( draft.candidates.len(), ) .await - .map_err(|message| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + .map_err(|error| { + AppError::from_status(error.status_code()).with_details(json!({ "provider": PUZZLE_RUNTIME_PROVIDER, - "message": message, + "message": error.body_text(), })) })?; let candidates_json = serde_json::to_string( @@ -3621,25 +3885,6 @@ mod tests { assert_eq!(PUZZLE_APIMART_GENERATED_IMAGE_SIZE, "1:1"); } - #[test] - fn puzzle_text_to_image_request_places_negative_prompt_in_input_when_present() { - let body = build_puzzle_text_to_image_request_body( - "一只猫在雨夜灯牌下回头。", - PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_GENERATED_IMAGE_SIZE, - 3, - ); - - assert_eq!(body["input"]["prompt"], "一只猫在雨夜灯牌下回头。"); - assert_eq!( - body["input"]["negative_prompt"], - PUZZLE_DEFAULT_NEGATIVE_PROMPT - ); - assert!(body["parameters"].get("negative_prompt").is_none()); - assert_eq!(body["parameters"]["size"], PUZZLE_GENERATED_IMAGE_SIZE); - assert_eq!(body["parameters"]["n"], 1); - } - #[test] fn puzzle_apimart_request_uses_selected_model_and_reference_images() { let body = build_puzzle_apimart_image_request_body( @@ -3665,16 +3910,62 @@ mod tests { } #[test] - fn puzzle_dashscope_upstream_error_keeps_status_and_raw_excerpt() { - let error = map_puzzle_dashscope_upstream_error( - reqwest::StatusCode::BAD_REQUEST, - r#"{"code":"InvalidParameter","message":"请求参数不合法"}"#, - "创建拼图图片生成任务失败", - ); + fn puzzle_compile_error_preserves_apimart_unavailable_status() { + let error = map_puzzle_compile_error(SpacetimeClientError::Runtime( + "APIMART_API_KEY 未配置".to_string(), + )); - assert_eq!(error.body_text(), "请求参数不合法"); let response = error.into_response(); - assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + 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] @@ -3698,31 +3989,19 @@ mod tests { } } -struct PuzzleDashScopeSettings { - base_url: String, - api_key: String, - reference_image_model: String, - request_timeout_ms: u64, -} - #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PuzzleImageModel { - Original, GptImage2, Gemini31FlashPreview, } impl PuzzleImageModel { fn provider_name(self) -> &'static str { - match self { - Self::Original => "dashscope", - Self::GptImage2 | Self::Gemini31FlashPreview => "apimart", - } + "apimart" } fn request_model_name(self) -> &'static str { match self { - Self::Original => PUZZLE_TEXT_TO_IMAGE_MODEL, Self::GptImage2 => PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, Self::Gemini31FlashPreview => PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW, } @@ -3730,7 +4009,6 @@ impl PuzzleImageModel { fn candidate_source_type(self) -> &'static str { match self { - Self::Original => "generated", Self::GptImage2 => "generated:gpt-image-2", Self::Gemini31FlashPreview => "generated:nanobanana2", } @@ -3764,49 +4042,10 @@ 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('/'); - if base_url.is_empty() { - return Err( - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "dashscope", - "reason": "DASHSCOPE_BASE_URL 未配置", - })), - ); - } - - let api_key = state - .config - .dashscope_api_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "dashscope", - "reason": "DASHSCOPE_API_KEY 未配置", - })) - })?; - - Ok(PuzzleDashScopeSettings { - base_url: base_url.to_string(), - api_key: api_key.to_string(), - reference_image_model: state.config.dashscope_reference_image_model.clone(), - request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1), - }) -} - fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel { - match value - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(PUZZLE_IMAGE_MODEL_ORIGINAL) - { - PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 => PuzzleImageModel::GptImage2, - PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW => PuzzleImageModel::Gemini31FlashPreview, - _ => PuzzleImageModel::Original, + match value.map(str::trim).filter(|value| !value.is_empty()) { + Some(PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW) => PuzzleImageModel::Gemini31FlashPreview, + _ => PuzzleImageModel::GptImage2, } } @@ -3816,6 +4055,7 @@ fn require_puzzle_apimart_settings(state: &AppState) -> Result Result Result { - let (provider, request_timeout_ms) = match image_model { - PuzzleImageModel::Original => { - ("dashscope", state.config.dashscope_image_request_timeout_ms) - } - PuzzleImageModel::GptImage2 | PuzzleImageModel::Gemini31FlashPreview => { - ("apimart", state.config.apimart_image_request_timeout_ms) - } - }; + let provider = image_model.provider_name(); + let request_timeout_ms = state.config.apimart_image_request_timeout_ms; reqwest::Client::builder() .timeout(Duration::from_millis(request_timeout_ms.max(1))) @@ -3880,149 +4115,6 @@ fn to_puzzle_generated_image_candidate( } } -async fn create_puzzle_text_to_image_generation( - http_client: &reqwest::Client, - settings: &PuzzleDashScopeSettings, - prompt: &str, - negative_prompt: &str, - size: &str, - candidate_count: u32, -) -> Result { - let response = http_client - .post(format!( - "{}/services/aigc/text2image/image-synthesis", - settings.base_url - )) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header("X-DashScope-Async", "enable") - .json(&build_puzzle_text_to_image_request_body( - prompt, - negative_prompt, - size, - candidate_count, - )) - .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 task_id = extract_puzzle_task_id(&payload).ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", - "message": "拼图图片生成任务未返回 task_id", - })) - })?; - let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); - - while Instant::now() < deadline { - let poll_response = http_client - .get(format!("{}/tasks/{}", settings.base_url, task_id)) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .send() - .await - .map_err(|error| { - map_puzzle_dashscope_request_error(format!("查询拼图图片生成任务失败:{error}")) - })?; - let poll_status = poll_response.status(); - let poll_text = poll_response.text().await.map_err(|error| { - map_puzzle_dashscope_request_error(format!("读取拼图图片生成任务响应失败:{error}")) - })?; - if !poll_status.is_success() { - return Err(map_puzzle_dashscope_upstream_error( - poll_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, images }); - } - if matches!(task_status.as_str(), "FAILED" | "UNKNOWN") { - return Err(map_puzzle_dashscope_upstream_error( - poll_status, - poll_text.as_str(), - "拼图图片生成任务失败", - )); - } - sleep(Duration::from_secs(2)).await; - } - - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", - "message": "拼图图片生成超时或未返回图片地址", - })), - ) -} - -fn build_puzzle_text_to_image_request_body( - prompt: &str, - negative_prompt: &str, - size: &str, - 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()), - ); - } - - json!({ - "model": PUZZLE_TEXT_TO_IMAGE_MODEL, - "input": input, - "parameters": parameters, - }) -} - async fn create_puzzle_apimart_image_generation( http_client: &reqwest::Client, settings: &PuzzleApimartSettings, @@ -4155,6 +4247,7 @@ async fn wait_puzzle_apimart_generated_images( 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 @@ -4307,9 +4400,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() @@ -4318,7 +4409,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( @@ -4346,185 +4437,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 @@ -4534,12 +4452,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(), })), @@ -4620,26 +4538,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, @@ -4647,6 +4583,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, @@ -4664,7 +4624,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}"), })) }) @@ -4742,10 +4702,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); } @@ -4754,6 +4712,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(';') @@ -4777,36 +4747,13 @@ 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( - upstream_status: reqwest::StatusCode, - raw_text: &str, - fallback_message: &str, -) -> AppError { - 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", - upstream_status = upstream_status.as_u16(), - message = %message, - raw_excerpt = %raw_excerpt, - "拼图 DashScope 上游请求失败" - ); - - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", - "upstreamStatus": upstream_status.as_u16(), - "message": message, - "rawExcerpt": raw_excerpt, - })) -} - fn map_puzzle_apimart_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ "provider": "apimart", 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 63ac1330..54b7e11d 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -319,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/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/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index f7586beb..6daf450e 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -171,6 +171,9 @@ import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClien import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; +import { PublishShareModal } from '../common/PublishShareModal'; +import type { PublishShareModalPayload } from '../common/publishShareModalModel'; +import { UnifiedModal } from '../common/UnifiedModal'; import { isBigFishGalleryEntry, isMatch3DGalleryEntry, @@ -227,6 +230,13 @@ type PuzzleSaveArchiveState = { currentLevelId?: unknown; }; +type DeleteCreationWorkConfirmation = { + id: string; + title: string; + detail: string; + run: () => void; +}; + async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) { return requestRpgRuntimeJson< ProfileSaveArchiveResumeResponse @@ -345,7 +355,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, @@ -403,7 +416,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, @@ -1000,10 +1016,14 @@ export function PlatformEntryFlowShellImpl({ const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< string | null >(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); @@ -1279,6 +1299,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; @@ -1347,6 +1411,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 ( @@ -1374,6 +1445,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; @@ -1433,6 +1535,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; @@ -1610,6 +1717,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 }) => { @@ -1805,6 +1917,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); setDeletingCreationWorkId(null); setClaimingPuzzlePointIncentiveProfileId(null); + setPublishSharePayload(null); setProfilePlayStats(null); setProfilePlayStatsError(null); setIsProfilePlayStatsOpen(false); @@ -2623,6 +2736,46 @@ export function PlatformEntryFlowShellImpl({ ], ); + 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, + puzzleFlow, + resolvePuzzleErrorMessage, + runProtectedAction, + setIsPuzzleBusy, + setPuzzleError, + setSelectionStage, + ]); + const leaveAgentWorkspace = useCallback(() => { enterCreateTab(); sessionController.resetSessionViewState(); @@ -2696,34 +2849,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( @@ -2732,47 +2883,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( @@ -2781,37 +2936,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, ], ); @@ -2821,40 +2978,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, ], ); @@ -2864,37 +3023,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, ], ); @@ -3326,12 +3486,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; } @@ -3368,6 +3531,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( ( item: BigFishWorkSummary, @@ -3580,12 +3756,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) => { @@ -3906,19 +4164,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) { @@ -4209,6 +4454,7 @@ export function PlatformEntryFlowShellImpl({ isMatch3DBusy } error={publicWorkDetailError} + actionMode={selectedPublicWorkActionMode} visibleCoverCount={resolveVisiblePuzzleDetailCoverCount( selectedPublicWorkDetail, puzzleRun, @@ -4250,6 +4496,9 @@ export function PlatformEntryFlowShellImpl({ } isBusy={detailNavigation.isMutatingDetail} error={detailNavigation.detailError} + actionMode={ + detailNavigation.isSelectedWorldOwned ? 'edit' : 'remix' + } onBack={() => { detailNavigation.setDetailError(null); clearSelectedPublicWorkAuthor(); @@ -4262,9 +4511,13 @@ export function PlatformEntryFlowShellImpl({ }} onStart={handleStartSelectedWorld} onRemix={() => { - remixPublicWork( - mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry), - ); + const publicWorkEntry = + mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry); + if (detailNavigation.isSelectedWorldOwned) { + editOwnedPublicWork(publicWorkEntry); + return; + } + remixPublicWork(publicWorkEntry); }} /> ) : ( @@ -4574,6 +4827,13 @@ export function PlatformEntryFlowShellImpl({ openPublicWorkDetail( mapMatch3DWorkToPublicWorkDetail(profile), ); + openPublishShareModal({ + title: profile.gameName, + publicWorkCode: buildMatch3DPublicWorkCode( + profile.profileId, + ), + stage: 'work-detail', + }); }} onStartTestRun={(profile) => { setMatch3DProfile(profile); @@ -4840,6 +5100,11 @@ export function PlatformEntryFlowShellImpl({ onBack={() => { setSelectionStage(puzzleRuntimeReturnStage); }} + onRemodelWork={ + selectedPuzzleDetail?.publicationStatus === 'published' + ? remodelCurrentPuzzleRuntimeWork + : undefined + } onSwapPieces={(payload) => { void swapPuzzlePiecesInRun(payload); }} @@ -4980,7 +5245,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( @@ -5144,6 +5411,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/puzzleImageModelOptions.ts b/src/components/puzzle-agent/puzzleImageModelOptions.ts index f7f1d1fa..12498083 100644 --- a/src/components/puzzle-agent/puzzleImageModelOptions.ts +++ b/src/components/puzzle-agent/puzzleImageModelOptions.ts @@ -1,9 +1,7 @@ -export const PUZZLE_IMAGE_MODEL_ORIGINAL = 'original'; 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_ORIGINAL | typeof PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 | typeof PUZZLE_IMAGE_MODEL_NANOBANANA2; @@ -11,7 +9,6 @@ export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{ id: PuzzleImageModelId; label: string; }> = [ - { id: PUZZLE_IMAGE_MODEL_ORIGINAL, label: '原模型' }, { id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' }, { id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' }, ]; @@ -21,13 +18,13 @@ export function normalizePuzzleImageModel( ): PuzzleImageModelId { return ( PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === value)?.id ?? - PUZZLE_IMAGE_MODEL_ORIGINAL + 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 27c7ad72..53e15cd7 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -235,16 +235,26 @@ describe('PuzzleResultView', () => { 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: 'original', + 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({ @@ -301,6 +311,7 @@ describe('PuzzleResultView', () => { 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 }), @@ -359,14 +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: 'original', + imageModel: 'gpt-image-2', candidateCount: 1, + workTitle: '暖灯猫街作品', + workDescription: '一套雨夜猫街主题拼图。', + summary: '新关卡里有一座发光钟楼。', + themeTags: ['猫咪', '雨夜', '暖灯'], levelsJson: expect.any(String), }); @@ -460,13 +481,23 @@ 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: 'original', + imageModel: 'gpt-image-2', candidateCount: 1, + workTitle: '暖灯猫街作品', + workDescription: '一套雨夜猫街主题拼图。', + summary: '屋檐下的猫与暖灯街角。', + themeTags: ['猫咪', '雨夜', '暖灯'], levelsJson: expect.any(String), }); }); @@ -491,6 +522,12 @@ describe('PuzzleResultView', () => { fireEvent.click( within(dialog).getByRole('button', { name: /重新生成画面/u }), ); + fireEvent.click( + within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole( + 'button', + { name: '确定' }, + ), + ); expect(onExecuteAction).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index aa9a7775..aa31afca 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -27,7 +27,7 @@ import { import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; import { useAuthUi } from '../auth/AuthUiContext'; import { - PUZZLE_IMAGE_MODEL_ORIGINAL, + PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, type PuzzleImageModelId, } from '../puzzle-agent/puzzleImageModelOptions'; import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker'; @@ -56,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 [ @@ -597,11 +599,29 @@ function PuzzleLevelDetailDialog({ 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_ORIGINAL, + 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, @@ -626,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; } @@ -801,25 +874,83 @@ function PuzzleLevelDetailDialog({ ) : null} - + {isGenerationProgressVisible ? ( +
+
+
+ + 预计剩余 {generationSecondsLeft} 秒 +
+
+ ) : ( + + )}
+ {isCostConfirmOpen ? ( +
setIsCostConfirmOpen(false)} + > +
event.stopPropagation()} + > +
+ + + +
+ 确认消耗光点 +
+
+
+ 消耗 {PUZZLE_IMAGE_GENERATION_POINT_COST} 光点 +
+
+ + +
+
+
+ ) : null} + {isHistoryPickerOpen ? ( { vi.useRealTimers(); }); +test('首次点击左上返回弹出作品改造引导,保存并退出后不再重复弹出', () => { + const onBack = vi.fn(); + const onRemodelWork = vi.fn(); + window.localStorage.clear(); + + renderPuzzleRuntime( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '返回上一页' })); + + const dialog = screen.getByRole('dialog', { + name: /体验不佳?\s*试试改造功能!/u, + }); + expect(dialog).toBeTruthy(); + expect(onBack).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByRole('button', { name: '保存并退出' })); + + expect(onBack).toHaveBeenCalledTimes(1); + expect( + window.localStorage.getItem( + 'genarrative.puzzle-runtime.exit-remodel-prompt.v1:profile-1', + ), + ).toBe('1'); + + fireEvent.click(screen.getByRole('button', { name: '返回上一页' })); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(onBack).toHaveBeenCalledTimes(2); + expect(onRemodelWork).not.toHaveBeenCalled(); +}); + +test('首次退出引导的作品改造按钮进入改造流程', () => { + const onRemodelWork = vi.fn(); + window.localStorage.clear(); + + renderPuzzleRuntime( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '返回上一页' })); + fireEvent.click(screen.getByRole('button', { name: '作品改造' })); + + expect(onRemodelWork).toHaveBeenCalledTimes(1); + expect(onRemodelWork).toHaveBeenCalledWith('profile-remodel'); + expect(screen.queryByRole('dialog')).toBeNull(); +}); + test('顶部作者显示头像昵称,底部功能居中放大且不显示等待候选', () => { const runWithoutNext: PuzzleRunSnapshot = { ...clearedRun, diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx index dc518179..cec5b65e 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx @@ -41,6 +41,7 @@ type PuzzleRuntimeShellProps = { isBusy?: boolean; error?: string | null; onBack: () => void; + onRemodelWork?: (profileId: string) => void | Promise; onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void; onDragPiece: (payload: DragPuzzlePieceRequest) => void; onAdvanceNextLevel: (target?: PuzzleNextLevelTarget) => void; @@ -208,6 +209,61 @@ const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500; const PUZZLE_MERGE_FLASH_DURATION_MS = 720; const PUZZLE_HINT_DEMO_DURATION_MS = 1_250; const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12; +const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX = + 'genarrative.puzzle-runtime.exit-remodel-prompt.v1'; + +const shownExitRemodelPromptProfileIds = new Set(); + +function buildExitRemodelPromptStorageKey(profileId: string) { + return `${PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX}:${encodeURIComponent( + profileId, + )}`; +} + +function hasSeenExitRemodelPrompt(profileId: string) { + const normalizedProfileId = profileId.trim(); + if (!normalizedProfileId) { + return true; + } + if (shownExitRemodelPromptProfileIds.has(normalizedProfileId)) { + if (typeof window === 'undefined') { + return true; + } + } + + try { + const seen = + window.localStorage.getItem( + buildExitRemodelPromptStorageKey(normalizedProfileId), + ) === '1'; + if (seen) { + shownExitRemodelPromptProfileIds.add(normalizedProfileId); + } + return seen; + } catch { + return shownExitRemodelPromptProfileIds.has(normalizedProfileId); + } +} + +function markExitRemodelPromptSeen(profileId: string) { + const normalizedProfileId = profileId.trim(); + if (!normalizedProfileId) { + return; + } + shownExitRemodelPromptProfileIds.add(normalizedProfileId); + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem( + buildExitRemodelPromptStorageKey(normalizedProfileId), + '1', + ); + } catch { + // 中文注释:隐私模式下 localStorage 可能不可写,内存集合足够兜底本次挂载周期。 + } +} type PuzzlePropDialogState = { propKind: PuzzleRuntimePropKind; @@ -251,6 +307,7 @@ export function PuzzleRuntimeShell({ isBusy = false, error = null, onBack, + onRemodelWork, onSwapPieces, onDragPiece, onAdvanceNextLevel, @@ -263,6 +320,8 @@ export function PuzzleRuntimeShell({ const authUi = useAuthUi(); const [selectedPieceId, setSelectedPieceId] = useState(null); const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); + const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] = + useState(false); const [propDialog, setPropDialog] = useState( null, ); @@ -621,7 +680,10 @@ export function PuzzleRuntimeShell({ }, [onTimeExpired]); const isUiPauseActive = - isSettingsPanelOpen || Boolean(propDialog) || isOriginalOverlayVisible; + isSettingsPanelOpen || + isExitRemodelPromptOpen || + Boolean(propDialog) || + isOriginalOverlayVisible; useEffect(() => { if (previousUiPauseActiveRef.current === isUiPauseActive) { @@ -898,6 +960,7 @@ export function PuzzleRuntimeShell({ const authorAvatarLabel = resolveAuthorAvatarLabel( currentLevel.authorDisplayName, ); + const exitPromptProfileId = currentLevel.profileId.trim(); const leaderboardEntries = (currentLevel.leaderboardEntries ?? []).length > 0 ? currentLevel.leaderboardEntries @@ -909,6 +972,20 @@ export function PuzzleRuntimeShell({ const isInteractionLocked = isBusy || runtimeStatus !== 'playing' || Boolean(propDialog); + const handleBackRequest = () => { + if ( + onRemodelWork && + exitPromptProfileId && + !hasSeenExitRemodelPrompt(exitPromptProfileId) + ) { + markExitRemodelPromptSeen(exitPromptProfileId); + setIsExitRemodelPromptOpen(true); + return; + } + + onBack(); + }; + const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => { const canOpen = propKind === 'extendTime' @@ -1016,7 +1093,7 @@ export function PuzzleRuntimeShell({
) : null} + {isExitRemodelPromptOpen ? ( +
+
event.stopPropagation()} + > +
+

+ 体验不佳? +
+ 试试改造功能! +

+
+
+ + +
+
+
+ ) : null} + {runtimeStatus === 'failed' ? (
{ + const user = userEvent.setup(); + const ownedPuzzleWork = { + workId: 'puzzle-work-owned-1', + profileId: 'puzzle-profile-owned-1', + ownerUserId: mockAuthUser.id, + sourceSessionId: 'puzzle-session-1', + authorDisplayName: mockAuthUser.displayName, + levelName: '星桥机关', + summary: '旋转碎片并接通星桥机关。', + themeTags: ['机关', '星桥'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-04-25T09:00:00.000Z', + publishedAt: '2026-04-25T09:00:00.000Z', + playCount: 3, + remixCount: 0, + likeCount: 0, + publishReady: true, + } satisfies PuzzleWorkSummary; + + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [ownedPuzzleWork], + }); + vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ + item: ownedPuzzleWork, + }); + + render(); + + await waitFor(() => { + expect(screen.getAllByText('星桥机关').length).toBeGreaterThan(0); + }); + + const workCards = screen.getAllByRole('button', { name: /星桥机关/u }); + await user.click(workCards[0]!); + expect(await screen.findByText('详情')).toBeTruthy(); + expect(screen.getByRole('button', { name: '作品编辑' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull(); + + await user.click(screen.getByRole('button', { name: '作品编辑' })); + + expect(getPuzzleAgentSession).toHaveBeenCalledWith('puzzle-session-1'); + expect(remixPuzzleGalleryWork).not.toHaveBeenCalled(); + expect(await screen.findByText('拼图结果页')).toBeTruthy(); +}); + test('logged out public detail gates big fish start before local runtime', async () => { const user = userEvent.setup(); const requireAuth = vi.fn(); @@ -2496,6 +2586,13 @@ test('published puzzle detail returns to the ranking platform tab', async () => expect(await screen.findByTestId('puzzle-board')).toBeTruthy(); await user.click(screen.getByRole('button', { name: '返回上一页' })); + await user.click( + within( + await screen.findByRole('dialog', { + name: /体验不佳?\s*试试改造功能!/u, + }), + ).getByRole('button', { name: '保存并退出' }), + ); await waitFor(() => { expect(screen.getByRole('button', { name: '启动' })).toBeTruthy(); @@ -2983,6 +3080,98 @@ test('formal puzzle next level uses backend run and leaderboard keeps frontend l expect(screen.getByText('测试玩家')).toBeTruthy(); }); +test('first puzzle runtime back click can open remix result page', async () => { + const user = userEvent.setup(); + const puzzleWork: PuzzleWorkSummary = { + workId: 'puzzle-work-public-1', + profileId: 'puzzle-profile-public-1', + ownerUserId: 'user-2', + sourceSessionId: null, + authorDisplayName: '拼图作者', + levelName: '雨夜猫塔', + summary: '一张聚焦发光猫咪与遗迹台阶的雨夜拼图。', + themeTags: ['雨夜', '猫咪', '遗迹'], + coverImageSrc: null, + coverAssetId: null, + publicationStatus: 'published', + updatedAt: '2026-04-25T12:10:00.000Z', + publishedAt: '2026-04-25T12:10:00.000Z', + playCount: 8, + remixCount: 0, + likeCount: 0, + publishReady: true, + }; + const anchorPack = buildPuzzleAnchorPack(); + const remixDraft: PuzzleResultDraft = { + workTitle: '改造后的雨夜猫塔', + workDescription: '准备改造的拼图草稿。', + levelName: '改造后的雨夜猫塔', + summary: '一只猫站在雨夜塔顶。', + themeTags: ['雨夜', '猫咪', '塔'], + forbiddenDirectives: [], + creatorIntent: null, + anchorPack, + candidates: [], + selectedCandidateId: null, + coverImageSrc: null, + coverAssetId: null, + generationStatus: 'idle', + levels: [], + metadata: null, + }; + const remixSession: PuzzleAgentSessionSnapshot = { + sessionId: 'puzzle-session-remix-1', + currentTurn: 1, + progressPercent: 100, + stage: 'ready_to_publish', + anchorPack, + draft: remixDraft, + messages: [], + lastAssistantReply: null, + publishedProfileId: null, + suggestedActions: [], + resultPreview: null, + updatedAt: '2026-04-25T12:12:00.000Z', + }; + + vi.mocked(listPuzzleGallery).mockResolvedValue({ + items: [puzzleWork], + }); + vi.mocked(getPuzzleGalleryDetail).mockResolvedValue({ + item: puzzleWork, + }); + vi.mocked(startPuzzleRun).mockResolvedValue({ + run: buildMockPuzzleRun(puzzleWork.profileId, puzzleWork.levelName), + }); + vi.mocked(remixPuzzleGalleryWork).mockResolvedValue({ + session: remixSession, + }); + + render(); + + const searchInput = await screen.findByPlaceholderText( + '搜索作品号、名称、作者、描述', + ); + await user.type(searchInput, 'PZ-EPUBLIC1'); + await user.click(screen.getByRole('button', { name: '搜索' })); + await user.click(await screen.findByRole('button', { name: '启动' })); + expect(await screen.findByTestId('puzzle-board')).toBeTruthy(); + await user.click(await screen.findByRole('button', { name: '返回上一页' })); + + const dialog = await screen.findByRole('dialog', { + name: /体验不佳?\s*试试改造功能!/u, + }); + await user.click(within(dialog).getByRole('button', { name: '作品改造' })); + + await waitFor(() => { + expect(remixPuzzleGalleryWork).toHaveBeenCalledWith( + 'puzzle-profile-public-1', + ); + }); + expect(await screen.findByText('拼图结果页')).toBeTruthy(); + expect(screen.getByDisplayValue('改造后的雨夜猫塔')).toBeTruthy(); +}); + test('public code search opens a published puzzle by PZ code', async () => { const user = userEvent.setup(); const puzzleWork: PuzzleWorkSummary = { diff --git a/src/components/rpg-entry/rpgEntryWorldPresentation.ts b/src/components/rpg-entry/rpgEntryWorldPresentation.ts index 2ce66206..646dbf4a 100644 --- a/src/components/rpg-entry/rpgEntryWorldPresentation.ts +++ b/src/components/rpg-entry/rpgEntryWorldPresentation.ts @@ -29,6 +29,7 @@ export type PlatformPuzzleGalleryCard = { sourceType: 'puzzle'; workId: string; profileId: string; + sourceSessionId?: string | null; publicWorkCode: string; ownerUserId: string; authorDisplayName: string; @@ -78,6 +79,7 @@ export type PlatformMatch3DGalleryCard = { sourceType: 'match3d'; workId: string; profileId: string; + sourceSessionId?: string | null; publicWorkCode: string; ownerUserId: string; authorDisplayName: string; @@ -132,6 +134,7 @@ export function mapPuzzleWorkToPlatformGalleryCard( sourceType: 'puzzle', workId: work.workId, profileId: work.profileId, + sourceSessionId: work.sourceSessionId ?? null, publicWorkCode: buildPuzzlePublicWorkCode(work.profileId), ownerUserId: work.ownerUserId, authorDisplayName: work.authorDisplayName, @@ -158,6 +161,7 @@ export function mapMatch3DWorkToPlatformGalleryCard( sourceType: 'match3d', workId: work.workId, profileId: work.profileId, + sourceSessionId: work.sourceSessionId ?? null, publicWorkCode: buildMatch3DPublicWorkCode(work.profileId), ownerUserId: work.ownerUserId, authorDisplayName: '玩家',