master #14
@@ -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. 草稿与已发布作品都通过后端聚合接口返回,前端不自己拼数据来源。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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. 未登录用户点击“作品改造”仍先触发登录拦截。
|
||||
@@ -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` 重启验证。
|
||||
|
||||
@@ -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. 发布、作品测试、自动保存作品名称、作品描述、作品标签和关卡列表仍可用。
|
||||
|
||||
@@ -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. 基础单块和合并块都能看到圆角,合并块的外凸角与内凹角都不是直角,且图片不会溢出圆角裁剪。
|
||||
|
||||
@@ -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. 不影响设置面板里的返回按钮、失败续时、通关结算和下一关入口。
|
||||
@@ -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 卡死的修复口径。
|
||||
|
||||
@@ -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` 重启后端。
|
||||
56
docs/technical/WORK_PUBLISH_SHARE_PANEL_2026-05-02.md
Normal file
56
docs/technical/WORK_PUBLISH_SHARE_PANEL_2026-05-02.md
Normal file
@@ -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 表结构。
|
||||
@@ -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 对象。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"}}
|
||||
@@ -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 对象。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"}}
|
||||
@@ -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 对象。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
LLM 请求超时,累计尝试 2 次
|
||||
@@ -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;
|
||||
}
|
||||
| {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -19,11 +19,45 @@ pub(crate) async fn execute_billable_asset_operation<T, Fut>(
|
||||
where
|
||||
Fut: Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
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<T, Fut>(
|
||||
state: &AppState,
|
||||
owner_user_id: &str,
|
||||
asset_kind: &str,
|
||||
asset_id: &str,
|
||||
points_cost: u64,
|
||||
operation: Fut,
|
||||
) -> Result<T, AppError>
|
||||
where
|
||||
Fut: Future<Output = Result<T, AppError>>,
|
||||
{
|
||||
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<bool, AppError> {
|
||||
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()),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<Value>(&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
|
||||
};
|
||||
|
||||
|
||||
@@ -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<CustomWorldFoundationDraftResult, String> {
|
||||
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<F>(
|
||||
repair_prompt_builder: F,
|
||||
repair_debug_label: &str,
|
||||
empty_response_message: &str,
|
||||
enable_web_search: bool,
|
||||
) -> Result<JsonValue, String>
|
||||
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<platform_llm::LlmTextResponse, String> {
|
||||
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<platform_llm::LlmTextResponse, platform_llm::LlmError> {
|
||||
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<Vec<JsonValue>, 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<Vec<JsonValue>, 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<Vec<JsonValue>, 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::<String>::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::<JsonValue>(&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<Mutex<Vec<String>>>,
|
||||
response_bodies: Vec<String>,
|
||||
) -> 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<Mutex<Vec<String>>>,
|
||||
responses: Vec<MockHttpResponse>,
|
||||
) -> 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())
|
||||
|
||||
@@ -34,6 +34,10 @@ impl AppError {
|
||||
self.code
|
||||
}
|
||||
|
||||
pub fn status_code(&self) -> StatusCode {
|
||||
self.status_code
|
||||
}
|
||||
|
||||
pub fn message(&self) -> &str {
|
||||
&self.message
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
65
src/components/common/PublishShareModal.test.tsx
Normal file
65
src/components/common/PublishShareModal.test.tsx
Normal file
@@ -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(
|
||||
<PublishShareModal open payload={payload} onClose={() => {}} />,
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
145
src/components/common/PublishShareModal.tsx
Normal file
145
src/components/common/PublishShareModal.tsx
Normal file
@@ -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<number | null>(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 (
|
||||
<UnifiedModal
|
||||
open={open && Boolean(payload)}
|
||||
title="分享给朋友"
|
||||
onClose={onClose}
|
||||
size="sm"
|
||||
panelClassName="platform-remap-surface"
|
||||
bodyClassName="space-y-4 px-4 py-4 sm:px-5 sm:py-5"
|
||||
footerClassName="justify-center border-t-0 px-4 pb-5 pt-0 sm:px-5"
|
||||
footer={
|
||||
<div className="grid w-full grid-cols-3 gap-3">
|
||||
{SHARE_CHANNELS.map((channel) => {
|
||||
const Icon = channel.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={channel.id}
|
||||
type="button"
|
||||
onClick={copyShareText}
|
||||
className="flex min-w-0 flex-col items-center gap-2 rounded-[1rem] px-2 py-2.5 text-xs font-bold text-[var(--platform-text-base)] transition hover:bg-white/62"
|
||||
aria-label={`分享到${channel.label}`}
|
||||
title={channel.label}
|
||||
>
|
||||
<span
|
||||
className={`inline-flex h-11 w-11 items-center justify-center rounded-full shadow-sm ${channel.className}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</span>
|
||||
<span>{channel.label}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="rounded-[1.25rem] border border-[var(--platform-subpanel-border)] bg-white/72 p-4">
|
||||
<div className="whitespace-pre-wrap break-words text-sm leading-6 text-[var(--platform-text-strong)]">
|
||||
{shareText}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={copyShareText}
|
||||
disabled={!shareText}
|
||||
className="platform-button platform-button--primary w-full justify-center gap-2 disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{copyState === 'copied' ? (
|
||||
<Check className="h-4 w-4" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
{copyState === 'copied'
|
||||
? '已复制'
|
||||
: copyState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享'}
|
||||
</button>
|
||||
</UnifiedModal>
|
||||
);
|
||||
}
|
||||
30
src/components/common/publishShareModalModel.ts
Normal file
30
src/components/common/publishShareModalModel.ts
Normal file
@@ -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,
|
||||
})}`;
|
||||
}
|
||||
@@ -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(
|
||||
<CustomWorldCreationHub
|
||||
items={[]}
|
||||
puzzleItems={[
|
||||
{
|
||||
workId: 'puzzle:work-delete',
|
||||
profileId: 'puzzle-profile-delete',
|
||||
ownerUserId: 'user-1',
|
||||
authorDisplayName: '拼图作者',
|
||||
levelName: '待删拼图',
|
||||
summary: '已发布作品也可以从创作页删除。',
|
||||
themeTags: ['灯塔'],
|
||||
coverImageSrc: null,
|
||||
publicationStatus: 'published',
|
||||
updatedAt: new Date('2026-05-02T12:00:00.000Z').toISOString(),
|
||||
publishedAt: new Date('2026-05-02T12:10:00.000Z').toISOString(),
|
||||
playCount: 8,
|
||||
remixCount: 2,
|
||||
likeCount: 1,
|
||||
publishReady: true,
|
||||
},
|
||||
]}
|
||||
loading={false}
|
||||
error={null}
|
||||
onRetry={() => {}}
|
||||
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[] = [];
|
||||
|
||||
@@ -268,68 +268,70 @@ export function CustomWorldWorkCard({
|
||||
<div className="absolute inset-0 bg-[var(--platform-card-overlay-strong)]" />
|
||||
<div className="absolute inset-0 bg-[radial-gradient(circle_at_100%_100%,rgba(255,255,255,0.18),transparent_34%),linear-gradient(180deg,rgba(255,255,255,0.08),rgba(0,0,0,0.08))]" />
|
||||
<div className="pointer-events-none relative z-20 flex min-h-[8rem] flex-col sm:min-h-[10.5rem] xl:min-h-[9.75rem]">
|
||||
{!isPublished && onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={deleteBusy}
|
||||
aria-label={deleteBusy ? '删除中' : '删除'}
|
||||
title={deleteBusy ? '删除中' : '删除作品'}
|
||||
className="pointer-events-auto absolute right-0 top-0 z-30 grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
|
||||
>
|
||||
{deleteBusy ? (
|
||||
<span className="text-xs leading-none">…</span>
|
||||
) : (
|
||||
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{isPublished ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copyShareText();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={!item.canShare || !item.sharePath}
|
||||
title={
|
||||
!item.canShare || !item.sharePath
|
||||
? '暂不可分享'
|
||||
: shareState === 'copied'
|
||||
? '已复制'
|
||||
: shareState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享作品'
|
||||
}
|
||||
aria-label={
|
||||
!item.canShare || !item.sharePath
|
||||
? '暂不可分享'
|
||||
: shareState === 'copied'
|
||||
? '分享内容已复制'
|
||||
: shareState === 'failed'
|
||||
? '分享内容复制失败'
|
||||
: '分享'
|
||||
}
|
||||
className="pointer-events-auto absolute right-0 top-0 z-30 inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
|
||||
>
|
||||
{shareState === 'idle' ? (
|
||||
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold leading-none">
|
||||
{shareState === 'copied' ? '已复制' : '复制失败'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
<div className="pointer-events-auto absolute right-0 top-0 z-30 flex items-center gap-1">
|
||||
{onDelete ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={deleteBusy}
|
||||
aria-label={deleteBusy ? '删除中' : '删除'}
|
||||
title={deleteBusy ? '删除中' : '删除作品'}
|
||||
className="grid h-7 w-7 place-items-center text-[var(--platform-text-soft)] transition hover:text-[var(--platform-button-danger-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:w-8"
|
||||
>
|
||||
{deleteBusy ? (
|
||||
<span className="text-xs leading-none">…</span>
|
||||
) : (
|
||||
<Trash2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
{isPublished ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
copyShareText();
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
}}
|
||||
disabled={!item.canShare || !item.sharePath}
|
||||
title={
|
||||
!item.canShare || !item.sharePath
|
||||
? '暂不可分享'
|
||||
: shareState === 'copied'
|
||||
? '已复制'
|
||||
: shareState === 'failed'
|
||||
? '复制失败'
|
||||
: '分享作品'
|
||||
}
|
||||
aria-label={
|
||||
!item.canShare || !item.sharePath
|
||||
? '暂不可分享'
|
||||
: shareState === 'copied'
|
||||
? '分享内容已复制'
|
||||
: shareState === 'failed'
|
||||
? '分享内容复制失败'
|
||||
: '分享'
|
||||
}
|
||||
className="inline-flex h-7 min-w-7 items-center justify-center gap-1 whitespace-nowrap px-1.5 text-[var(--platform-text-soft)] transition hover:text-[var(--platform-cool-text)] disabled:cursor-not-allowed disabled:opacity-55 sm:h-8 sm:min-w-8"
|
||||
>
|
||||
{shareState === 'idle' ? (
|
||||
<Share2 aria-hidden="true" className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<span className="text-[10px] font-semibold leading-none">
|
||||
{shareState === 'copied' ? '已复制' : '复制失败'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-2 pr-12 sm:gap-3 sm:pr-14">
|
||||
<div className="flex max-h-[3rem] min-w-0 flex-wrap gap-1 overflow-hidden sm:max-h-none sm:gap-2">
|
||||
|
||||
@@ -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<PuzzleSaveArchiveState>
|
||||
@@ -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<DeleteCreationWorkConfirmation | null>(null);
|
||||
const [
|
||||
claimingPuzzlePointIncentiveProfileId,
|
||||
setClaimingPuzzlePointIncentiveProfileId,
|
||||
] = useState<string | null>(null);
|
||||
const [publishSharePayload, setPublishSharePayload] =
|
||||
useState<PublishShareModalPayload | null>(null);
|
||||
const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish');
|
||||
const [profilePlayStats, setProfilePlayStats] =
|
||||
useState<ProfilePlayStatsResponse | null>(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({
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<PublishShareModal
|
||||
open={Boolean(publishSharePayload)}
|
||||
payload={publishSharePayload}
|
||||
onClose={() => setPublishSharePayload(null)}
|
||||
/>
|
||||
<UnifiedModal
|
||||
open={Boolean(pendingDeleteCreationWork)}
|
||||
title="删除作品"
|
||||
description={
|
||||
pendingDeleteCreationWork
|
||||
? `确认删除《${pendingDeleteCreationWork.title}》吗?`
|
||||
: undefined
|
||||
}
|
||||
onClose={closeDeleteCreationWorkConfirmation}
|
||||
closeDisabled={Boolean(deletingCreationWorkId)}
|
||||
closeOnBackdrop={!deletingCreationWorkId}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeDeleteCreationWorkConfirmation}
|
||||
disabled={Boolean(deletingCreationWorkId)}
|
||||
className="platform-button platform-button--ghost min-h-0 rounded-full px-4 py-2 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={confirmDeleteCreationWork}
|
||||
disabled={Boolean(deletingCreationWorkId)}
|
||||
className="platform-button platform-button--danger min-h-0 rounded-full px-4 py-2 text-sm disabled:cursor-not-allowed disabled:opacity-55"
|
||||
>
|
||||
{deletingCreationWorkId ? '删除中' : '确认删除'}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="text-sm leading-6 text-[var(--platform-text-base)]">
|
||||
{pendingDeleteCreationWork?.detail}
|
||||
</div>
|
||||
</UnifiedModal>
|
||||
<AnimatePresence>
|
||||
{(searchedPublicUser || publicSearchError) && (
|
||||
<motion.div
|
||||
|
||||
@@ -122,6 +122,24 @@ test('PlatformWorkDetailView calls like handler', () => {
|
||||
expect(onLike).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView switches remix action label for owned work edit', () => {
|
||||
render(
|
||||
<PlatformWorkDetailView
|
||||
entry={createPuzzleEntry()}
|
||||
actionMode="edit"
|
||||
isBusy={false}
|
||||
error={null}
|
||||
onBack={vi.fn()}
|
||||
onLike={vi.fn()}
|
||||
onStart={vi.fn()}
|
||||
onRemix={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('button', { name: '作品编辑' })).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull();
|
||||
});
|
||||
|
||||
test('PlatformWorkDetailView cycles puzzle level cover slides', () => {
|
||||
vi.useFakeTimers();
|
||||
const { container } = render(
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
<GitFork className="h-5 w-5" />
|
||||
作品改造
|
||||
<WorkActionIcon className="h-5 w-5" />
|
||||
{workActionLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -100,8 +100,9 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
|
||||
workDescription: '一套雨夜猫街主题拼图。',
|
||||
pictureDescription: '一只猫在雨夜灯牌下回头。',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'original',
|
||||
imageModel: 'gpt-image-2',
|
||||
});
|
||||
expect(screen.getByText('消耗2光点')).toBeTruthy();
|
||||
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
|
||||
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
|
||||
});
|
||||
@@ -130,7 +131,7 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
|
||||
workDescription: '雾港遗迹拼图',
|
||||
pictureDescription: '潮雾中的灯塔与断桥',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'original',
|
||||
imageModel: 'gpt-image-2',
|
||||
candidateCount: 1,
|
||||
});
|
||||
});
|
||||
@@ -158,6 +159,7 @@ test('puzzle workspace switches the image model from the description box', () =>
|
||||
target: { value: '一只猫在雨夜灯牌下回头。' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '图片模型' }));
|
||||
expect(screen.queryByRole('menuitemradio', { name: '原模型' })).toBeNull();
|
||||
fireEvent.click(screen.getByRole('menuitemradio', { name: 'nanobanana2' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /生成草稿/u }));
|
||||
|
||||
@@ -243,6 +245,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
|
||||
workDescription: '旧街雨夜的拼图草稿。',
|
||||
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
|
||||
referenceImageSrc: null,
|
||||
imageModel: 'original',
|
||||
imageModel: 'gpt-image-2',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
|
||||
import {
|
||||
normalizePuzzleImageModel,
|
||||
PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
type PuzzleImageModelId,
|
||||
} from './puzzleImageModelOptions';
|
||||
import { PuzzleImageModelPicker } from './PuzzleImageModelPicker';
|
||||
@@ -42,7 +42,7 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
|
||||
pictureDescription: '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
imageModel: PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
};
|
||||
|
||||
function resolveInitialFormState(
|
||||
@@ -97,7 +97,7 @@ function resolveInitialFormState(
|
||||
session.draft?.summary || session.anchorPack.visualSubject.value || '',
|
||||
referenceImageSrc: '',
|
||||
referenceImageLabel: '',
|
||||
imageModel: PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -439,7 +439,10 @@ export function PuzzleAgentWorkspace({
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
生成草稿
|
||||
<span>生成草稿</span>
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗2光点
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<PuzzleImageModelId>(
|
||||
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<HTMLInputElement>,
|
||||
@@ -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({
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
onGenerate(
|
||||
level.levelId,
|
||||
level.pictureDescription.trim() || undefined,
|
||||
referenceImageSrc || undefined,
|
||||
imageModel,
|
||||
);
|
||||
}}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
{isBusy ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
|
||||
<Sparkles className="h-4 w-4" />
|
||||
{hasFormalImage ? '重新生成画面' : '生成画面'}
|
||||
</button>
|
||||
{isGenerationProgressVisible ? (
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label="画面生成进度"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={generationProgressPercent}
|
||||
className="platform-progress-track relative h-12 overflow-hidden rounded-full"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-amber-600 transition-[width] duration-300"
|
||||
style={{ width: `${generationProgressPercent}%` }}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center gap-2 px-4 text-sm font-bold text-white">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
预计剩余 {generationSecondsLeft} 秒
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => setIsCostConfirmOpen(true)}
|
||||
className="inline-flex w-full items-center justify-center gap-2 rounded-full bg-amber-600 px-4 py-3 text-sm font-bold text-white disabled:opacity-45"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
<span>{hasFormalImage ? '重新生成画面' : '生成画面'}</span>
|
||||
<span className="rounded-full bg-white/24 px-2 py-0.5 text-[11px] font-bold">
|
||||
消耗{PUZZLE_IMAGE_GENERATION_POINT_COST}光点
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isCostConfirmOpen ? (
|
||||
<div
|
||||
className="absolute inset-0 z-20 flex items-center justify-center bg-black/45 p-4 backdrop-blur-sm"
|
||||
onClick={() => setIsCostConfirmOpen(false)}
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="确认消耗光点"
|
||||
className="platform-modal-shell platform-remap-surface w-full max-w-sm overflow-hidden rounded-[1.5rem] shadow-[0_24px_80px_rgba(0,0,0,0.45)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center gap-3 border-b border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<span className="inline-flex h-9 w-9 items-center justify-center rounded-full bg-amber-100 text-amber-700">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
</span>
|
||||
<div className="text-base font-semibold text-[var(--platform-text-strong)]">
|
||||
确认消耗光点
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5 py-4 text-sm font-semibold text-[var(--platform-text-base)]">
|
||||
消耗 {PUZZLE_IMAGE_GENERATION_POINT_COST} 光点
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-3 border-t border-[var(--platform-subpanel-border)] px-5 py-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCostConfirmOpen(false)}
|
||||
className="platform-button platform-button--ghost min-h-10 px-4 py-2 text-sm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={executeGeneration}
|
||||
className="platform-button platform-button--primary min-h-10 px-5 py-2 text-sm"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isHistoryPickerOpen ? (
|
||||
<PuzzleHistoryAssetPickerDialog
|
||||
isBusy={isBusy}
|
||||
@@ -1420,8 +1551,12 @@ export function PuzzleResultView({
|
||||
levelId,
|
||||
promptText,
|
||||
referenceImageSrc,
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_ORIGINAL,
|
||||
imageModel: imageModel ?? PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
|
||||
candidateCount: 1,
|
||||
workTitle: editState.workTitle.trim(),
|
||||
workDescription: editState.workDescription.trim(),
|
||||
summary: activeLevel.pictureDescription.trim(),
|
||||
themeTags: editState.themeTags,
|
||||
levelsJson: JSON.stringify(editState.levels),
|
||||
});
|
||||
}}
|
||||
|
||||
@@ -171,6 +171,75 @@ test('通关后显示结算弹窗、排行榜和下一关按钮', () => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test('首次点击左上返回弹出作品改造引导,保存并退出后不再重复弹出', () => {
|
||||
const onBack = vi.fn();
|
||||
const onRemodelWork = vi.fn();
|
||||
window.localStorage.clear();
|
||||
|
||||
renderPuzzleRuntime(
|
||||
<PuzzleRuntimeShell
|
||||
run={clearedRun}
|
||||
onBack={onBack}
|
||||
onRemodelWork={onRemodelWork}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<PuzzleRuntimeShell
|
||||
run={{
|
||||
...clearedRun,
|
||||
currentLevel: {
|
||||
...clearedRun.currentLevel!,
|
||||
profileId: 'profile-remodel',
|
||||
},
|
||||
}}
|
||||
onBack={vi.fn()}
|
||||
onRemodelWork={onRemodelWork}
|
||||
onSwapPieces={vi.fn()}
|
||||
onDragPiece={vi.fn()}
|
||||
onAdvanceNextLevel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
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,
|
||||
|
||||
@@ -41,6 +41,7 @@ type PuzzleRuntimeShellProps = {
|
||||
isBusy?: boolean;
|
||||
error?: string | null;
|
||||
onBack: () => void;
|
||||
onRemodelWork?: (profileId: string) => void | Promise<void>;
|
||||
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<string>();
|
||||
|
||||
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<string | null>(null);
|
||||
const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false);
|
||||
const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] =
|
||||
useState(false);
|
||||
const [propDialog, setPropDialog] = useState<PuzzlePropDialogState | null>(
|
||||
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({
|
||||
<div className="grid grid-cols-[2.75rem_minmax(0,1fr)_2.75rem] items-start gap-2 sm:gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
onClick={handleBackRequest}
|
||||
aria-label="返回上一页"
|
||||
className="inline-flex h-11 w-11 items-center justify-center rounded-full bg-black/30 backdrop-blur"
|
||||
>
|
||||
@@ -1664,6 +1741,54 @@ export function PuzzleRuntimeShell({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isExitRemodelPromptOpen ? (
|
||||
<div
|
||||
className="absolute inset-0 z-50 flex items-center justify-center bg-slate-950/72 px-4 py-6 backdrop-blur-sm"
|
||||
>
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="puzzle-exit-remodel-title"
|
||||
className="flex w-full max-w-[22rem] flex-col overflow-hidden rounded-[1.5rem] border border-white/14 bg-slate-950/94 shadow-[0_28px_90px_rgba(0,0,0,0.5)]"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="px-5 pt-6 text-center">
|
||||
<h2
|
||||
id="puzzle-exit-remodel-title"
|
||||
className="text-2xl font-black leading-tight text-white"
|
||||
>
|
||||
体验不佳?
|
||||
<br />
|
||||
试试改造功能!
|
||||
</h2>
|
||||
</header>
|
||||
<footer className="grid gap-3 px-5 py-5">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
onClick={() => {
|
||||
setIsExitRemodelPromptOpen(false);
|
||||
void onRemodelWork?.(exitPromptProfileId);
|
||||
}}
|
||||
className="rounded-full bg-amber-200 px-5 py-3 text-sm font-black text-slate-950 transition hover:bg-amber-100 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
作品改造
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsExitRemodelPromptOpen(false);
|
||||
onBack();
|
||||
}}
|
||||
className="rounded-full border border-white/14 bg-black/24 px-5 py-3 text-sm font-black text-white transition hover:bg-white/10"
|
||||
>
|
||||
保存并退出
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{runtimeStatus === 'failed' ? (
|
||||
<div className="absolute inset-0 z-40 flex items-center justify-center bg-slate-950/68 px-4 py-6 backdrop-blur-sm">
|
||||
<section
|
||||
|
||||
@@ -15,6 +15,13 @@ import type {
|
||||
} from '../../../packages/shared/src/contracts/match3dAgent';
|
||||
import type { Match3DRunSnapshot } from '../../../packages/shared/src/contracts/match3dRuntime';
|
||||
import type { Match3DWorkSummary } from '../../../packages/shared/src/contracts/match3dWorks';
|
||||
import type {
|
||||
PuzzleAnchorPack,
|
||||
PuzzleResultDraft,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentDraft';
|
||||
import type {
|
||||
PuzzleAgentSessionSnapshot,
|
||||
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
|
||||
import type { PuzzleRunSnapshot } from '../../../packages/shared/src/contracts/puzzleRuntimeSession';
|
||||
import type { PuzzleWorkSummary } from '../../../packages/shared/src/contracts/puzzleWorkSummary';
|
||||
import type { RpgCreationResultView } from '../../../packages/shared/src/contracts/rpgCreationResultView';
|
||||
@@ -619,6 +626,41 @@ function buildMockPuzzleRun(
|
||||
};
|
||||
}
|
||||
|
||||
function buildPuzzleAnchorPack(): PuzzleAnchorPack {
|
||||
return {
|
||||
themePromise: {
|
||||
key: 'themePromise',
|
||||
label: '题材承诺',
|
||||
value: '雨夜拼图',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualSubject: {
|
||||
key: 'visualSubject',
|
||||
label: '画面主体',
|
||||
value: '雨夜猫塔',
|
||||
status: 'confirmed',
|
||||
},
|
||||
visualMood: {
|
||||
key: 'visualMood',
|
||||
label: '视觉气质',
|
||||
value: '暖灯',
|
||||
status: 'confirmed',
|
||||
},
|
||||
compositionHooks: {
|
||||
key: 'compositionHooks',
|
||||
label: '拼图记忆点',
|
||||
value: '灯塔与猫',
|
||||
status: 'confirmed',
|
||||
},
|
||||
tagsAndForbidden: {
|
||||
key: 'tagsAndForbidden',
|
||||
label: '标签与禁忌',
|
||||
value: '雨夜、猫咪、塔',
|
||||
status: 'confirmed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildClearedPuzzleRun(params: {
|
||||
runId: string;
|
||||
entryProfileId: string;
|
||||
@@ -2198,6 +2240,54 @@ test('logged out public detail gates puzzle start and remix before real actions'
|
||||
expect(remixPuzzleGalleryWork).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('owned public puzzle detail edits original draft instead of remixing', async () => {
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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(<TestWrapper withAuth />);
|
||||
|
||||
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 = {
|
||||
|
||||
@@ -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: '玩家',
|
||||
|
||||
Reference in New Issue
Block a user