4 Commits

Author SHA1 Message Date
5b1fa72ad7 Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative
Some checks failed
CI / verify (push) Has been cancelled
2026-05-02 17:57:02 +08:00
acc55d0e13 1 2026-05-02 17:56:42 +08:00
2311edb2e6 Merge branch 'master' of http://82.157.175.59:3000/GenarrativeAI/Genarrative 2026-05-02 16:22:12 +08:00
33dd105630 1 2026-05-01 22:16:01 +08:00
60 changed files with 4328 additions and 906 deletions

View File

@@ -119,6 +119,11 @@ RPG_LLM_WEB_SEARCH_ENABLED="true"
DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1"
DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY"
# Server-side APIMart image generation config for optional puzzle image models.
APIMART_BASE_URL="https://api.apimart.ai/v1"
APIMART_API_KEY="YOUR_APIMART_API_KEY"
APIMART_IMAGE_REQUEST_TIMEOUT_MS="180000"
# 阿里云 OSS 配置。
# Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量,
# 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。

View File

@@ -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. 草稿与已发布作品都通过后端聚合接口返回,前端不自己拼数据来源。
---

View File

@@ -0,0 +1,33 @@
# 新建作品入口配置说明 2026-05-01
## 背景
创作中心顶部“新建作品”入口和平台创作类型弹层都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。
## 落地规则
1. 新建作品入口配置统一放在 `src/config/newWorkEntryConfig.ts`
2. `visible` 控制玩法是否展示在新建作品入口和创作类型弹层中。
3. `open` 控制玩法是否允许点击创建;`open: false` 时入口保持展示但禁用。
4. `title``subtitle``badge` 控制玩法卡片文案。
5. `startCard` 控制创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案。
6. `typeModal` 控制平台创作类型弹层标题和描述。
7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。
## 当前状态
| 玩法 | 展示 | 开放 | 说明 |
| --- | --- | --- | --- |
| 角色扮演 | 是 | 是 | 点击后进入 RPG Agent 共创工作台 |
| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 |
| 拼图 | 是 | 是 | 点击后进入拼图 Agent 共创工作台 |
| 抓大鹅 | 是 | 是 | 点击后进入抓大鹅 Agent 共创工作台 |
| AIRP | 是 | 否 | 保留入口,显示敬请期待 |
| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 |
## 验收
1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作中心顶部卡带和平台创作类型弹层应同步变化。
2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。
3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。
4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。

View File

@@ -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. 未登录用户点击“作品改造”仍先触发登录拦截。

View File

@@ -0,0 +1,75 @@
# 拼图 APIMart 图片模型路由接入 2026-05-01
## 背景
拼图创作已收口为填表式流程,首图生成和结果页关卡重新生成都由 `server-rs/crates/api-server/src/puzzle.rs` 执行外部图片 I/O再把正式图写入 OSS 与 SpacetimeDB。新的模型选择只影响图片生成上游不改变 SpacetimeDB 表结构、拼图草稿结构或前端运行时规则。
本轮参考 APIMart 文档:
1. `https://docs.apimart.ai/cn/api-reference/images/gpt-image-2/generation`
2. `https://docs.apimart.ai/cn/api-reference/images/gemini-3.1-flash/generation`
两条文档均指向 OpenAI 兼容风格的图片生成入口:`POST https://api.apimart.ai/v1/images/generations`,头部使用 `Authorization: Bearer {APIMART_API_KEY}`。请求体至少包含 `model``prompt``n``size`。返回体按 OpenAI images 兼容格式优先读取 `data[].url`,若供应商返回异步任务结构,则继续按 `task_id` / `tasks/{task_id}` 轮询并提取图片 URL。
## 模型选项
拼图图片生成支持两个选项:
| 前端显示 | 请求值 | 上游 |
| --- | --- | --- |
| `gpt-image-2` | `gpt-image-2` | APIMart `/v1/images/generations` |
| `nanobanana2` | `gemini-3.1-flash-image-preview` | APIMart `/v1/images/generations` |
默认值为 `gpt-image-2`。前端只负责展示和传递所选模型,不能把模型路由逻辑、上游请求体拼装或 API Key 暴露到浏览器。历史草稿或旧请求中的空值、`original`、未知值统一按 `gpt-image-2` 处理,不再把拼图生图路由回 DashScope 原模型。
## 前端交互
1. 拼图创作表单的“画面描述”输入框左下角显示当前调用模型。
2. 拼图结果页关卡详情的“画面描述”输入框左下角同样显示当前调用模型。
3. 点击模型标识弹出轻量选择菜单,只支持 `gpt-image-2``nanobanana2` 两项。
4. 菜单只是模型选择控件,不写入说明性规则文案。
5. 参考图入口继续在画面描述输入框右下角,模型选择在左下角,两者不得遮挡文本。
6. 表单创建 session、自动保存表单草稿、首图生成失败重试时都要保留当前模型选择。
7. 结果页关卡重新生成时将当前模型随 `generate_puzzle_images` action 传给后端。
8. “生成草稿”和关卡详情“生成画面 / 重新生成画面”按钮文本右侧展示 `消耗2光点`
9. 关卡详情点击“生成画面 / 重新生成画面”后先弹出确认消耗光点弹窗,确认后开始请求;按钮区域切换为 30 秒倒计时进度条,并展示预计剩余生成完成时间。
## 后端路由
1. `CreatePuzzleAgentSessionRequest``ExecutePuzzleAgentActionRequest` 增加可选 `imageModel` 字段;该字段不进入 SpacetimeDB reducer 输入结构。
2. `compile_puzzle_draft_with_initial_cover``generate_puzzle_image_candidates` 增加图片模型参数。
3. `imageModel` 归一化规则:
- 空值、`original` 或未知值统一回落为 `gpt-image-2`
- `gpt-image-2` 走 APIMart
- `gemini-3.1-flash-image-preview` 走 APIMart前端显示名为 `nanobanana2`
4. APIMart 文生图和图生图共用 `POST /v1/images/generations`。有参考图时,后端将参考图 Data URL 作为 `image_urls` 数组传入;若上游不接受该字段,错误按上游失败返回,不在前端降级伪造结果。
5. APIMart 尺寸使用文档要求的比例写法 `1:1``gemini-3.1-flash-image-preview` 额外带 `resolution = "1K"`,对齐约 1024px 的拼图正方形素材。
6. APIMart 生成成功后仍下载远程图片,沿用现有 OSS 私有对象、`asset_object``asset_entity_binding` 写入流程。若图片已成功上传 OSS但 Maincloud / SpacetimeDB 短暂返回 `503 Service Unavailable`,资产索引写入允许降级跳过,并返回本次生成图片;日志必须记录 `拼图图片资产索引写入因 SpacetimeDB 连接不可用而降级跳过`
7. `save_puzzle_generated_images` 写回草稿时若遇到 Maincloud 连接级 `503` 或断线API 层基于本次生成结果合成 session 快照返回给前端,避免 APIMart 已成功出图却被后置持久化误报成服务不可用。余额不足、参数错误、上游生图失败仍按原错误返回,不做伪成功。
8. 结果页 `generate_puzzle_images` 会携带当前作品信息和 `levelsJson`。当 Maincloud / SpacetimeDB 在读取 session 阶段就返回连接级 `503` 或断线时,后端必须先用这份结果页快照构造最小内存 session再继续调用 APIMart外部图片已经生成后仍按第 6、7 条处理持久化降级。余额不足、参数错误、缺少草稿快照、关卡不存在等业务错误不走此降级。
9. APIMart 异步任务轮询按文档口径在提交后先等待 `10s`,再调用 `GET /v1/tasks/{task_id}`;图片地址提取同时支持 `url: "..."``url: ["..."]` 两种结构。
10. APIMart 错误统一映射为 `502 UPSTREAM_ERROR``details.provider = "apimart"`,保留上游状态码、业务 message 和截断后的 raw excerpt。
11. 拼图首图生成 `compile_puzzle_draft` 与关卡图片生成 `generate_puzzle_images` 每次预扣 `2` 光点;余额不足仍返回 `409 CONFLICT`Maincloud 连接级 503 仍按既有降级策略处理。
## 环境变量
新增服务端环境变量:
```text
APIMART_BASE_URL="https://api.apimart.ai/v1"
APIMART_API_KEY="YOUR_APIMART_API_KEY"
APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000
```
`APIMART_API_KEY` 只能存在于本地或部署环境,不写入 Git 跟踪文件。若选择 APIMart 模型但缺少 key后端返回服务不可用错误前端展示现有错误面板。
## 验收
1. 创作表单和关卡详情的画面描述框左下角能切换 `gpt-image-2``nanobanana2`,默认显示 `gpt-image-2`
2. 点击“生成草稿”时,后端首图生成使用当前表单选择的模型。
3. 点击“生成画面 / 重新生成画面”时,后端当前关卡图片生成使用关卡详情选择的模型。
4. 历史 `original` 或空模型值不会再触发 DashScope统一按 `gpt-image-2` 请求 APIMart。
5. 选择 APIMart 模型时,请求 `POST {APIMART_BASE_URL}/images/generations`,使用 `Authorization: Bearer {APIMART_API_KEY}``model` 等于请求值,`size = 1:1`
6. “生成草稿”和关卡详情生图按钮展示 `消耗2光点`;关卡详情确认后展示 30 秒预计剩余进度条。
7. 不改 SpacetimeDB 表结构,因此无需更新 `migration.rs` 或重新生成 bindings。
8. 后端改动后运行对应 Rust 测试,并按项目约束用 `npm run api-server:maincloud` 重启验证。

View File

@@ -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. 基础单块和合并块都能看到圆角,合并块的外凸角与内凹角都不是直角,且图片不会溢出圆角裁剪。

View File

@@ -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. 不影响设置面板里的返回按钮、失败续时、通关结算和下一关入口。

View File

@@ -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-*` 首次搜索增强失败时,日志出现一次降级 warningoperation 不应直接失败。
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` 重启后端。

View 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 表结构。

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,131 @@
event: response.created
data: {"type":"response.created","response":{"created_at":1777642663,"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":0}
event: response.in_progress
data: {"type":"response.in_progress","response":{"created_at":1777642663,"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":1}
event: response.output_item.added
data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},"sequence_number":2}
event: response.content_part.added
data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":4}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":5}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"了解","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":6}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":7}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":8}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":9}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":10}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":11}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":12}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"的","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":13}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"创作","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":14}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"背景","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":15}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"和","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":16}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"灵感","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":17}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"来源","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":18}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":19}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":20}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":21}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"帮助你","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":22}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"构建","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":23}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"这个","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":24}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"游戏","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":25}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"世界","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":26}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"。","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":27}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"让我","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":28}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":29}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":30}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"相关信息","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":31}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":32}
event: response.output_text.done
data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n","sequence_number":33}
event: response.content_part.done
data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"part":{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"},"sequence_number":34}
event: response.output_item.done
data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"}],"status":"completed","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},"sequence_number":35}
event: response.output_item.added
data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"},"sequence_number":36}
event: response.web_search_call.in_progress
data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":37}
event: response.web_search_call.searching
data: {"type":"response.web_search_call.searching","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":38}
event: response.web_search_call.completed
data: {"type":"response.web_search_call.completed","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":39}
event: response.output_item.done
data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 游戏设定 世界观;玩具题材游戏 设计灵感;王国主题玩具世界","type":"search"},"status":"completed","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"},"sequence_number":40}
event: error
data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":41}
event: response.failed
data: {"type":"response.failed","response":{"created_at":1777642663,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"}],"status":"completed","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},{"type":"web_search_call","action":{"query":"玩具王国 游戏设定 世界观;玩具题材游戏 设计灵感;王国主题玩具世界","type":"search"},"status":"completed","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":1791,"output_tokens":122,"total_tokens":1913,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":42}
data: [DONE]

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,117 @@
event: response.created
data: {"type":"response.created","response":{"created_at":1777644754,"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":0}
event: response.in_progress
data: {"type":"response.in_progress","response":{"created_at":1777644754,"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":1}
event: response.output_item.added
data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},"sequence_number":2}
event: response.content_part.added
data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":4}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":5}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":6}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":7}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":8}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":9}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":10}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":11}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":12}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"的相关","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":13}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"信息","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":14}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":15}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":16}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":17}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"理解","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":18}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"这个概念","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":19}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":20}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"为","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":21}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"我们的","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":22}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"共创","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":23}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"提供","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":24}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"更有","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":25}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"价值的","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":26}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"参考","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":27}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":28}
event: response.output_text.done
data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n","sequence_number":29}
event: response.content_part.done
data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"part":{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"},"sequence_number":30}
event: response.output_item.done
data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"}],"status":"completed","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},"sequence_number":31}
event: response.output_item.added
data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"},"sequence_number":32}
event: response.web_search_call.in_progress
data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":33}
event: response.web_search_call.searching
data: {"type":"response.web_search_call.searching","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":34}
event: response.web_search_call.completed
data: {"type":"response.web_search_call.completed","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":35}
event: response.output_item.done
data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 游戏设定;玩具王国 世界观;玩具主题 游戏","type":"search"},"status":"completed","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"},"sequence_number":36}
event: error
data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":37}
event: response.failed
data: {"type":"response.failed","response":{"created_at":1777644754,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"}],"status":"completed","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},{"type":"web_search_call","action":{"query":"玩具王国 游戏设定;玩具王国 世界观;玩具主题 游戏","type":"search"},"status":"completed","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":1808,"output_tokens":120,"total_tokens":1928,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":38}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,117 @@
event: response.created
data: {"type":"response.created","response":{"created_at":1777644794,"id":"resp_02177764479383248aafbb39a6fef3e9b38f22991150366ae7197","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903993},"sequence_number":0}
event: response.in_progress
data: {"type":"response.in_progress","response":{"created_at":1777644794,"id":"resp_02177764479383248aafbb39a6fef3e9b38f22991150366ae7197","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903993},"sequence_number":1}
event: response.output_item.added
data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764479470200000000000000000000ffffac1549fec91d99"},"sequence_number":2}
event: response.content_part.added
data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":4}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":5}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":6}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":7}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":8}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":9}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":10}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":11}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":12}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"和","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":13}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":14}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"误","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":15}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"闯入","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":16}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"的人类","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":17}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"孩子","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":18}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":19}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"的","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":20}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"创意","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":21}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"灵感","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":22}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":23}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"来","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":24}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"帮助我们","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":25}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":26}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"构建","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":27}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"这个世界","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":28}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":29}
event: response.output_text.done
data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n","sequence_number":30}
event: response.content_part.done
data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"part":{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n"},"sequence_number":31}
event: response.output_item.done
data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n"}],"status":"completed","id":"msg_02177764479470200000000000000000000ffffac1549fec91d99"},"sequence_number":32}
event: response.output_item.added
data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df"},"sequence_number":33}
event: response.web_search_call.in_progress
data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":34}
event: response.web_search_call.searching
data: {"type":"response.web_search_call.searching","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":35}
event: response.web_search_call.completed
data: {"type":"response.web_search_call.completed","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":36}
event: response.output_item.done
data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国创意设定;人类孩子误入奇幻世界;玩具主题游戏世界观","type":"search"},"status":"completed","id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df"},"sequence_number":37}
event: error
data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":38}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,155 @@
event: response.created
data: {"type":"response.created","response":{"created_at":1777644839,"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":0}
event: response.in_progress
data: {"type":"response.in_progress","response":{"created_at":1777644839,"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":1}
event: response.output_item.added
data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},"sequence_number":2}
event: response.content_part.added
data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":4}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"基于","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":5}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"当前的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":6}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":7}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":8}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":9}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":10}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"为用户","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":11}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"补","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":12}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"全","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":13}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"剩余的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":14}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":15}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"内容","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":16}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"。","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":17}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"让我","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":18}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":19}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":20}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":21}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":22}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":23}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":24}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"、","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":25}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"童话","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":26}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":27}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"、","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":28}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"儿童","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":29}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"奇幻","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":30}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"世界的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":31}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"参考","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":32}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"信息","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":33}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":34}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":35}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":36}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"完善","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":37}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"这个","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":38}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":39}
event: response.output_text.delta
data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":40}
event: response.output_text.done
data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n","sequence_number":41}
event: response.content_part.done
data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"part":{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"},"sequence_number":42}
event: response.output_item.done
data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"}],"status":"completed","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},"sequence_number":43}
event: response.output_item.added
data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"},"sequence_number":44}
event: response.web_search_call.in_progress
data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":45}
event: response.web_search_call.searching
data: {"type":"response.web_search_call.searching","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":46}
event: response.web_search_call.completed
data: {"type":"response.web_search_call.completed","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":47}
event: response.output_item.done
data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 童话设定;儿童奇幻世界 游戏设定;玩具主题 RPG 游戏","type":"search"},"status":"completed","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"},"sequence_number":48}
event: error
data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":49}
event: response.failed
data: {"type":"response.failed","response":{"created_at":1777644839,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"}],"status":"completed","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},{"type":"web_search_call","action":{"query":"玩具王国 童话设定;儿童奇幻世界 游戏设定;玩具主题 RPG 游戏","type":"search"},"status":"completed","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":2173,"output_tokens":130,"total_tokens":2303,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":50}
data: [DONE]

View File

@@ -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 对象。"
}
]
}

View File

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

View File

@@ -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 对象。"
}
]
}

View File

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

View File

@@ -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 对象。"
}
]
}

View File

@@ -0,0 +1 @@
LLM 请求超时,累计尝试 2 次

View File

@@ -46,6 +46,7 @@ export type PuzzleAgentActionRequest =
workTitle?: string;
workDescription?: string;
pictureDescription?: string;
imageModel?: string | null;
}
| {
action: 'compile_puzzle_draft';
@@ -54,6 +55,7 @@ export type PuzzleAgentActionRequest =
workDescription?: string;
pictureDescription?: string;
referenceImageSrc?: string | null;
imageModel?: string | null;
candidateCount?: number;
}
| {
@@ -61,7 +63,12 @@ export type PuzzleAgentActionRequest =
levelId?: string | null;
promptText?: string | null;
referenceImageSrc?: string | null;
imageModel?: string | null;
candidateCount?: number;
workTitle?: string;
workDescription?: string;
summary?: string;
themeTags?: string[];
levelsJson?: string;
}
| {

View File

@@ -1,4 +1,7 @@
import type { PuzzleAgentActionResponse, PuzzleAgentSuggestedAction } from './puzzleAgentActions';
import type {
PuzzleAgentActionResponse,
PuzzleAgentSuggestedAction,
} from './puzzleAgentActions';
import type { PuzzleAnchorPack, PuzzleResultDraft } from './puzzleAgentDraft';
import type { PuzzleResultPreviewEnvelope } from './puzzleResultPreview';
@@ -47,6 +50,7 @@ export interface CreatePuzzleAgentSessionRequest {
workDescription?: string;
pictureDescription?: string;
referenceImageSrc?: string | null;
imageModel?: string | null;
}
export interface CreatePuzzleAgentSessionResponse {
@@ -59,6 +63,7 @@ export interface SendPuzzleAgentMessageRequest {
quickFillRequested?: boolean;
}
export interface SendPuzzleAgentMessageResponse extends PuzzleAgentActionResponse {
export interface SendPuzzleAgentMessageResponse
extends PuzzleAgentActionResponse {
session: PuzzleAgentSessionSnapshot;
}

View File

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

View File

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

View File

@@ -90,6 +90,9 @@ pub struct AppConfig {
pub dashscope_reference_image_model: String,
pub dashscope_cover_image_model: String,
pub dashscope_image_request_timeout_ms: u64,
pub apimart_base_url: String,
pub apimart_api_key: Option<String>,
pub apimart_image_request_timeout_ms: u64,
pub draft_asset_generation_max_concurrent_requests: usize,
pub ark_character_video_base_url: String,
pub ark_character_video_api_key: Option<String>,
@@ -182,6 +185,9 @@ impl Default for AppConfig {
dashscope_reference_image_model: "qwen-image-2.0".to_string(),
dashscope_cover_image_model: "wan2.2-t2i-flash".to_string(),
dashscope_image_request_timeout_ms: 150_000,
apimart_base_url: "https://api.apimart.ai/v1".to_string(),
apimart_api_key: None,
apimart_image_request_timeout_ms: 180_000,
draft_asset_generation_max_concurrent_requests: 4,
ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(),
ark_character_video_api_key: None,
@@ -525,6 +531,18 @@ impl AppConfig {
config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms;
}
if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) {
config.apimart_base_url = apimart_base_url;
}
config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]);
if let Some(apimart_image_request_timeout_ms) =
read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"])
{
config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms;
}
if let Some(max_concurrent_requests) = read_first_usize_env(&[
"GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",
"DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS",

View File

@@ -1,4 +1,4 @@
use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest};
use platform_llm::{LlmClient, LlmError, LlmMessage, LlmStreamDelta, LlmTextRequest};
use serde_json::Value as JsonValue;
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
@@ -33,10 +33,63 @@ where
{
let llm_client =
llm_client.ok_or_else(|| build_error(messages.model_unavailable.to_string()))?;
let user_prompt = user_prompt.into();
let turn_output = match request_stream_creation_agent_json_turn(
llm_client,
system_prompt.clone(),
user_prompt.clone(),
enable_web_search,
&mut on_reply_update,
)
.await
{
Ok(turn_output) => Ok(turn_output),
Err(CreationAgentJsonTurnFailure::Stream(error))
if enable_web_search && is_web_search_tool_unavailable(&error) =>
{
tracing::warn!(
error = %error,
"创作 Agent 联网搜索插件不可用,自动降级为无联网搜索重试"
);
request_stream_creation_agent_json_turn(
llm_client,
system_prompt,
user_prompt,
false,
&mut on_reply_update,
)
.await
}
Err(error) => Err(error),
};
turn_output.map_err(|error| match error {
CreationAgentJsonTurnFailure::Stream(_) => {
build_error(messages.generation_failed.to_string())
}
CreationAgentJsonTurnFailure::Parse => build_error(messages.parse_failed.to_string()),
})
}
enum CreationAgentJsonTurnFailure {
Stream(LlmError),
Parse,
}
async fn request_stream_creation_agent_json_turn<F>(
llm_client: &LlmClient,
system_prompt: String,
user_prompt: String,
enable_web_search: bool,
on_reply_update: &mut F,
) -> Result<CreationAgentJsonTurnOutput, CreationAgentJsonTurnFailure>
where
F: FnMut(&str),
{
let mut latest_reply_text = String::new();
let response = llm_client
.stream_text(
build_creation_agent_llm_request(system_prompt, user_prompt.into(), enable_web_search),
build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search),
|delta: &LlmStreamDelta| {
if let Some(reply_progress) =
extract_reply_text_from_partial_json(delta.accumulated_text.as_str())
@@ -48,9 +101,9 @@ where
},
)
.await
.map_err(|_| build_error(messages.generation_failed.to_string()))?;
.map_err(CreationAgentJsonTurnFailure::Stream)?;
let parsed = parse_json_response_text(response.content.as_str())
.map_err(|_| build_error(messages.parse_failed.to_string()))?;
.map_err(|_| CreationAgentJsonTurnFailure::Parse)?;
let reply_text = read_reply_text(&parsed);
if let Some(reply_text) = reply_text.as_deref()
&& reply_text != latest_reply_text
@@ -61,6 +114,13 @@ where
Ok(CreationAgentJsonTurnOutput { parsed })
}
fn is_web_search_tool_unavailable(error: &LlmError) -> bool {
let message = error.to_string();
message.contains("ToolNotOpen")
|| message.contains("has not activated web search")
|| message.contains("未开通")
}
fn build_creation_agent_llm_request(
system_prompt: String,
user_prompt: String,
@@ -168,11 +228,23 @@ fn read_reply_text(parsed: &JsonValue) -> Option<String> {
#[cfg(test)]
mod tests {
use std::{
fs,
io::{Read, Write},
net::TcpListener,
sync::{Arc, Mutex},
thread,
time::{Duration as StdDuration, SystemTime, UNIX_EPOCH},
};
use platform_llm::{LlmConfig, LlmProvider};
use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL;
use super::{
build_creation_agent_llm_request, extract_reply_text_from_partial_json,
parse_json_response_text,
CreationAgentLlmTurnErrorMessages, build_creation_agent_llm_request,
extract_reply_text_from_partial_json, is_web_search_tool_unavailable,
parse_json_response_text, stream_creation_agent_json_turn,
};
#[test]
@@ -202,4 +274,214 @@ mod tests {
assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses);
assert_eq!(request.messages.len(), 2);
}
#[test]
fn detects_upstream_web_search_tool_unavailable_error() {
let error = platform_llm::LlmError::Upstream {
status_code: 502,
message: "Your account has not activated web search. code=ToolNotOpen".to_string(),
};
assert!(is_web_search_tool_unavailable(&error));
}
#[tokio::test]
async fn stream_turn_retries_without_web_search_when_tool_is_unavailable() {
let log_dir = std::env::temp_dir().join(format!(
"api-server-creation-agent-raw-log-test-{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time should be after epoch")
.as_nanos()
));
unsafe {
std::env::set_var("LLM_RAW_LOG_DIR", &log_dir);
}
let success_json = serde_json::json!({
"replyText": "好,我们先把玩具王国定住。",
"progressPercent": 12,
"nextAnchorContent": {
"worldPromise": "玩具王国初步方向",
"playerFantasy": null,
"themeBoundary": null,
"playerEntryPoint": null,
"coreConflict": null,
"keyRelationships": null,
"hiddenLines": null,
"iconicElements": null
}
})
.to_string();
let server = spawn_capturing_mock_server(vec![
MockResponse {
body: concat!(
"data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n",
"data: [DONE]\n\n"
)
.to_string(),
},
MockResponse {
body: format!(
"data: {}\n\n",
serde_json::json!({
"type": "response.output_text.delta",
"delta": success_json
})
) + "data: {\"type\":\"response.completed\"}\n\n",
},
]);
let config = LlmConfig::new(
LlmProvider::Ark,
server.base_url,
"test-key".to_string(),
"test-model".to_string(),
30_000,
0,
1,
)
.expect("LLM config should build");
let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build");
let mut visible_replies = Vec::new();
let output = stream_creation_agent_json_turn(
Some(&llm_client),
"系统提示".to_string(),
"用户提示",
true,
CreationAgentLlmTurnErrorMessages {
model_unavailable: "模型不可用",
generation_failed: "生成失败",
parse_failed: "解析失败",
},
|text| visible_replies.push(text.to_string()),
|message| message,
)
.await
.expect("web search fallback should succeed");
assert_eq!(
output.parsed["replyText"].as_str(),
Some("好,我们先把玩具王国定住。")
);
assert_eq!(visible_replies, vec!["好,我们先把玩具王国定住。"]);
let requests = server.requests.lock().expect("requests lock").clone();
assert_eq!(requests.len(), 2);
assert!(requests[0].contains("\"tools\""));
assert!(requests[0].contains("\"web_search\""));
assert!(!requests[1].contains("\"tools\""));
unsafe {
std::env::remove_var("LLM_RAW_LOG_DIR");
}
if log_dir.exists() {
fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed");
}
}
struct MockResponse {
body: String,
}
struct CapturingMockServer {
base_url: String,
requests: Arc<Mutex<Vec<String>>>,
}
fn spawn_capturing_mock_server(responses: Vec<MockResponse>) -> CapturingMockServer {
let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind");
let address = listener.local_addr().expect("listener should have addr");
let requests = Arc::new(Mutex::new(Vec::new()));
let requests_for_thread = Arc::clone(&requests);
thread::spawn(move || {
for response in responses {
let (mut stream, _) = listener.accept().expect("request should connect");
let request_text = read_request(&mut stream);
requests_for_thread
.lock()
.expect("requests lock")
.push(request_text);
write_sse_response(&mut stream, response);
}
});
CapturingMockServer {
base_url: format!("http://{address}"),
requests,
}
}
fn read_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(StdDuration::from_secs(1)))
.expect("read timeout should be set");
let mut buffer = Vec::new();
let mut chunk = [0_u8; 1024];
let mut expected_total = None;
loop {
match stream.read(&mut chunk) {
Ok(0) => break,
Ok(bytes_read) => {
buffer.extend_from_slice(&chunk[..bytes_read]);
if expected_total.is_none()
&& let Some(header_end) = find_header_end(&buffer)
{
let content_length =
read_content_length(&buffer[..header_end]).unwrap_or(0);
expected_total = Some(header_end + content_length);
}
if let Some(total_bytes) = expected_total
&& buffer.len() >= total_bytes
{
break;
}
}
Err(error)
if error.kind() == std::io::ErrorKind::WouldBlock
|| error.kind() == std::io::ErrorKind::TimedOut =>
{
break;
}
Err(error) => panic!("mock server failed to read request: {error}"),
}
}
String::from_utf8_lossy(buffer.as_slice()).to_string()
}
fn write_sse_response(stream: &mut std::net::TcpStream, response: MockResponse) {
let raw_response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
response.body.len(),
response.body
);
stream
.write_all(raw_response.as_bytes())
.expect("mock response should be written");
stream.flush().expect("mock response should flush");
}
fn find_header_end(buffer: &[u8]) -> Option<usize> {
buffer
.windows(4)
.position(|window| window == b"\r\n\r\n")
.map(|index| index + 4)
}
fn read_content_length(headers: &[u8]) -> Option<usize> {
let text = String::from_utf8_lossy(headers);
text.lines().find_map(|line| {
let (name, value) = line.split_once(':')?;
if name.eq_ignore_ascii_case("content-length") {
return value.trim().parse::<usize>().ok();
}
None
})
}
}

View File

@@ -185,17 +185,22 @@ pub async fn generate_custom_world_profile(
);
// 中文注释profile 生成需要外部 LLM必须留在 Axum/api-serverSpacetimeDB 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
};

View File

@@ -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())

View File

@@ -34,6 +34,10 @@ impl AppError {
self.code
}
pub fn status_code(&self) -> StatusCode {
self.status_code
}
pub fn message(&self) -> &str {
&self.message
}

View File

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

View File

@@ -2540,8 +2540,7 @@ fn format_rfc3339(value: OffsetDateTime) -> Result<String, String> {
}
fn current_auth_user_created_at() -> String {
format_rfc3339(OffsetDateTime::now_utc())
.unwrap_or_else(|_| default_auth_user_created_at())
format_rfc3339(OffsetDateTime::now_utc()).unwrap_or_else(|_| default_auth_user_created_at())
}
fn default_auth_user_created_at() -> String {

View File

@@ -13,6 +13,8 @@ pub struct CreatePuzzleAgentSessionRequest {
pub picture_description: Option<String>,
#[serde(default)]
pub reference_image_src: Option<String>,
#[serde(default)]
pub image_model: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
@@ -33,6 +35,8 @@ pub struct ExecutePuzzleAgentActionRequest {
#[serde(default)]
pub reference_image_src: Option<String>,
#[serde(default)]
pub image_model: Option<String>,
#[serde(default)]
pub candidate_count: Option<u32>,
#[serde(default)]
pub candidate_id: Option<String>,

View 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();
});
});
});

View 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>
);
}

View 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,
})}`;
}

View File

@@ -108,11 +108,10 @@ test('creation hub reflects updated draft title summary and counts after rerende
const puzzleButton = screen.getByRole('button', { name: /.*/u });
const match3dButton = screen.getByRole('button', { name: //u });
expect(
puzzleButton.compareDocumentPosition(rpgButton) &
rpgButton.compareDocumentPosition(puzzleButton) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
expect((match3dButton as HTMLButtonElement).disabled).toBe(false);
expect(
within(match3dButton).getAllByText('经典消除玩法').length,
@@ -320,6 +319,55 @@ test('creation hub shows delete action for persisted rpg drafts', () => {
expect(screen.getByRole('button', { name: '删除' })).toBeTruthy();
});
test('creation hub published work delete action is available beside share without opening card', async () => {
const user = userEvent.setup();
const onDeletePuzzle = vi.fn();
const onOpenPuzzleDetail = vi.fn();
render(
<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[] = [];

View File

@@ -43,7 +43,6 @@ test('creation hub draft card renders compiled work summary fields', () => {
expect(html).toContain('玩家是失职返乡的守灯人');
expect(html).toContain('守灯会与沉船商盟争夺航道解释权');
expect(html).toContain('角色扮演');
expect(html).toContain('敬请期待');
expect(html).toContain('拼图');
expect(html).toContain('创意礼物,生活分享');
expect(html).not.toContain('大鱼吃小鱼');

View File

@@ -1,5 +1,6 @@
import { ArrowRight } from 'lucide-react';
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
import {
getVisiblePlatformCreationTypes,
type PlatformCreationTypeId,
@@ -27,13 +28,15 @@ export function CustomWorldCreationStartCard({
<div className="relative z-10 space-y-2.5 sm:space-y-4 xl:space-y-3">
<div className="flex items-center justify-between gap-3 xl:items-end">
<div className="text-xl font-black leading-none text-white sm:text-3xl xl:text-2xl">
{NEW_WORK_ENTRY_CONFIG.startCard.title}
</div>
<div className="hidden text-sm leading-6 text-zinc-200/88 sm:block xl:text-xs xl:leading-5">
{NEW_WORK_ENTRY_CONFIG.startCard.description}
</div>
<span className="platform-pill platform-pill--neutral shrink-0 border-white/25 bg-white/14 px-2.5 text-xs text-white sm:hidden">
{busy ? '正在开启' : '选择模板'}
{busy
? NEW_WORK_ENTRY_CONFIG.startCard.busyBadge
: NEW_WORK_ENTRY_CONFIG.startCard.idleBadge}
</span>
</div>

View File

@@ -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">

View File

@@ -1,5 +1,6 @@
import { ArrowRight } from 'lucide-react';
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
import { UnifiedModal } from '../common/UnifiedModal';
import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes';
@@ -86,8 +87,8 @@ export function PlatformEntryCreationTypeModal({
return (
<UnifiedModal
open={isOpen}
title="选择创作类型"
description="先选玩法类型,再进入对应创作工作台。"
title={NEW_WORK_ENTRY_CONFIG.typeModal.title}
description={NEW_WORK_ENTRY_CONFIG.typeModal.description}
onClose={onClose}
closeDisabled={isBusy}
size="lg"

File diff suppressed because it is too large Load Diff

View File

@@ -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(

View File

@@ -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"

View File

@@ -0,0 +1,41 @@
import { expect, test } from 'vitest';
import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig';
import {
getVisiblePlatformCreationTypes,
isPlatformCreationTypeVisible,
PLATFORM_CREATION_TYPES,
} from './platformEntryCreationTypes';
test('platform creation types are derived from new work entry config', () => {
const puzzleConfig = NEW_WORK_ENTRY_CONFIG.creationTypes.find(
(item) => item.id === 'puzzle',
);
expect(puzzleConfig).toBeTruthy();
expect(PLATFORM_CREATION_TYPES).toContainEqual(
expect.objectContaining({
id: 'puzzle',
title: puzzleConfig?.title,
subtitle: puzzleConfig?.subtitle,
badge: puzzleConfig?.badge,
locked: false,
hidden: false,
}),
);
});
test('new work entry config controls visibility and open order', () => {
const visibleIds = getVisiblePlatformCreationTypes().map((item) => item.id);
expect(isPlatformCreationTypeVisible('big-fish')).toBe(false);
expect(visibleIds).not.toContain('big-fish');
expect(visibleIds[0]).toBe('rpg');
expect(visibleIds).toEqual([
'rpg',
'puzzle',
'match3d',
'airp',
'visual-novel',
]);
});

View File

@@ -1,10 +1,9 @@
export type PlatformCreationTypeId =
| 'rpg'
| 'big-fish'
| 'match3d'
| 'puzzle'
| 'airp'
| 'visual-novel';
import {
NEW_WORK_ENTRY_CONFIG,
type NewWorkEntryCreationTypeId,
} from '../../config/newWorkEntryConfig';
export type PlatformCreationTypeId = NewWorkEntryCreationTypeId;
export type PlatformCreationTypeCard = {
id: PlatformCreationTypeId;
@@ -39,51 +38,15 @@ export function isPlatformCreationTypeVisible(id: PlatformCreationTypeId) {
}
/**
* 创作页与类型弹层共用同一份模板元数据,避免多入口文案和可用状态漂移。
* 创作页与类型弹层共用同一份新建作品入口配置,避免多入口文案和开放状态漂移。
* `hidden` 只控制平台入口是否展示,不影响既有玩法链路和路由能力。
*/
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] = [
{
id: 'rpg',
title: '角色扮演',
subtitle: '敬请期待',
badge: '敬请期待',
locked: true,
},
{
id: 'big-fish',
title: '大鱼吃小鱼',
subtitle: '实时成长玩法',
badge: '可创建',
locked: false,
hidden: true,
},
{
id: 'puzzle',
title: '拼图',
subtitle: '创意礼物,生活分享',
badge: '可创建',
locked: false,
},
{
id: 'match3d',
title: '抓大鹅',
subtitle: '经典消除玩法',
badge: '可创建',
locked: false,
},
{
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '敬请期待',
locked: true,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
badge: '敬请期待',
locked: true,
},
];
export const PLATFORM_CREATION_TYPES: PlatformCreationTypeCard[] =
NEW_WORK_ENTRY_CONFIG.creationTypes.map((item) => ({
id: item.id,
title: item.title,
subtitle: item.subtitle,
badge: item.badge,
locked: !item.open,
hidden: !item.visible,
}));

View File

@@ -100,7 +100,9 @@ test('puzzle workspace submits the work form instead of agent chat', () => {
workDescription: '一套雨夜猫街主题拼图。',
pictureDescription: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
});
expect(screen.getByText('消耗2光点')).toBeTruthy();
expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull();
expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull();
});
@@ -129,10 +131,45 @@ test('puzzle workspace falls back to compile action for restored sessions', () =
workDescription: '雾港遗迹拼图',
pictureDescription: '潮雾中的灯塔与断桥',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
candidateCount: 1,
});
});
test('puzzle workspace switches the image model from the description box', () => {
const onCreateFromForm = vi.fn();
render(
<PuzzleAgentWorkspace
session={null}
onBack={() => {}}
onSubmitMessage={() => {}}
onExecuteAction={() => {}}
onCreateFromForm={onCreateFromForm}
/>,
);
fireEvent.change(screen.getByLabelText('作品名称'), {
target: { value: '暖灯猫街' },
});
fireEvent.change(screen.getByLabelText('作品描述'), {
target: { value: '一套雨夜猫街主题拼图。' },
});
fireEvent.change(screen.getByLabelText('画面描述'), {
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 }));
expect(onCreateFromForm).toHaveBeenCalledWith(
expect.objectContaining({
imageModel: 'gemini-3.1-flash-image-preview',
}),
);
});
test('puzzle workspace restores form draft fields and autosaves edits', () => {
vi.useFakeTimers();
const onAutoSaveForm = vi.fn();
@@ -208,5 +245,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => {
workDescription: '旧街雨夜的拼图草稿。',
pictureDescription: '旧街灯牌下的猫和发光雨伞。',
referenceImageSrc: null,
imageModel: 'gpt-image-2',
});
});

View File

@@ -8,6 +8,12 @@ import type {
SendPuzzleAgentMessageRequest,
} from '../../../packages/shared/src/contracts/puzzleAgentSession';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import {
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from './puzzleImageModelOptions';
import { PuzzleImageModelPicker } from './PuzzleImageModelPicker';
type PuzzleAgentWorkspaceProps = {
session: PuzzleAgentSessionSnapshot | null;
@@ -27,6 +33,7 @@ type PuzzleFormState = {
pictureDescription: string;
referenceImageSrc: string;
referenceImageLabel: string;
imageModel: PuzzleImageModelId;
};
const EMPTY_FORM_STATE: PuzzleFormState = {
@@ -35,6 +42,7 @@ const EMPTY_FORM_STATE: PuzzleFormState = {
pictureDescription: '',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
};
function resolveInitialFormState(
@@ -51,6 +59,7 @@ function resolveInitialFormState(
referenceImageLabel: initialFormPayload?.referenceImageSrc
? '已选择参考图'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload?.imageModel),
};
}
@@ -64,6 +73,7 @@ function resolveInitialFormState(
referenceImageLabel: initialFormPayload.referenceImageSrc
? '已选择参考图'
: '',
imageModel: normalizePuzzleImageModel(initialFormPayload.imageModel),
};
}
@@ -87,6 +97,7 @@ function resolveInitialFormState(
session.draft?.summary || session.anchorPack.visualSubject.value || '',
referenceImageSrc: '',
referenceImageLabel: '',
imageModel: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
};
}
@@ -151,9 +162,11 @@ export function PuzzleAgentWorkspace({
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
}),
[
formState.referenceImageSrc,
formState.imageModel,
pictureDescription,
workDescription,
workTitle,
@@ -163,6 +176,7 @@ export function PuzzleAgentWorkspace({
autosavePayload.workTitle,
autosavePayload.workDescription,
autosavePayload.pictureDescription,
autosavePayload.imageModel,
]);
const lastAutosaveSignatureRef = useRef(autosaveSignature);
const autosaveSessionIdRef = useRef(session?.sessionId ?? null);
@@ -240,6 +254,7 @@ export function PuzzleAgentWorkspace({
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
};
if (!session && onCreateFromForm) {
@@ -254,6 +269,7 @@ export function PuzzleAgentWorkspace({
workDescription,
pictureDescription,
referenceImageSrc: formState.referenceImageSrc || null,
imageModel: formState.imageModel,
candidateCount: 1,
});
};
@@ -332,6 +348,16 @@ export function PuzzleAgentWorkspace({
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
value={formState.imageModel}
disabled={isBusy}
onChange={(imageModel) =>
setFormState((current) => ({
...current,
imageModel,
}))
}
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={
@@ -413,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>

View File

@@ -0,0 +1,84 @@
import { useEffect, useRef, useState } from 'react';
import {
getPuzzleImageModelLabel,
normalizePuzzleImageModel,
PUZZLE_IMAGE_MODEL_OPTIONS,
type PuzzleImageModelId,
} from './puzzleImageModelOptions';
type PuzzleImageModelPickerProps = {
value: PuzzleImageModelId;
disabled?: boolean;
onChange: (value: PuzzleImageModelId) => void;
};
export function PuzzleImageModelPicker({
value,
disabled = false,
onChange,
}: PuzzleImageModelPickerProps) {
const [isOpen, setIsOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(null);
const normalizedValue = normalizePuzzleImageModel(value);
useEffect(() => {
if (!isOpen) {
return;
}
const handlePointerDown = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node)) {
setIsOpen(false);
}
};
window.addEventListener('pointerdown', handlePointerDown);
return () => window.removeEventListener('pointerdown', handlePointerDown);
}, [isOpen]);
return (
<div ref={rootRef} className="absolute bottom-3 left-3 z-10">
<button
type="button"
disabled={disabled}
onClick={() => setIsOpen((current) => !current)}
className={`inline-flex min-h-8 max-w-[10rem] items-center rounded-full border border-[var(--platform-subpanel-border)] bg-white/96 px-3 text-[11px] font-bold text-[var(--platform-text-strong)] shadow-sm transition hover:bg-[var(--platform-subpanel-fill)] ${disabled ? 'cursor-not-allowed opacity-55' : ''}`}
aria-haspopup="menu"
aria-expanded={isOpen}
aria-label="图片模型"
title="图片模型"
>
<span className="truncate">
{getPuzzleImageModelLabel(normalizedValue)}
</span>
</button>
{isOpen ? (
<div
role="menu"
className="absolute bottom-10 left-0 min-w-[11rem] overflow-hidden rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/98 p-1 shadow-[0_16px_40px_rgba(0,0,0,0.18)]"
>
{PUZZLE_IMAGE_MODEL_OPTIONS.map((option) => (
<button
key={option.id}
type="button"
role="menuitemradio"
aria-checked={option.id === normalizedValue}
onClick={() => {
onChange(option.id);
setIsOpen(false);
}}
className={`block min-h-9 w-full rounded-[0.8rem] px-3 text-left text-xs font-bold transition ${
option.id === normalizedValue
? 'bg-amber-100/80 text-amber-800'
: 'text-[var(--platform-text-base)] hover:bg-[var(--platform-subpanel-fill)]'
}`}
>
{option.label}
</button>
))}
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,30 @@
export const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2';
export const PUZZLE_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview';
export type PuzzleImageModelId =
| typeof PUZZLE_IMAGE_MODEL_GPT_IMAGE_2
| typeof PUZZLE_IMAGE_MODEL_NANOBANANA2;
export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{
id: PuzzleImageModelId;
label: string;
}> = [
{ id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' },
{ id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' },
];
export function normalizePuzzleImageModel(
value: string | null | undefined,
): PuzzleImageModelId {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === value)?.id ??
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2
);
}
export function getPuzzleImageModelLabel(model: PuzzleImageModelId) {
return (
PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ??
'gpt-image-2'
);
}

View File

@@ -232,16 +232,29 @@ describe('PuzzleResultView', () => {
fireEvent.change(within(dialog).getByLabelText('画面描述'), {
target: { value: '一只猫在雨夜灯牌下回头。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
fireEvent.click(
within(dialog).getByRole('button', { name: //u }),
);
const confirmDialog = screen.getByRole('dialog', {
name: '确认消耗光点',
});
expect(within(confirmDialog).getByText('消耗 2 光点')).toBeTruthy();
fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' }));
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '一只猫在雨夜灯牌下回头。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '一只猫在雨夜灯牌下回头。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
expect(screen.getByRole('progressbar', { name: '画面生成进度' })).toBeTruthy();
const generatePayload = onExecuteAction.mock.calls[0]![0];
expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([
expect.objectContaining({
@@ -295,9 +308,14 @@ describe('PuzzleResultView', () => {
fireEvent.click(screen.getByRole('button', { name: //u }));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
expect(within(dialog).getByRole('button', { name: //u })).toBeTruthy();
expect(
within(dialog).getByRole('button', { name: //u }),
).toBeTruthy();
expect(within(dialog).getByText('消耗2光点')).toBeTruthy();
expect(within(dialog).queryByText('画面图')).toBeNull();
expect(within(dialog).queryByRole('button', { name: //u })).toBeNull();
expect(
within(dialog).queryByRole('button', { name: //u }),
).toBeNull();
fireEvent.click(screen.getByLabelText('关闭'));
expect(screen.getAllByText('第2关').length).toBeGreaterThan(0);
@@ -352,13 +370,24 @@ describe('PuzzleResultView', () => {
target: { value: '新关卡里有一座发光钟楼。' },
});
fireEvent.click(within(dialog).getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1775000000000-2',
promptText: '新关卡里有一座发光钟楼。',
referenceImageSrc: undefined,
imageModel: 'gpt-image-2',
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '新关卡里有一座发光钟楼。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
@@ -452,13 +481,59 @@ describe('PuzzleResultView', () => {
});
fireEvent.click(screen.getByRole('button', { name: //u }));
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenLastCalledWith({
action: 'generate_puzzle_images',
levelId: 'puzzle-level-1',
promptText: '屋檐下的猫与暖灯街角。',
referenceImageSrc: '/generated-puzzle-assets/history/image.png',
imageModel: 'gpt-image-2',
candidateCount: 1,
workTitle: '暖灯猫街作品',
workDescription: '一套雨夜猫街主题拼图。',
summary: '屋檐下的猫与暖灯街角。',
themeTags: ['猫咪', '雨夜', '暖灯'],
levelsJson: expect.any(String),
});
});
test('passes the selected image model when regenerating a level image', () => {
const onExecuteAction = vi.fn();
render(
<PuzzleResultView
session={createSession()}
onBack={() => {}}
onExecuteAction={onExecuteAction}
/>,
);
fireEvent.click(screen.getByText('雨夜猫街'));
const dialog = screen.getByRole('dialog', { name: '关卡详情' });
fireEvent.click(within(dialog).getByRole('button', { name: '图片模型' }));
fireEvent.click(
within(dialog).getByRole('menuitemradio', { name: 'gpt-image-2' }),
);
fireEvent.click(
within(dialog).getByRole('button', { name: //u }),
);
fireEvent.click(
within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole(
'button',
{ name: '确定' },
),
);
expect(onExecuteAction).toHaveBeenCalledWith(
expect.objectContaining({
action: 'generate_puzzle_images',
imageModel: 'gpt-image-2',
}),
);
});
});

View File

@@ -26,6 +26,11 @@ import {
} from '../../services/puzzle-works/puzzleAssetClient';
import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage';
import { useAuthUi } from '../auth/AuthUiContext';
import {
PUZZLE_IMAGE_MODEL_GPT_IMAGE_2,
type PuzzleImageModelId,
} from '../puzzle-agent/puzzleImageModelOptions';
import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker';
import { ResolvedAssetImage } from '../ResolvedAssetImage';
type PuzzleResultViewProps = {
@@ -51,6 +56,8 @@ type DraftEditState = {
const PUZZLE_MIN_THEME_TAG_COUNT = 3;
const PUZZLE_MAX_THEME_TAG_COUNT = 6;
const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600;
const PUZZLE_IMAGE_GENERATION_POINT_COST = 2;
const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 30;
function normalizeThemeTagInput(value: string) {
return [
@@ -80,7 +87,9 @@ function resolveLevelFormalImageSrc(level: PuzzleDraftLevel) {
);
}
function buildFallbackLevelFromDraft(draft: PuzzleResultDraft): PuzzleDraftLevel {
function buildFallbackLevelFromDraft(
draft: PuzzleResultDraft,
): PuzzleDraftLevel {
return {
levelId: 'puzzle-level-1',
levelName: draft.levelName || '',
@@ -143,7 +152,9 @@ function createDraftEditState(draft: PuzzleResultDraft): DraftEditState {
};
}
function createBlankPuzzleLevel(existingLevels: PuzzleDraftLevel[]): PuzzleDraftLevel {
function createBlankPuzzleLevel(
existingLevels: PuzzleDraftLevel[],
): PuzzleDraftLevel {
const nextIndex = existingLevels.length + 1;
return {
levelId: `puzzle-level-${Date.now()}-${nextIndex}`,
@@ -200,7 +211,9 @@ function buildPublishReady(
...(levels.length > 0 ? [] : ['至少需要一个拼图关卡。']),
...levels.flatMap((level, index) => [
...(level.levelName.trim() ? [] : [`${index + 1}关名称不能为空。`]),
...(resolveLevelFormalImageSrc(level) ? [] : [`${index + 1}关缺少正式图。`]),
...(resolveLevelFormalImageSrc(level)
? []
: [`${index + 1}关缺少正式图。`]),
]),
];
@@ -574,6 +587,7 @@ function PuzzleLevelDetailDialog({
levelId: string,
promptText?: string | null,
referenceImageSrc?: string | null,
imageModel?: PuzzleImageModelId | null,
) => void;
onLevelChange: (nextLevel: PuzzleDraftLevel) => void;
onStartTestRun?: (level: PuzzleDraftLevel) => void;
@@ -581,10 +595,33 @@ function PuzzleLevelDetailDialog({
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const [referenceImageSrc, setReferenceImageSrc] = useState('');
const [referenceImageLabel, setReferenceImageLabel] = useState('');
const [referenceImageError, setReferenceImageError] = useState<string | null>(null);
const [referenceImageError, setReferenceImageError] = useState<string | null>(
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_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>,
@@ -609,6 +646,59 @@ function PuzzleLevelDetailDialog({
}
};
useEffect(() => {
if (!isGenerationProgressActive) {
return;
}
if (generationCountdown <= 0) {
if (!isBusy) {
setIsGenerationProgressActive(false);
}
return;
}
const timer = window.setTimeout(() => {
setGenerationCountdown((current) => Math.max(0, current - 1));
}, 1000);
return () => window.clearTimeout(timer);
}, [generationCountdown, isBusy, isGenerationProgressActive]);
useEffect(() => {
if (isGenerationProgressActive && isBusy) {
generationBusySeenRef.current = true;
return;
}
if (
isGenerationProgressActive &&
!isBusy &&
generationBusySeenRef.current
) {
generationBusySeenRef.current = false;
setIsGenerationProgressActive(false);
setGenerationCountdown(0);
}
if (!isBusy) {
setIsCostConfirmOpen(false);
}
}, [isBusy, isGenerationProgressActive]);
const executeGeneration = () => {
setIsCostConfirmOpen(false);
setIsGenerationProgressActive(true);
generationBusySeenRef.current = false;
setGenerationCountdown(PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS);
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
imageModel,
);
};
if (typeof document === 'undefined') {
return null;
}
@@ -704,6 +794,11 @@ function PuzzleLevelDetailDialog({
className="w-full resize-none rounded-[1rem] border border-[var(--platform-subpanel-border)] bg-white/90 px-4 py-3 pb-16 text-sm leading-6 text-[var(--platform-text-strong)] outline-none"
aria-label="画面描述"
/>
<PuzzleImageModelPicker
value={imageModel}
disabled={isBusy}
onChange={setImageModel}
/>
<label
className={`absolute bottom-3 right-3 inline-flex h-10 w-10 cursor-pointer items-center justify-center rounded-full border border-amber-300/70 bg-white/96 text-amber-700 shadow-sm transition hover:bg-amber-50 ${isBusy ? 'cursor-not-allowed opacity-55' : ''}`}
title={referenceImageSrc ? '更换参考图' : '添加参考图'}
@@ -779,24 +874,83 @@ function PuzzleLevelDetailDialog({
</button>
) : null}
<button
type="button"
disabled={isBusy}
onClick={() => {
onGenerate(
level.levelId,
level.pictureDescription.trim() || undefined,
referenceImageSrc || undefined,
);
}}
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}
@@ -836,7 +990,9 @@ function PuzzlePublishDialog({
}) {
const platformTheme = useAuthUi()?.platformTheme ?? 'light';
const primaryLevel = editState.levels[0] ?? null;
const formalImageSrc = primaryLevel ? resolveLevelFormalImageSrc(primaryLevel) : '';
const formalImageSrc = primaryLevel
? resolveLevelFormalImageSrc(primaryLevel)
: '';
if (typeof document === 'undefined') {
return null;
@@ -1180,7 +1336,9 @@ export function PuzzleResultView({
return syncDraftFromEditState(draft, editState);
}, [draft, editState]);
const primaryLevel = editState?.levels[0] ?? null;
const primaryImageSrc = primaryLevel ? resolveLevelFormalImageSrc(primaryLevel) : '';
const primaryImageSrc = primaryLevel
? resolveLevelFormalImageSrc(primaryLevel)
: '';
const imageRefreshKey = `${session.updatedAt}:${primaryImageSrc}:${editState?.levels.length ?? 0}`;
const activeLevel =
editState?.levels.find((level) => level.levelId === activeLevelId) ?? null;
@@ -1201,7 +1359,8 @@ export function PuzzleResultView({
pictureDescription: level.pictureDescription.trim(),
})),
};
const originalState = savedEditStateRef.current ?? createDraftEditState(draft);
const originalState =
savedEditStateRef.current ?? createDraftEditState(draft);
const changed =
JSON.stringify(normalizedState) !== JSON.stringify(originalState);
@@ -1386,13 +1545,18 @@ export function PuzzleResultView({
isBusy={isBusy}
level={activeLevel}
onClose={() => setActiveLevelId(null)}
onGenerate={(levelId, promptText, referenceImageSrc) => {
onGenerate={(levelId, promptText, referenceImageSrc, imageModel) => {
onExecuteAction({
action: 'generate_puzzle_images',
levelId,
promptText,
referenceImageSrc,
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),
});
}}

View File

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

View File

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

View File

@@ -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;
@@ -1779,7 +1821,7 @@ beforeEach(() => {
vi.mocked(streamRpgCreationMessage).mockResolvedValue(mockSession);
});
test('create hub keeps RPG, AIRP and visual novel locked', async () => {
test('create hub opens RPG while keeping AIRP and visual novel locked', async () => {
const user = userEvent.setup();
render(<TestWrapper withAuth />);
@@ -1795,9 +1837,13 @@ test('create hub keeps RPG, AIRP and visual novel locked', async () => {
expect((visualNovelButton as HTMLButtonElement).disabled).toBe(true);
const rpgButton = screen.getByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0);
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
expect(
await screen.findByText('Agent工作区custom-world-agent-session-1'),
).toBeTruthy();
});
test('platform create hub does not prefetch hidden big fish platform data', async () => {
@@ -2194,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();
@@ -2492,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();
@@ -2508,7 +2609,7 @@ test('published puzzle detail returns to the ranking platform tab', async () =>
});
});
test('selecting locked RPG creation while logged out does not route through requireAuth', async () => {
test('selecting RPG creation while logged out routes through requireAuth', async () => {
const user = userEvent.setup();
const requireAuth = vi.fn();
@@ -2525,9 +2626,9 @@ test('selecting locked RPG creation while logged out does not route through requ
await openCreationHub(user);
const rpgButton = await screen.findByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect(requireAuth).not.toHaveBeenCalled();
expect(requireAuth).toHaveBeenCalledTimes(1);
expect(createRpgCreationSession).not.toHaveBeenCalled();
});
@@ -2653,16 +2754,16 @@ test('new creation entry maps raw bearer token errors to user-facing auth copy',
await openCreationHub(user);
const rpgButton = screen.getByRole('button', { name: //u });
expect((rpgButton as HTMLButtonElement).disabled).toBe(true);
expect((rpgButton as HTMLButtonElement).disabled).toBe(false);
await user.click(rpgButton);
expect(listPuzzleWorks).toHaveBeenCalled();
expect(createRpgCreationSession).not.toHaveBeenCalled();
expect(createRpgCreationSession).toHaveBeenCalledTimes(1);
expect(
within(getPlatformTabPanel('create')).queryByText(
await within(getPlatformTabPanel('create')).findByText(
'当前登录状态已失效,请重新登录后继续。',
),
).toBeNull();
).toBeTruthy();
expect(screen.queryByText('缺少 Authorization Bearer Token')).toBeNull();
});
@@ -2979,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 = {

View File

@@ -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: '玩家',

View File

@@ -0,0 +1,69 @@
/**
* 新建作品入口配置。
* 修改入口开放状态、隐藏状态和展示文案时,优先调整本文件,避免多入口文案漂移。
*/
export const NEW_WORK_ENTRY_CONFIG = {
startCard: {
title: '新建作品',
description: '直接选择游戏创作模板,立刻进入对应的共创工作台。',
idleBadge: '选择模板',
busyBadge: '正在开启',
},
typeModal: {
title: '选择创作类型',
description: '先选玩法类型,再进入对应创作工作台。',
},
creationTypes: [
{
id: 'rpg',
title: '角色扮演',
subtitle: '敬请期待',
badge: '敬请期待',
visible: true,
open: true,
},
{
id: 'big-fish',
title: '大鱼吃小鱼',
subtitle: '实时成长玩法',
badge: '可创建',
visible: false,
open: true,
},
{
id: 'puzzle',
title: '拼图',
subtitle: '创意礼物,生活分享',
badge: '可创建',
visible: true,
open: true,
},
{
id: 'match3d',
title: '抓大鹅',
subtitle: '经典消除玩法',
badge: '可创建',
visible: true,
open: true,
},
{
id: 'airp',
title: 'AIRP',
subtitle: '敬请期待',
badge: '敬请期待',
visible: true,
open: false,
},
{
id: 'visual-novel',
title: '视觉小说',
subtitle: '敬请期待',
badge: '敬请期待',
visible: true,
open: false,
},
],
} as const;
export type NewWorkEntryCreationTypeId =
(typeof NEW_WORK_ENTRY_CONFIG.creationTypes)[number]['id'];