From 33dd105630cca4fca0ae29c5c1b8f272ef25973f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Fri, 1 May 2026 22:16:01 +0800 Subject: [PATCH 1/3] 1 --- .env.example | 5 + ...DEL_ROUTING_RPG_AND_CREATION_2026-04-30.md | 9 +- .../NEW_WORK_ENTRY_CONFIG_2026-05-01.md | 33 ++ ..._APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md | 69 +++ docs/technical/README.md | 2 + ...5584-000001-parse_stream_failed.input.json | 18 + ...5584-000001-parse_stream_failed.output.txt | 131 +++++ ...9212-000001-parse_stream_failed.input.json | 18 + ...9212-000001-parse_stream_failed.output.txt | 117 +++++ ...9212-000002-parse_stream_failed.input.json | 18 + ...9212-000002-parse_stream_failed.output.txt | 117 +++++ ...9212-000003-parse_stream_failed.input.json | 18 + ...9212-000003-parse_stream_failed.output.txt | 155 ++++++ .../src/contracts/puzzleAgentActions.ts | 3 + .../src/contracts/puzzleAgentSession.ts | 9 +- server-rs/crates/api-server/src/config.rs | 18 + .../api-server/src/creation_agent_llm_turn.rs | 294 ++++++++++- server-rs/crates/api-server/src/puzzle.rs | 466 ++++++++++++++++-- server-rs/crates/module-auth/src/lib.rs | 3 +- server-rs/crates/module-puzzle/src/lib.rs | 5 +- .../shared-contracts/src/puzzle_agent.rs | 4 + ...ustomWorldCreationHub.interaction.test.tsx | 5 +- .../CustomWorldCreationHub.test.tsx | 1 - .../CustomWorldCreationStartCard.tsx | 9 +- .../PlatformEntryCreationTypeModal.tsx | 5 +- .../PlatformEntryFlowShellImpl.tsx | 230 +++++---- .../platformEntryCreationTypes.test.ts | 41 ++ .../platformEntryCreationTypes.ts | 69 +-- .../PuzzleAgentWorkspace.interaction.test.tsx | 36 ++ .../puzzle-agent/PuzzleAgentWorkspace.tsx | 26 + .../puzzle-agent/PuzzleImageModelPicker.tsx | 84 ++++ .../puzzle-agent/puzzleImageModelOptions.ts | 33 ++ .../puzzle-result/PuzzleResultView.test.tsx | 44 +- .../puzzle-result/PuzzleResultView.tsx | 45 +- ...gEntryFlowShell.agent.interaction.test.tsx | 26 +- src/config/newWorkEntryConfig.ts | 69 +++ 36 files changed, 1999 insertions(+), 236 deletions(-) create mode 100644 docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md create mode 100644 docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md create mode 100644 logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.input.json create mode 100644 logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.output.txt create mode 100644 logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.input.json create mode 100644 logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.output.txt create mode 100644 logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.input.json create mode 100644 logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.output.txt create mode 100644 logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.input.json create mode 100644 logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.output.txt create mode 100644 src/components/platform-entry/platformEntryCreationTypes.test.ts create mode 100644 src/components/puzzle-agent/PuzzleImageModelPicker.tsx create mode 100644 src/components/puzzle-agent/puzzleImageModelOptions.ts create mode 100644 src/config/newWorkEntryConfig.ts diff --git a/.env.example b/.env.example index 879be395..e8b61440 100644 --- a/.env.example +++ b/.env.example @@ -119,6 +119,11 @@ RPG_LLM_WEB_SEARCH_ENABLED="true" DASHSCOPE_BASE_URL="https://dashscope.aliyuncs.com/api/v1" DASHSCOPE_API_KEY="YOUR_DASHSCOPE_API_KEY" +# Server-side APIMart image generation config for optional puzzle image models. +APIMART_BASE_URL="https://api.apimart.ai/v1" +APIMART_API_KEY="YOUR_APIMART_API_KEY" +APIMART_IMAGE_REQUEST_TIMEOUT_MS="180000" + # 阿里云 OSS 配置。 # Rust `server-rs` 的 `api-server` 会优先从 `.env` / `.env.local` 读取这些变量, # 用于签发浏览器 PostObject 直传票据,并保持 `/generated-*` 旧路径习惯。 diff --git a/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md b/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md index 3b7bb215..d9dc20f8 100644 --- a/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md +++ b/docs/technical/LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md @@ -39,6 +39,8 @@ - protocol: `/responses` - web_search: 开启时映射为 `tools: [{ "type": "web_search", "max_keyword": 3 }]` +联网搜索是模板创作的增强能力,不是 Agent 对话主链的硬依赖。若上游 Responses SSE 返回 `ToolNotOpen` 或“账号未开通 web search”类错误,`creation_agent_llm_turn` 必须使用同一 system/user prompt 自动降级为无联网搜索重试一次;只有重试仍失败时才向玩法层返回“生成失败”。这样 RPG Agent、大鱼 Agent、拼图 Agent 都能在未开通搜索插件的环境中继续完成基础 JSON turn。 + 覆盖入口: 1. `creation_agent_llm_turn.rs` @@ -74,6 +76,7 @@ Responses 非流式解析优先读取 `output_text`,再兼容 `output[].conten 1. RPG 运行时 LLM 请求在代码层显式带 `doubao-seed-character-251128`。 2. 创作模板 LLM 请求在代码层显式带 `deepseek-v3-2-251201` 与 Responses 协议。 3. `platform-llm` 单测覆盖 Responses 非流式、Responses SSE、Responses web_search tools 请求体。 -4. `cargo test -p platform-llm --manifest-path server-rs/Cargo.toml` 通过。 -5. `cargo test -p api-server creation_agent_llm_turn --manifest-path server-rs/Cargo.toml` 通过。 -6. 修改后按项目约束使用 `npm run api-server:maincloud` 重新启动后端,并执行相应自动测试。 +4. `api-server` 单测覆盖创作 Agent 对 `ToolNotOpen` / 未开通 web search 错误的识别,确保可触发无搜索重试。 +5. `cargo test -p platform-llm --manifest-path server-rs/Cargo.toml` 通过。 +6. `cargo test -p api-server creation_agent_llm_turn --manifest-path server-rs/Cargo.toml` 通过。 +7. 修改后按项目约束使用 `npm run api-server:maincloud` 重新启动后端,并执行相应自动测试。 diff --git a/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md new file mode 100644 index 00000000..0acb3623 --- /dev/null +++ b/docs/technical/NEW_WORK_ENTRY_CONFIG_2026-05-01.md @@ -0,0 +1,33 @@ +# 新建作品入口配置说明 2026-05-01 + +## 背景 + +创作中心顶部“新建作品”入口和平台创作类型弹层都依赖同一组玩法模板。此前入口开放状态、隐藏状态和中文文案集中写在 `src/components/platform-entry/platformEntryCreationTypes.ts` 与入口组件中,后续切换玩法开放节奏时容易出现多个入口不一致。 + +## 落地规则 + +1. 新建作品入口配置统一放在 `src/config/newWorkEntryConfig.ts`。 +2. `visible` 控制玩法是否展示在新建作品入口和创作类型弹层中。 +3. `open` 控制玩法是否允许点击创建;`open: false` 时入口保持展示但禁用。 +4. `title`、`subtitle`、`badge` 控制玩法卡片文案。 +5. `startCard` 控制创作中心顶部新建作品模块的标题、辅助文案和移动端角标文案。 +6. `typeModal` 控制平台创作类型弹层标题和描述。 +7. 入口排序仍遵循“可创建玩法在前,未开放玩法在后”;同组内部沿用配置顺序。 + +## 当前状态 + +| 玩法 | 展示 | 开放 | 说明 | +| --- | --- | --- | --- | +| 角色扮演 | 是 | 是 | 点击后进入 RPG Agent 共创工作台 | +| 大鱼吃小鱼 | 否 | 是 | 功能仍保留,不在新建作品入口展示 | +| 拼图 | 是 | 是 | 当前唯一可见且可创建入口 | +| 抓大鹅 | 是 | 否 | 保留入口,显示敬请期待 | +| AIRP | 是 | 否 | 保留入口,显示敬请期待 | +| 视觉小说 | 是 | 否 | 保留入口,显示敬请期待 | + +## 验收 + +1. 修改 `src/config/newWorkEntryConfig.ts` 后,创作中心顶部卡带和平台创作类型弹层应同步变化。 +2. 隐藏玩法不触发入口预加载,也不出现在新建作品入口中。 +3. 未开放玩法点击态保持禁用,不应进入鉴权或创建会话链路。 +4. 已开放玩法点击后必须进入对应创建链路;若用户未登录,先走登录保护。 diff --git a/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md new file mode 100644 index 00000000..2bdf08ff --- /dev/null +++ b/docs/technical/PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md @@ -0,0 +1,69 @@ +# 拼图 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。 + +## 模型选项 + +拼图图片生成支持三个选项: + +| 前端显示 | 请求值 | 上游 | +| --- | --- | --- | +| 原模型 | `original` | 继续使用现有 DashScope 文生图 / 图生图链路 | +| `gpt-image-2` | `gpt-image-2` | APIMart `/v1/images/generations` | +| `nanobanana2` | `gemini-3.1-flash-image-preview` | APIMart `/v1/images/generations` | + +默认值为 `original`。前端只负责展示和传递所选模型,不能把模型路由逻辑、上游请求体拼装或 API Key 暴露到浏览器。 + +## 前端交互 + +1. 拼图创作表单的“画面描述”输入框左下角显示当前调用模型。 +2. 拼图结果页关卡详情的“画面描述”输入框左下角同样显示当前调用模型。 +3. 点击模型标识弹出轻量选择菜单,支持 `原模型`、`gpt-image-2`、`nanobanana2` 三项。 +4. 菜单只是模型选择控件,不写入说明性规则文案。 +5. 参考图入口继续在画面描述输入框右下角,模型选择在左下角,两者不得遮挡文本。 +6. 表单创建 session、自动保存表单草稿、首图生成失败重试时都要保留当前模型选择。 +7. 结果页关卡重新生成时将当前模型随 `generate_puzzle_images` action 传给后端。 + +## 后端路由 + +1. `CreatePuzzleAgentSessionRequest` 与 `ExecutePuzzleAgentActionRequest` 增加可选 `imageModel` 字段;该字段不进入 SpacetimeDB reducer 输入结构。 +2. `compile_puzzle_draft_with_initial_cover` 与 `generate_puzzle_image_candidates` 增加图片模型参数。 +3. `imageModel` 归一化规则: + - 空值、`original` 或未知值统一回落为原 DashScope 链路; + - `gpt-image-2` 走 APIMart; + - `gemini-3.1-flash-image-preview` 走 APIMart,前端显示名为 `nanobanana2`。 +4. APIMart 文生图和图生图共用 `POST /v1/images/generations`。有参考图时,后端将参考图 Data URL 作为 `image_urls` 数组传入;若上游不接受该字段,错误按上游失败返回,不在前端降级伪造结果。 +5. APIMart 尺寸使用文档要求的比例写法 `1:1`;原 DashScope 继续使用像素写法 `1024*1024`。`gemini-3.1-flash-image-preview` 额外带 `resolution = "1K"`,对齐约 1024px 的拼图正方形素材。 +6. APIMart 生成成功后仍下载远程图片,沿用现有 OSS 私有对象、`asset_object` 和 `asset_entity_binding` 写入流程。 +7. APIMart 错误统一映射为 `502 UPSTREAM_ERROR`,`details.provider = "apimart"`,保留上游状态码、业务 message 和截断后的 raw excerpt。 + +## 环境变量 + +新增服务端环境变量: + +```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`。 +2. 点击“生成草稿”时,后端首图生成使用当前表单选择的模型。 +3. 点击“生成画面 / 重新生成画面”时,后端当前关卡图片生成使用关卡详情选择的模型。 +4. 选择 `original` 时,现有 DashScope 请求体、尺寸和错误处理保持兼容。 +5. 选择 APIMart 模型时,请求 `POST {APIMART_BASE_URL}/images/generations`,使用 `Authorization: Bearer {APIMART_API_KEY}`,`model` 等于请求值,`size = 1:1`。 +6. 不改 SpacetimeDB 表结构,因此无需更新 `migration.rs` 或重新生成 bindings。 +7. 后端改动后运行对应 Rust 测试,并按项目约束用 `npm run api-server:maincloud` 重启验证。 diff --git a/docs/technical/README.md b/docs/technical/README.md index a694ceaf..6cee2ff0 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,8 @@ ## 文档列表 - [PRODUCT_NAMING_BAIMENG_RENAME_2026-05-01.md](./PRODUCT_NAMING_BAIMENG_RENAME_2026-05-01.md):冻结当前对外中文命名,产品展示名统一为“百梦”,消费单位为“光点”,公开账号标识为“百梦号”,创作侧称谓为“百梦主”。 +- [NEW_WORK_ENTRY_CONFIG_2026-05-01.md](./NEW_WORK_ENTRY_CONFIG_2026-05-01.md):记录新建作品入口开放状态、隐藏状态和展示文案统一收口到 `src/config/newWorkEntryConfig.ts` 的配置规则。 +- [PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md](./PUZZLE_APIMART_IMAGE_MODEL_ROUTING_2026-05-01.md):记录拼图图片生成接入 APIMart `gpt-image-2`、`gemini-3.1-flash-image-preview`,以及画面描述框内模型切换与后端路由边界。 - [SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md](./SPACETIMEDB_SCHEMA_CHANGE_CONSTRAINTS.md):冻结 SpacetimeDB 表结构变更约束、自动迁移可接受范围、冲突后的系统行为,以及保留旧数据的增量迁移流程;凡涉及 `spacetime publish`、表字段调整或 `migration.rs` 对齐时优先参考。 - [SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md](./SPACETIMEDB_LOCAL_REPLICA_IDENTITY_MISMATCH_FIX_2026-04-30.md):记录本地 standalone 启动时报 `mismatched database identity` 的 root-dir/replica 数据残留根因、备份重建步骤和脚本诊断口径。 - [LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md](./LLM_MODEL_ROUTING_RPG_AND_CREATION_2026-04-30.md):冻结 RPG 运行时剧情推理使用 `doubao-seed-character-251128` 的 `/chat/completions`,以及所有模板创作大模型推理使用 `deepseek-v3-2-251201` 的 `/responses`。 diff --git a/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.input.json b/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.input.json new file mode 100644 index 00000000..88d1a6c5 --- /dev/null +++ b/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": true, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:bootstrap\n\n目标:\n1. 先把世界的基本方向抓住\n2. 不要一次塞太多新设定\n3. 回复要降低用户开口压力\n\n本轮行为要求:\n1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索\n2. 如果用户信息很少,不要强行把整套结构一次补满\n3. replyText 要像共创搭档,而不是像审问\n4. 默认只推进一个最关键的问题方向\n5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步\n6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题\n7. 不要把问题问得像表单采集,不要一口气追问多个维度\n\n用户体验要求:\n1. 让用户觉得“现在很容易继续往下说”\n2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉\n3. replyText 最好短、稳、可接话\n4. 如果用户信息很少,也不要显得冷淡或机械\n\n本轮用户输入较少或较虚。\n请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。\nreplyText 要让用户容易继续往下说。\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: sparse\n- driftRisk: low\n- conversationMode: bootstrap\n- judgementSummary: 用户仅提供了一个非常概括的题材词,信息密度极低,处于最初始阶段。正式生成时应使用启发式提问,提出一个最易于回答、最能推动设计落地的问题,比如关于这个王国的性质或主角身份。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": null,\n \"playerFantasy\": null,\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" + }, + { + "role": "user", + "content": "请按约定输出这一轮的 JSON。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.output.txt b/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.output.txt new file mode 100644 index 00000000..6397774e --- /dev/null +++ b/logs/llm-raw/1777642667085-45584-000001-parse_stream_failed.output.txt @@ -0,0 +1,131 @@ +event: response.created +data: {"type":"response.created","response":{"created_at":1777642663,"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"created_at":1777642663,"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"了解","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"的","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"创作","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"背景","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"和","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"灵感","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"来源","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"帮助你","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"构建","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"这个","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"游戏","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"世界","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"让我","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":28} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":29} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":30} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"相关信息","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":31} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"sequence_number":32} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n","sequence_number":33} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764266332000000000000000000000ffffac1550372ec101","output_index":0,"part":{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"},"sequence_number":34} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"}],"status":"completed","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},"sequence_number":35} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"},"sequence_number":36} + +event: response.web_search_call.in_progress +data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":37} + +event: response.web_search_call.searching +data: {"type":"response.web_search_call.searching","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":38} + +event: response.web_search_call.completed +data: {"type":"response.web_search_call.completed","item_id":"ws_02177764266480300000000000000000000ffffac155037f1bc06","output_index":1,"sequence_number":39} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 游戏设定 世界观;玩具题材游戏 设计灵感;王国主题玩具世界","type":"search"},"status":"completed","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"},"sequence_number":40} + +event: error +data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":41} + +event: response.failed +data: {"type":"response.failed","response":{"created_at":1777642663,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_021777642661795d15b81097ef4f266330d7ca8118c9494434eb3","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先了解一些关于\"玩具王国\"的创作背景和灵感来源,以便更好地帮助你构建这个游戏世界。让我搜索一些相关信息。\n\n"}],"status":"completed","id":"msg_02177764266332000000000000000000000ffffac1550372ec101"},{"type":"web_search_call","action":{"query":"玩具王国 游戏设定 世界观;玩具题材游戏 设计灵感;王国主题玩具世界","type":"search"},"status":"completed","id":"ws_02177764266480300000000000000000000ffffac155037f1bc06"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":1791,"output_tokens":122,"total_tokens":1913,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777901861},"sequence_number":42} + +data: [DONE] + diff --git a/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.input.json b/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.input.json new file mode 100644 index 00000000..ca3df2cf --- /dev/null +++ b/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": true, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:bootstrap\n\n目标:\n1. 先把世界的基本方向抓住\n2. 不要一次塞太多新设定\n3. 回复要降低用户开口压力\n\n本轮行为要求:\n1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索\n2. 如果用户信息很少,不要强行把整套结构一次补满\n3. replyText 要像共创搭档,而不是像审问\n4. 默认只推进一个最关键的问题方向\n5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步\n6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题\n7. 不要把问题问得像表单采集,不要一口气追问多个维度\n\n用户体验要求:\n1. 让用户觉得“现在很容易继续往下说”\n2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉\n3. replyText 最好短、稳、可接话\n4. 如果用户信息很少,也不要显得冷淡或机械\n\n本轮用户输入较少或较虚。\n请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。\nreplyText 要让用户容易继续往下说。\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: sparse\n- driftRisk: low\n- conversationMode: bootstrap\n- judgementSummary: 用户仅提供了核心题材词,信息非常稀疏,尚未形成任何具体方向。正式生成时,应避免空泛回应,必须采用一个启发式提问来推动设计落地,例如聚焦于玩家身份或世界氛围。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": null,\n \"playerFantasy\": null,\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" + }, + { + "role": "user", + "content": "请按约定输出这一轮的 JSON。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.output.txt b/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.output.txt new file mode 100644 index 00000000..d1ee8032 --- /dev/null +++ b/logs/llm-raw/1777644758257-49212-000001-parse_stream_failed.output.txt @@ -0,0 +1,117 @@ +event: response.created +data: {"type":"response.created","response":{"created_at":1777644754,"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"created_at":1777644754,"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"的相关","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"信息","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"理解","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"这个概念","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"为","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"我们的","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"共创","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"提供","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"更有","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"价值的","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"参考","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"sequence_number":28} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n","sequence_number":29} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d","output_index":0,"part":{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"},"sequence_number":30} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"}],"status":"completed","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},"sequence_number":31} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"},"sequence_number":32} + +event: response.web_search_call.in_progress +data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":33} + +event: response.web_search_call.searching +data: {"type":"response.web_search_call.searching","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":34} + +event: response.web_search_call.completed +data: {"type":"response.web_search_call.completed","item_id":"ws_02177764475609200000000000000000000ffffac15dae8e54616","output_index":1,"sequence_number":35} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 游戏设定;玩具王国 世界观;玩具主题 游戏","type":"search"},"status":"completed","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"},"sequence_number":36} + +event: error +data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":37} + +event: response.failed +data: {"type":"response.failed","response":{"created_at":1777644754,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_02177764475392548aafbb39a6fef3e9b38f2299115036678748a","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"的相关信息,以便更好地理解这个概念,为我们的共创提供更有价值的参考。\n\n"}],"status":"completed","id":"msg_02177764475480800000000000000000000ffffac15dae87fba6d"},{"type":"web_search_call","action":{"query":"玩具王国 游戏设定;玩具王国 世界观;玩具主题 游戏","type":"search"},"status":"completed","id":"ws_02177764475609200000000000000000000ffffac15dae8e54616"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":1808,"output_tokens":120,"total_tokens":1928,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777903953},"sequence_number":38} + diff --git a/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.input.json b/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.input.json new file mode 100644 index 00000000..5e3d8b4f --- /dev/null +++ b/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": true, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:bootstrap\n\n目标:\n1. 先把世界的基本方向抓住\n2. 不要一次塞太多新设定\n3. 回复要降低用户开口压力\n\n本轮行为要求:\n1. 优先从用户输入里抓世界方向、玩家视角、主题边界的线索\n2. 如果用户信息很少,不要强行把整套结构一次补满\n3. replyText 要像共创搭档,而不是像审问\n4. 默认只推进一个最关键的问题方向\n5. 如果用户刚开口,优先给“被理解感”,再轻轻推进下一步\n6. 可以用一句很短的话先确认你抓到的核心方向,再提一个最好回答的问题\n7. 不要把问题问得像表单采集,不要一口气追问多个维度\n\n用户体验要求:\n1. 让用户觉得“现在很容易继续往下说”\n2. 不要制造被考试、被拷问、被策划问卷追着跑的感觉\n3. replyText 最好短、稳、可接话\n4. 如果用户信息很少,也不要显得冷淡或机械\n\n本轮用户输入较少或较虚。\n请保留上一版中仍然成立的内容,不要为了凑完整度而强行发明过多新设定。\nreplyText 要让用户容易继续往下说。\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: sparse\n- driftRisk: low\n- conversationMode: bootstrap\n- judgementSummary: 用户输入简短,仅确认了玩家身份方向,信息密度低。正式生成时应采用启发式提问,聚焦于让这个身份更具体、更有故事感,例如询问孩子为何闯入或初始处境。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": \"一个由玩具构成的鲜活王国,等待玩家探索与守护。\",\n \"playerFantasy\": null,\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"好的,我捕捉到核心了:一个关于“玩具王国”的世界。为了让它变得具体,我们可以先聊聊玩家在这个王国里会是谁?比如,是某个玩具的守护者,还是一个闯入这里的人类孩子?\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"误闯入这里的人类孩子\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" + }, + { + "role": "user", + "content": "请按约定输出这一轮的 JSON。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.output.txt b/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.output.txt new file mode 100644 index 00000000..948a5618 --- /dev/null +++ b/logs/llm-raw/1777644798047-49212-000002-parse_stream_failed.output.txt @@ -0,0 +1,117 @@ +event: response.created +data: {"type":"response.created","response":{"created_at":1777644794,"id":"resp_02177764479383248aafbb39a6fef3e9b38f22991150366ae7197","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903993},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"created_at":1777644794,"id":"resp_02177764479383248aafbb39a6fef3e9b38f22991150366ae7197","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777903993},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764479470200000000000000000000ffffac1549fec91d99"},"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"和","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"误","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"闯入","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"的人类","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"孩子","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"\"","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"的","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"创意","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"灵感","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"来","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"帮助我们","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"构建","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"这个世界","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":28} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"sequence_number":29} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n","sequence_number":30} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764479470200000000000000000000ffffac1549fec91d99","output_index":0,"part":{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n"},"sequence_number":31} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要先搜索一些关于\"玩具王国\"和\"误闯入的人类孩子\"的创意灵感,来帮助我们更好地构建这个世界。\n\n"}],"status":"completed","id":"msg_02177764479470200000000000000000000ffffac1549fec91d99"},"sequence_number":32} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df"},"sequence_number":33} + +event: response.web_search_call.in_progress +data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":34} + +event: response.web_search_call.searching +data: {"type":"response.web_search_call.searching","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":35} + +event: response.web_search_call.completed +data: {"type":"response.web_search_call.completed","item_id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df","output_index":1,"sequence_number":36} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国创意设定;人类孩子误入奇幻世界;玩具主题游戏世界观","type":"search"},"status":"completed","id":"ws_02177764479607400000000000000000000ffffac1549fee3e1df"},"sequence_number":37} + +event: error +data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":38} + diff --git a/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.input.json b/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.input.json new file mode 100644 index 00000000..fdc46d60 --- /dev/null +++ b/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": true, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是一个负责共创游戏世界设定的专业策划。\n\n你正在和用户一起共创一个游戏世界。每一轮你都必须读取:\n1. 当前完整设定结构\n2. 用户聊天记录\n\n然后输出:\n1. 一版新的完整设定结构\n2. 当前 progress 百分比\n3. 一段直接回复用户的话\n\n你必须把“新的完整设定结构”视为下一轮的唯一有效版本。\n你的输出会直接覆盖上一版设定结构。\n\n你不是在做局部 patch。\n你不是在做解释报告。\n你不是在给开发者写分析。\n你是在同时完成:\n1. 世界设定更新\n2. 当前推进程度判断\n3. 对用户的共创回复\n\n全局硬约束:\n\n1. 必须输出完整的设定结构,而不是只输出变化部分。\n2. 新的设定结构会直接覆盖旧内容,因此不得随意丢失仍然成立的重要信息。\n3. 如果用户明确修正旧设定,必须在新的设定结构中直接体现修正结果。\n4. 如果用户输入信息不足,可以保留上一版中仍然成立的内容。\n5. progressPercent 最低为 0,不允许为负数。\n6. replyText 会直接发送给用户,因此要自然、直接、可继续聊天。\n7. 不要输出额外解释,不要输出 markdown 代码块,不要输出开发备注。\n8. replyText 不要写成长篇策划文,不要展开大段世界观百科。\n9. replyText 默认只推进当前最关键的一步,不要同时抛出很多话题。\n10. replyText 不要提及“八锚点”“锚点”“结构字段”“框架字段”等内部概念词。\n11. 你输出的 JSON 必须可以被直接解析。\n12. 输出字段顺序必须固定为:replyText、progressPercent、nextAnchorContent。\n\n当前模式:force_complete\n\n目标:\n1. 基于当前方向直接补齐剩余设定\n2. 生成一版尽量完整、可进入下一阶段的设定结构\n3. 结束当前收集阶段\n\n本轮行为要求:\n1. 尽量保留已经形成的世界方向\n2. 对明显缺失的关键维度进行合理补全\n3. 不要继续拉长聊天,不要再追问用户\n4. progressPercent 直接输出为 100\n5. replyText 要自然引导用户点击“生成游戏设定草稿”\n6. 补全时要优先做“顺着已有方向补齐”,而不是突然换题材、换气质、换主冲突\n7. 可以让结果更完整,但不要补得过满、过死、过像定稿圣经\n8. replyText 更像阶段完成提示,不再像继续采集信息的对话\n\n用户体验要求:\n1. 让用户感到“系统已经帮我把能补的补好了”\n2. 不要在这一步突然冒出很多陌生设定把用户吓出戏\n3. 回复要有完成感,但不要太官话\n4. 清楚告诉用户下一步可以做什么\n\n本轮用户把部分决定权交给你。\n你可以在 replyText 中给出有限度的建议,但不要突然补满整套设定。\n新的完整设定结构仍应尽量建立在已有世界方向上,而不是完全重做。\n\n用户刚刚主动要求你自动补充剩余关键字。\n\n这表示用户接受你基于当前方向自行补完仍缺失的关键设定:当前 RPG 世界方向里的剩余设定\n\n本轮要求:\n1. 不要再继续提问\n2. 不要要求用户再提供世界观、角色、冲突或禁忌信息\n3. 必须保留已有已确认内容,并直接补齐缺失或仍为空的关键项\n4. 对你自行推断补齐的项,应标记或表达为系统推断;已有明确内容继续保持确认或锁定状态\n5. progressPercent 直接输出为 100\n6. 直接输出一版尽量完整的设定结构\n7. replyText 只做简短完成说明,引导用户可以进入“生成游戏设定草稿”,不能出现问号\n\n上一轮预判得到的创作状态如下。\n正式生成时必须把它作为本轮策略输入直接执行,不要重新另起一套判断。\n\n创作状态:\n- userInputSignal: delegate\n- driftRisk: low\n- conversationMode: force_complete\n- judgementSummary: 用户明确授权系统自动补全剩余设定,应停止提问,直接生成完整且自洽的世界设定,并确保与已有方向一致。\n\n当前完整设定结构如下。\n你必须把它视为上一版有效世界底子。\n\n如果用户没有否定其中某部分内容,且该部分仍然成立,可以继续保留。\n如果用户明确修正了某部分内容,新的完整设定结构必须体现修正后的版本。\n\n当前完整设定结构:\n{\n \"worldPromise\": \"一个由玩具构成的鲜活王国,等待玩家探索与守护。\",\n \"playerFantasy\": \"作为一个误入的人类孩子,你渴望找到回家的路,同时在这个充满秘密与危险的玩具王国中找到自己的位置。\",\n \"themeBoundary\": null,\n \"playerEntryPoint\": null,\n \"coreConflict\": null,\n \"keyRelationships\": null,\n \"hiddenLines\": null,\n \"iconicElements\": null\n}\n\n以下是用户聊天记录。\n请重点理解最近几轮里用户新增、修正、强调的设定信息。\n不要把早期已经被用户否定的内容继续当成最终结论。\n\n用户聊天记录:\n[\n {\n \"content\": \"我会先帮你把世界的核心锚点整理出来。你可以从世界钩子、玩家身份、主题氛围、核心冲突、关键关系或标志性元素开始。\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"玩具王国\",\n \"role\": \"user\"\n },\n {\n \"content\": \"好的,我捕捉到核心了:一个关于“玩具王国”的世界。为了让它变得具体,我们可以先聊聊玩家在这个王国里会是谁?比如,是某个玩具的守护者,还是一个闯入这里的人类孩子?\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"误闯入这里的人类孩子\",\n \"role\": \"user\"\n },\n {\n \"content\": \"明白了,一个误闯入玩具王国的孩子。那么,这个孩子是怀着怎样的心情,又是怎样闯进这里的?比如,是躲进衣柜时无意发现的,还是因为某个特别的玩具?\",\n \"role\": \"assistant\"\n },\n {\n \"content\": \"请补充剩余设定。\",\n \"role\": \"user\"\n }\n]\n\n请严格按以下 JSON 结构输出,不要输出其他文字:\n{\n \"replyText\": \"\",\n \"progressPercent\": 0,\n \"nextAnchorContent\": {\n \"worldPromise\": \"\",\n \"playerFantasy\": \"\",\n \"themeBoundary\": \"\",\n \"playerEntryPoint\": \"\",\n \"coreConflict\": \"\",\n \"keyRelationships\": \"\",\n \"hiddenLines\": \"\",\n \"iconicElements\": \"\"\n }\n}\n\nnextAnchorContent 的 8 个锚点每个都只能是一个字符串或 null,不允许输出对象或数组。\n请把每个锚点写成一段凝练中文:\n- worldPromise 关注世界钩子、差异点、玩家体验。\n- playerFantasy 关注玩家身份、核心追求、失去风险。\n- themeBoundary 关注主题气质、美术方向、禁用方向。\n- playerEntryPoint 关注开局身份、开局问题、行动动机。\n- coreConflict 关注表层冲突、隐藏危机、首次触发点。\n- keyRelationships 关注关键人物关系、关系类型、代价或秘密。\n- hiddenLines 关注隐藏真相、误导线索、揭示节奏。\n- iconicElements 关注标志意象、组织/物件、硬规则。\n" + }, + { + "role": "user", + "content": "请按约定输出这一轮的 JSON。" + } + ] +} \ No newline at end of file diff --git a/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.output.txt b/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.output.txt new file mode 100644 index 00000000..0ff6ee84 --- /dev/null +++ b/logs/llm-raw/1777644843633-49212-000003-parse_stream_failed.output.txt @@ -0,0 +1,155 @@ +event: response.created +data: {"type":"response.created","response":{"created_at":1777644839,"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":0} + +event: response.in_progress +data: {"type":"response.in_progress","response":{"created_at":1777644839,"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","service_tier":"default","tools":[{"type":"web_search","max_keyword":3}],"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":1} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","role":"assistant","status":"in_progress","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},"sequence_number":2} + +event: response.content_part.added +data: {"type":"response.content_part.added","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"part":{"type":"output_text","text":""},"sequence_number":3} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"我需要","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":4} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"基于","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":5} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"当前的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":6} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":7} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":8} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":9} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":10} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"为用户","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":11} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"补","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":12} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"全","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":13} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"剩余的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":14} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":15} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"内容","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":16} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":17} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"让我","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":18} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"先","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":19} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"搜索","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":20} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"一些","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":21} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"关于","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":22} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"玩具","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":23} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"王国","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":24} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"、","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":25} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"童话","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":26} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":27} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"、","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":28} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"儿童","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":29} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"奇幻","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":30} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"世界的","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":31} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"参考","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":32} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"信息","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":33} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":",","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":34} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"以便","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":35} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"更好地","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":36} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"完善","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":37} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"这个","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":38} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"设定","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":39} + +event: response.output_text.delta +data: {"type":"response.output_text.delta","content_index":0,"delta":"。\n\n","item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"sequence_number":40} + +event: response.output_text.done +data: {"type":"response.output_text.done","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n","sequence_number":41} + +event: response.content_part.done +data: {"type":"response.content_part.done","content_index":0,"item_id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581","output_index":0,"part":{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"},"sequence_number":42} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":0,"item":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"}],"status":"completed","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},"sequence_number":43} + +event: response.output_item.added +data: {"type":"response.output_item.added","output_index":1,"item":{"type":"web_search_call","status":"in_progress","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"},"sequence_number":44} + +event: response.web_search_call.in_progress +data: {"type":"response.web_search_call.in_progress","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":45} + +event: response.web_search_call.searching +data: {"type":"response.web_search_call.searching","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":46} + +event: response.web_search_call.completed +data: {"type":"response.web_search_call.completed","item_id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5","output_index":1,"sequence_number":47} + +event: response.output_item.done +data: {"type":"response.output_item.done","output_index":1,"item":{"type":"web_search_call","action":{"query":"玩具王国 童话设定;儿童奇幻世界 游戏设定;玩具主题 RPG 游戏","type":"search"},"status":"completed","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"},"sequence_number":48} + +event: error +data: {"type":"error","code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin","param":"","sequence_number":49} + +event: response.failed +data: {"type":"response.failed","response":{"created_at":1777644839,"error":{"code":"ToolNotOpen","message":"Your account has not activated web search. You may activate it at https://console.volcengine.com/common-buy/CC_content_plugin"},"id":"resp_02177764483844428a04e35a69bbadd2802274f74f3d7f05d787b","max_output_tokens":32768,"model":"deepseek-v3-2-251201","object":"response","output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"我需要基于当前的玩具王国设定,为用户补全剩余的设定内容。让我先搜索一些关于玩具王国、童话设定、儿童奇幻世界的参考信息,以便更好地完善这个设定。\n\n"}],"status":"completed","id":"msg_02177764483994400000000000000000000ffffac151e4c1d2581"},{"type":"web_search_call","action":{"query":"玩具王国 童话设定;儿童奇幻世界 游戏设定;玩具主题 RPG 游戏","type":"search"},"status":"completed","id":"ws_02177764484182900000000000000000000ffffac151e4ca524c5"}],"service_tier":"default","status":"failed","tools":[{"type":"web_search","max_keyword":3}],"usage":{"input_tokens":2173,"output_tokens":130,"total_tokens":2303,"input_tokens_details":{"cached_tokens":0},"output_tokens_details":{"reasoning_tokens":0}},"caching":{"type":"disabled"},"store":true,"expire_at":1777904038},"sequence_number":50} + +data: [DONE] + diff --git a/packages/shared/src/contracts/puzzleAgentActions.ts b/packages/shared/src/contracts/puzzleAgentActions.ts index 5d96445c..98ecbba7 100644 --- a/packages/shared/src/contracts/puzzleAgentActions.ts +++ b/packages/shared/src/contracts/puzzleAgentActions.ts @@ -46,6 +46,7 @@ export type PuzzleAgentActionRequest = workTitle?: string; workDescription?: string; pictureDescription?: string; + imageModel?: string | null; } | { action: 'compile_puzzle_draft'; @@ -54,6 +55,7 @@ export type PuzzleAgentActionRequest = workDescription?: string; pictureDescription?: string; referenceImageSrc?: string | null; + imageModel?: string | null; candidateCount?: number; } | { @@ -61,6 +63,7 @@ export type PuzzleAgentActionRequest = levelId?: string | null; promptText?: string | null; referenceImageSrc?: string | null; + imageModel?: string | null; candidateCount?: number; levelsJson?: string; } diff --git a/packages/shared/src/contracts/puzzleAgentSession.ts b/packages/shared/src/contracts/puzzleAgentSession.ts index f580f0a6..fe53c241 100644 --- a/packages/shared/src/contracts/puzzleAgentSession.ts +++ b/packages/shared/src/contracts/puzzleAgentSession.ts @@ -1,4 +1,7 @@ -import type { PuzzleAgentActionResponse, PuzzleAgentSuggestedAction } from './puzzleAgentActions'; +import type { + PuzzleAgentActionResponse, + PuzzleAgentSuggestedAction, +} from './puzzleAgentActions'; import type { PuzzleAnchorPack, PuzzleResultDraft } from './puzzleAgentDraft'; import type { PuzzleResultPreviewEnvelope } from './puzzleResultPreview'; @@ -47,6 +50,7 @@ export interface CreatePuzzleAgentSessionRequest { workDescription?: string; pictureDescription?: string; referenceImageSrc?: string | null; + imageModel?: string | null; } export interface CreatePuzzleAgentSessionResponse { @@ -59,6 +63,7 @@ export interface SendPuzzleAgentMessageRequest { quickFillRequested?: boolean; } -export interface SendPuzzleAgentMessageResponse extends PuzzleAgentActionResponse { +export interface SendPuzzleAgentMessageResponse + extends PuzzleAgentActionResponse { session: PuzzleAgentSessionSnapshot; } diff --git a/server-rs/crates/api-server/src/config.rs b/server-rs/crates/api-server/src/config.rs index 236059fe..7ba442af 100644 --- a/server-rs/crates/api-server/src/config.rs +++ b/server-rs/crates/api-server/src/config.rs @@ -90,6 +90,9 @@ pub struct AppConfig { pub dashscope_reference_image_model: String, pub dashscope_cover_image_model: String, pub dashscope_image_request_timeout_ms: u64, + pub apimart_base_url: String, + pub apimart_api_key: Option, + pub apimart_image_request_timeout_ms: u64, pub draft_asset_generation_max_concurrent_requests: usize, pub ark_character_video_base_url: String, pub ark_character_video_api_key: Option, @@ -182,6 +185,9 @@ impl Default for AppConfig { dashscope_reference_image_model: "qwen-image-2.0".to_string(), dashscope_cover_image_model: "wan2.2-t2i-flash".to_string(), dashscope_image_request_timeout_ms: 150_000, + apimart_base_url: "https://api.apimart.ai/v1".to_string(), + apimart_api_key: None, + apimart_image_request_timeout_ms: 180_000, draft_asset_generation_max_concurrent_requests: 4, ark_character_video_base_url: DEFAULT_ARK_BASE_URL.to_string(), ark_character_video_api_key: None, @@ -530,6 +536,18 @@ impl AppConfig { config.dashscope_image_request_timeout_ms = dashscope_image_request_timeout_ms; } + if let Some(apimart_base_url) = read_first_non_empty_env(&["APIMART_BASE_URL"]) { + config.apimart_base_url = apimart_base_url; + } + + config.apimart_api_key = read_first_non_empty_env(&["APIMART_API_KEY"]); + + if let Some(apimart_image_request_timeout_ms) = + read_first_positive_u64_env(&["APIMART_IMAGE_REQUEST_TIMEOUT_MS"]) + { + config.apimart_image_request_timeout_ms = apimart_image_request_timeout_ms; + } + if let Some(max_concurrent_requests) = read_first_usize_env(&[ "GENARRATIVE_DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS", "DRAFT_ASSET_GENERATION_MAX_CONCURRENT_REQUESTS", diff --git a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs index 7579d214..ffd92d65 100644 --- a/server-rs/crates/api-server/src/creation_agent_llm_turn.rs +++ b/server-rs/crates/api-server/src/creation_agent_llm_turn.rs @@ -1,4 +1,4 @@ -use platform_llm::{LlmClient, LlmMessage, LlmStreamDelta, LlmTextRequest}; +use platform_llm::{LlmClient, LlmError, LlmMessage, LlmStreamDelta, LlmTextRequest}; use serde_json::Value as JsonValue; use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; @@ -33,10 +33,63 @@ where { let llm_client = llm_client.ok_or_else(|| build_error(messages.model_unavailable.to_string()))?; + let user_prompt = user_prompt.into(); + let turn_output = match request_stream_creation_agent_json_turn( + llm_client, + system_prompt.clone(), + user_prompt.clone(), + enable_web_search, + &mut on_reply_update, + ) + .await + { + Ok(turn_output) => Ok(turn_output), + Err(CreationAgentJsonTurnFailure::Stream(error)) + if enable_web_search && is_web_search_tool_unavailable(&error) => + { + tracing::warn!( + error = %error, + "创作 Agent 联网搜索插件不可用,自动降级为无联网搜索重试" + ); + request_stream_creation_agent_json_turn( + llm_client, + system_prompt, + user_prompt, + false, + &mut on_reply_update, + ) + .await + } + Err(error) => Err(error), + }; + + turn_output.map_err(|error| match error { + CreationAgentJsonTurnFailure::Stream(_) => { + build_error(messages.generation_failed.to_string()) + } + CreationAgentJsonTurnFailure::Parse => build_error(messages.parse_failed.to_string()), + }) +} + +enum CreationAgentJsonTurnFailure { + Stream(LlmError), + Parse, +} + +async fn request_stream_creation_agent_json_turn( + llm_client: &LlmClient, + system_prompt: String, + user_prompt: String, + enable_web_search: bool, + on_reply_update: &mut F, +) -> Result +where + F: FnMut(&str), +{ let mut latest_reply_text = String::new(); let response = llm_client .stream_text( - build_creation_agent_llm_request(system_prompt, user_prompt.into(), enable_web_search), + build_creation_agent_llm_request(system_prompt, user_prompt, enable_web_search), |delta: &LlmStreamDelta| { if let Some(reply_progress) = extract_reply_text_from_partial_json(delta.accumulated_text.as_str()) @@ -48,9 +101,9 @@ where }, ) .await - .map_err(|_| build_error(messages.generation_failed.to_string()))?; + .map_err(CreationAgentJsonTurnFailure::Stream)?; let parsed = parse_json_response_text(response.content.as_str()) - .map_err(|_| build_error(messages.parse_failed.to_string()))?; + .map_err(|_| CreationAgentJsonTurnFailure::Parse)?; let reply_text = read_reply_text(&parsed); if let Some(reply_text) = reply_text.as_deref() && reply_text != latest_reply_text @@ -61,6 +114,13 @@ where Ok(CreationAgentJsonTurnOutput { parsed }) } +fn is_web_search_tool_unavailable(error: &LlmError) -> bool { + let message = error.to_string(); + message.contains("ToolNotOpen") + || message.contains("has not activated web search") + || message.contains("未开通") +} + fn build_creation_agent_llm_request( system_prompt: String, user_prompt: String, @@ -168,11 +228,23 @@ fn read_reply_text(parsed: &JsonValue) -> Option { #[cfg(test)] mod tests { + use std::{ + fs, + io::{Read, Write}, + net::TcpListener, + sync::{Arc, Mutex}, + thread, + time::{Duration as StdDuration, SystemTime, UNIX_EPOCH}, + }; + + use platform_llm::{LlmConfig, LlmProvider}; + use crate::llm_model_routing::CREATION_TEMPLATE_LLM_MODEL; use super::{ - build_creation_agent_llm_request, extract_reply_text_from_partial_json, - parse_json_response_text, + CreationAgentLlmTurnErrorMessages, build_creation_agent_llm_request, + extract_reply_text_from_partial_json, is_web_search_tool_unavailable, + parse_json_response_text, stream_creation_agent_json_turn, }; #[test] @@ -202,4 +274,214 @@ mod tests { assert_eq!(request.protocol, platform_llm::LlmTextProtocol::Responses); assert_eq!(request.messages.len(), 2); } + + #[test] + fn detects_upstream_web_search_tool_unavailable_error() { + let error = platform_llm::LlmError::Upstream { + status_code: 502, + message: "Your account has not activated web search. code=ToolNotOpen".to_string(), + }; + + assert!(is_web_search_tool_unavailable(&error)); + } + + #[tokio::test] + async fn stream_turn_retries_without_web_search_when_tool_is_unavailable() { + let log_dir = std::env::temp_dir().join(format!( + "api-server-creation-agent-raw-log-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after epoch") + .as_nanos() + )); + unsafe { + std::env::set_var("LLM_RAW_LOG_DIR", &log_dir); + } + let success_json = serde_json::json!({ + "replyText": "好,我们先把玩具王国定住。", + "progressPercent": 12, + "nextAnchorContent": { + "worldPromise": "玩具王国初步方向", + "playerFantasy": null, + "themeBoundary": null, + "playerEntryPoint": null, + "coreConflict": null, + "keyRelationships": null, + "hiddenLines": null, + "iconicElements": null + } + }) + .to_string(); + let server = spawn_capturing_mock_server(vec![ + MockResponse { + body: concat!( + "data: {\"type\":\"error\",\"code\":\"ToolNotOpen\",\"message\":\"Your account has not activated web search.\"}\n\n", + "data: [DONE]\n\n" + ) + .to_string(), + }, + MockResponse { + body: format!( + "data: {}\n\n", + serde_json::json!({ + "type": "response.output_text.delta", + "delta": success_json + }) + ) + "data: {\"type\":\"response.completed\"}\n\n", + }, + ]); + let config = LlmConfig::new( + LlmProvider::Ark, + server.base_url, + "test-key".to_string(), + "test-model".to_string(), + 30_000, + 0, + 1, + ) + .expect("LLM config should build"); + let llm_client = platform_llm::LlmClient::new(config).expect("LLM client should build"); + let mut visible_replies = Vec::new(); + + let output = stream_creation_agent_json_turn( + Some(&llm_client), + "系统提示".to_string(), + "用户提示", + true, + CreationAgentLlmTurnErrorMessages { + model_unavailable: "模型不可用", + generation_failed: "生成失败", + parse_failed: "解析失败", + }, + |text| visible_replies.push(text.to_string()), + |message| message, + ) + .await + .expect("web search fallback should succeed"); + + assert_eq!( + output.parsed["replyText"].as_str(), + Some("好,我们先把玩具王国定住。") + ); + assert_eq!(visible_replies, vec!["好,我们先把玩具王国定住。"]); + + let requests = server.requests.lock().expect("requests lock").clone(); + assert_eq!(requests.len(), 2); + assert!(requests[0].contains("\"tools\"")); + assert!(requests[0].contains("\"web_search\"")); + assert!(!requests[1].contains("\"tools\"")); + + unsafe { + std::env::remove_var("LLM_RAW_LOG_DIR"); + } + if log_dir.exists() { + fs::remove_dir_all(log_dir).expect("temporary LLM raw log dir should be removed"); + } + } + + struct MockResponse { + body: String, + } + + struct CapturingMockServer { + base_url: String, + requests: Arc>>, + } + + fn spawn_capturing_mock_server(responses: Vec) -> CapturingMockServer { + let listener = TcpListener::bind("127.0.0.1:0").expect("listener should bind"); + let address = listener.local_addr().expect("listener should have addr"); + let requests = Arc::new(Mutex::new(Vec::new())); + let requests_for_thread = Arc::clone(&requests); + + thread::spawn(move || { + for response in responses { + let (mut stream, _) = listener.accept().expect("request should connect"); + let request_text = read_request(&mut stream); + requests_for_thread + .lock() + .expect("requests lock") + .push(request_text); + write_sse_response(&mut stream, response); + } + }); + + CapturingMockServer { + base_url: format!("http://{address}"), + requests, + } + } + + fn read_request(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(StdDuration::from_secs(1))) + .expect("read timeout should be set"); + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + let mut expected_total = None; + + loop { + match stream.read(&mut chunk) { + Ok(0) => break, + Ok(bytes_read) => { + buffer.extend_from_slice(&chunk[..bytes_read]); + + if expected_total.is_none() + && let Some(header_end) = find_header_end(&buffer) + { + let content_length = + read_content_length(&buffer[..header_end]).unwrap_or(0); + expected_total = Some(header_end + content_length); + } + + if let Some(total_bytes) = expected_total + && buffer.len() >= total_bytes + { + break; + } + } + Err(error) + if error.kind() == std::io::ErrorKind::WouldBlock + || error.kind() == std::io::ErrorKind::TimedOut => + { + break; + } + Err(error) => panic!("mock server failed to read request: {error}"), + } + } + + String::from_utf8_lossy(buffer.as_slice()).to_string() + } + + fn write_sse_response(stream: &mut std::net::TcpStream, response: MockResponse) { + let raw_response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + response.body.len(), + response.body + ); + + stream + .write_all(raw_response.as_bytes()) + .expect("mock response should be written"); + stream.flush().expect("mock response should flush"); + } + + fn find_header_end(buffer: &[u8]) -> Option { + buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|index| index + 4) + } + + fn read_content_length(headers: &[u8]) -> Option { + let text = String::from_utf8_lossy(headers); + text.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if name.eq_ignore_ascii_case("content-length") { + return value.trim().parse::().ok(); + } + None + }) + } } diff --git a/server-rs/crates/api-server/src/puzzle.rs b/server-rs/crates/api-server/src/puzzle.rs index f2ab14a2..81cd3b8c 100644 --- a/server-rs/crates/api-server/src/puzzle.rs +++ b/server-rs/crates/api-server/src/puzzle.rs @@ -98,8 +98,13 @@ const PUZZLE_WORKS_PROVIDER: &str = "puzzle-works"; const PUZZLE_GALLERY_PROVIDER: &str = "puzzle-gallery"; const PUZZLE_RUNTIME_PROVIDER: &str = "puzzle-runtime"; const PUZZLE_TEXT_TO_IMAGE_MODEL: &str = "wan2.2-t2i-flash"; +const PUZZLE_IMAGE_MODEL_ORIGINAL: &str = "original"; +const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2: &str = "gpt-image-2"; +const PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW: &str = "gemini-3.1-flash-image-preview"; const PUZZLE_ENTITY_KIND: &str = "puzzle_work"; const PUZZLE_GENERATED_IMAGE_SIZE: &str = "1024*1024"; +const PUZZLE_APIMART_GENERATED_IMAGE_SIZE: &str = "1:1"; +const PUZZLE_APIMART_GEMINI_RESOLUTION: &str = "1K"; pub async fn create_puzzle_agent_session( State(state): State, @@ -463,6 +468,7 @@ pub async fn execute_puzzle_agent_action( session_id = %session_id, owner_user_id = %owner_user_id, action = %action, + image_model = payload.image_model.as_deref().unwrap_or(PUZZLE_IMAGE_MODEL_ORIGINAL), prompt_chars = payload .prompt_text .as_deref() @@ -508,6 +514,7 @@ pub async fn execute_puzzle_agent_action( owner_user_id.clone(), prompt_text, payload.reference_image_src.as_deref(), + payload.image_model.as_deref(), now, ) .await @@ -627,6 +634,7 @@ pub async fn execute_puzzle_agent_action( &target_level.level_name, &prompt, payload.reference_image_src.as_deref(), + payload.image_model.as_deref(), candidate_count, candidate_start_index, ) @@ -2406,6 +2414,7 @@ async fn compile_puzzle_draft_with_initial_cover( owner_user_id: String, prompt_text: Option<&str>, reference_image_src: Option<&str>, + image_model: Option<&str>, now: i64, ) -> Result { let compiled_session = state @@ -2431,6 +2440,7 @@ async fn compile_puzzle_draft_with_initial_cover( &target_level.level_name, &image_prompt, reference_image_src, + image_model, 1, target_level.candidates.len(), ) @@ -2544,7 +2554,12 @@ fn is_missing_puzzle_form_draft_procedure_error(error: &SpacetimeClientError) -> fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { let message = error.to_string(); - let provider = if message.contains("DashScope") || message.contains("dashscope") { + let provider = if message.contains("APIMart") + || message.contains("apimart") + || message.contains("APIMART") + { + "apimart" + } else if message.contains("DashScope") || message.contains("dashscope") { "dashscope" } else if message.contains("OSS") || message.contains("oss") || message.contains("参考图") { "puzzle-assets" @@ -2556,6 +2571,9 @@ fn map_puzzle_compile_error(error: SpacetimeClientError) -> AppError { || message.contains("上游") || message.contains("DashScope") || message.contains("dashscope") + || message.contains("APIMart") + || message.contains("apimart") + || message.contains("APIMART") || message.contains("参考图") || message.contains("图片") || message.contains("OSS") @@ -2648,17 +2666,18 @@ async fn generate_puzzle_image_candidates( level_name: &str, prompt: &str, reference_image_src: Option<&str>, + image_model: Option<&str>, candidate_count: u32, candidate_start_index: usize, ) -> Result, String> { let count = candidate_count.clamp(1, 1); - let settings = - require_puzzle_dashscope_settings(state).map_err(map_puzzle_generation_app_error)?; - let http_client = - build_puzzle_dashscope_http_client(&settings).map_err(map_puzzle_generation_app_error)?; + let resolved_model = resolve_puzzle_image_model(image_model); let actual_prompt = build_puzzle_image_prompt(level_name, prompt); + let http_client = build_puzzle_image_http_client(state, resolved_model) + .map_err(map_puzzle_generation_app_error)?; tracing::info!( - provider = "dashscope", + provider = resolved_model.provider_name(), + image_model = resolved_model.request_model_name(), session_id, level_name, prompt_chars = prompt.chars().count(), @@ -2680,29 +2699,50 @@ async fn generate_puzzle_image_candidates( ), None => None, }; - // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与 DashScope 图生图都必须停留在 api-server。 + // 中文注释:SpacetimeDB reducer 不能做外部 I/O,参考图读取与外部生图都必须停留在 api-server。 // 中文注释:拼图作品资产统一按 1:1 正方形生成,前端运行时也按正方形棋盘切块承载。 - let generated = match reference_image.as_deref() { - Some(reference_image) => { - create_puzzle_image_to_image_generation( - &http_client, - &settings, - actual_prompt.as_str(), - PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_GENERATED_IMAGE_SIZE, - count, - reference_image, - ) - .await + let generated = match resolved_model { + PuzzleImageModel::Original => { + let settings = require_puzzle_dashscope_settings(state) + .map_err(map_puzzle_generation_app_error)?; + match reference_image.as_deref() { + Some(reference_image) => { + create_puzzle_image_to_image_generation( + &http_client, + &settings, + actual_prompt.as_str(), + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_GENERATED_IMAGE_SIZE, + count, + reference_image, + ) + .await + } + None => { + create_puzzle_text_to_image_generation( + &http_client, + &settings, + actual_prompt.as_str(), + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_GENERATED_IMAGE_SIZE, + count, + ) + .await + } + } } - None => { - create_puzzle_text_to_image_generation( + PuzzleImageModel::GptImage2 | PuzzleImageModel::Gemini31FlashPreview => { + let settings = + require_puzzle_apimart_settings(state).map_err(map_puzzle_generation_app_error)?; + create_puzzle_apimart_image_generation( &http_client, &settings, + resolved_model, actual_prompt.as_str(), PUZZLE_DEFAULT_NEGATIVE_PROMPT, - PUZZLE_GENERATED_IMAGE_SIZE, + PUZZLE_APIMART_GENERATED_IMAGE_SIZE, count, + reference_image.as_deref(), ) .await } @@ -2733,7 +2773,7 @@ async fn generate_puzzle_image_candidates( asset_id: asset.asset_id, prompt: prompt.to_string(), actual_prompt: Some(actual_prompt.clone()), - source_type: "generated".to_string(), + source_type: resolved_model.candidate_source_type().to_string(), // 单图生成结果总是直接成为当前正式图。 selected: index == 0, }); @@ -2823,6 +2863,7 @@ async fn build_local_next_puzzle_run( &draft.level_name, &draft.summary, None, + None, 1, draft.candidates.len(), ) @@ -3577,6 +3618,7 @@ mod tests { #[test] fn puzzle_generated_image_size_is_square_1_1() { assert_eq!(PUZZLE_GENERATED_IMAGE_SIZE, "1024*1024"); + assert_eq!(PUZZLE_APIMART_GENERATED_IMAGE_SIZE, "1:1"); } #[test] @@ -3598,6 +3640,30 @@ mod tests { assert_eq!(body["parameters"]["n"], 1); } + #[test] + fn puzzle_apimart_request_uses_selected_model_and_reference_images() { + let body = build_puzzle_apimart_image_request_body( + PuzzleImageModel::Gemini31FlashPreview, + "一只猫在雨夜灯牌下回头。", + PUZZLE_DEFAULT_NEGATIVE_PROMPT, + PUZZLE_APIMART_GENERATED_IMAGE_SIZE, + 4, + Some("data:image/png;base64,abcd"), + ); + + assert_eq!(body["model"], PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW); + assert_eq!(body["size"], PUZZLE_APIMART_GENERATED_IMAGE_SIZE); + assert_eq!(body["resolution"], PUZZLE_APIMART_GEMINI_RESOLUTION); + assert_eq!(body["n"], 1); + assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd"); + assert!( + body["prompt"] + .as_str() + .unwrap_or_default() + .contains("文字水印") + ); + } + #[test] fn puzzle_dashscope_upstream_error_keeps_status_and_raw_excerpt() { let error = map_puzzle_dashscope_upstream_error( @@ -3639,6 +3705,44 @@ struct PuzzleDashScopeSettings { request_timeout_ms: u64, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum PuzzleImageModel { + Original, + GptImage2, + Gemini31FlashPreview, +} + +impl PuzzleImageModel { + fn provider_name(self) -> &'static str { + match self { + Self::Original => "dashscope", + Self::GptImage2 | Self::Gemini31FlashPreview => "apimart", + } + } + + fn request_model_name(self) -> &'static str { + match self { + Self::Original => PUZZLE_TEXT_TO_IMAGE_MODEL, + Self::GptImage2 => PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, + Self::Gemini31FlashPreview => PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW, + } + } + + fn candidate_source_type(self) -> &'static str { + match self { + Self::Original => "generated", + Self::GptImage2 => "generated:gpt-image-2", + Self::Gemini31FlashPreview => "generated:nanobanana2", + } + } +} + +struct PuzzleApimartSettings { + base_url: String, + api_key: String, + request_timeout_ms: u64, +} + struct PuzzleGeneratedImages { task_id: String, images: Vec, @@ -3694,16 +3798,69 @@ fn require_puzzle_dashscope_settings( }) } -fn build_puzzle_dashscope_http_client( - settings: &PuzzleDashScopeSettings, +fn resolve_puzzle_image_model(value: Option<&str>) -> PuzzleImageModel { + match value + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or(PUZZLE_IMAGE_MODEL_ORIGINAL) + { + PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 => PuzzleImageModel::GptImage2, + PUZZLE_IMAGE_MODEL_GEMINI_31_FLASH_PREVIEW => PuzzleImageModel::Gemini31FlashPreview, + _ => PuzzleImageModel::Original, + } +} + +fn require_puzzle_apimart_settings(state: &AppState) -> Result { + let base_url = state.config.apimart_base_url.trim().trim_end_matches('/'); + if base_url.is_empty() { + return Err( + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "apimart", + "reason": "APIMART_BASE_URL 未配置", + })), + ); + } + + let api_key = state + .config + .apimart_api_key + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| { + AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ + "provider": "apimart", + "reason": "APIMART_API_KEY 未配置", + })) + })?; + + Ok(PuzzleApimartSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1), + }) +} + +fn build_puzzle_image_http_client( + state: &AppState, + image_model: PuzzleImageModel, ) -> Result { + let (provider, request_timeout_ms) = match image_model { + PuzzleImageModel::Original => { + ("dashscope", state.config.dashscope_image_request_timeout_ms) + } + PuzzleImageModel::GptImage2 | PuzzleImageModel::Gemini31FlashPreview => { + ("apimart", state.config.apimart_image_request_timeout_ms) + } + }; + reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms)) + .timeout(Duration::from_millis(request_timeout_ms.max(1))) .build() .map_err(|error| { AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "dashscope", - "message": format!("构造拼图 DashScope HTTP 客户端失败:{error}"), + "provider": provider, + "message": format!("构造拼图图片生成 HTTP 客户端失败:{error}"), })) }) } @@ -3866,6 +4023,229 @@ fn build_puzzle_text_to_image_request_body( }) } +async fn create_puzzle_apimart_image_generation( + http_client: &reqwest::Client, + settings: &PuzzleApimartSettings, + image_model: PuzzleImageModel, + prompt: &str, + negative_prompt: &str, + size: &str, + candidate_count: u32, + reference_image: Option<&str>, +) -> Result { + let request_body = build_puzzle_apimart_image_request_body( + image_model, + prompt, + negative_prompt, + size, + candidate_count, + reference_image, + ); + let response = http_client + .post(format!("{}/images/generations", settings.base_url)) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .json(&request_body) + .send() + .await + .map_err(|error| { + map_puzzle_apimart_request_error(format!("创建拼图 APIMart 图片生成任务失败:{error}")) + })?; + let status = response.status(); + let response_text = response.text().await.map_err(|error| { + map_puzzle_apimart_request_error(format!("读取拼图 APIMart 图片生成响应失败:{error}")) + })?; + if !status.is_success() { + return Err(map_puzzle_apimart_upstream_error( + status, + response_text.as_str(), + "创建拼图 APIMart 图片生成任务失败", + )); + } + + let payload = + parse_puzzle_json_payload(response_text.as_str(), "解析拼图 APIMart 图片生成响应失败")?; + let image_urls = extract_puzzle_image_urls(&payload); + if !image_urls.is_empty() { + return download_puzzle_images_from_urls( + http_client, + format!("apimart-{}", current_utc_micros()), + image_urls, + candidate_count, + ) + .await; + } + + let task_id = extract_puzzle_task_id(&payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": "拼图 APIMart 图片生成未返回 task_id 或图片地址", + })) + })?; + + wait_puzzle_apimart_generated_images( + http_client, + settings, + task_id.as_str(), + candidate_count, + "拼图 APIMart 图片生成任务失败", + ) + .await +} + +fn build_puzzle_apimart_image_request_body( + image_model: PuzzleImageModel, + prompt: &str, + negative_prompt: &str, + size: &str, + candidate_count: u32, + reference_image: Option<&str>, +) -> Value { + let mut body = Map::from_iter([ + ( + "model".to_string(), + Value::String(image_model.request_model_name().to_string()), + ), + ( + "prompt".to_string(), + Value::String(build_puzzle_apimart_prompt(prompt, negative_prompt)), + ), + ("n".to_string(), json!(candidate_count.clamp(1, 1))), + ("size".to_string(), Value::String(size.to_string())), + ]); + body.insert( + "resolution".to_string(), + Value::String( + match image_model { + PuzzleImageModel::Gemini31FlashPreview => PUZZLE_APIMART_GEMINI_RESOLUTION, + _ => "1k", + } + .to_string(), + ), + ); + + if let Some(reference_image) = reference_image + .map(str::trim) + .filter(|value| !value.is_empty()) + { + body.insert("image_urls".to_string(), json!([reference_image])); + } + + Value::Object(body) +} + +fn build_puzzle_apimart_prompt(prompt: &str, negative_prompt: &str) -> String { + let prompt = prompt.trim(); + let negative_prompt = negative_prompt.trim(); + if negative_prompt.is_empty() { + return prompt.to_string(); + } + + format!("{prompt}\n避免:{negative_prompt}") +} + +async fn wait_puzzle_apimart_generated_images( + http_client: &reqwest::Client, + settings: &PuzzleApimartSettings, + task_id: &str, + candidate_count: u32, + failure_message: &str, +) -> Result { + let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); + + while Instant::now() < deadline { + let poll_response = http_client + .get(format!("{}/tasks/{}", settings.base_url, task_id)) + .header( + reqwest::header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .send() + .await + .map_err(|error| { + map_puzzle_apimart_request_error(format!( + "查询拼图 APIMart 图片生成任务失败:{error}" + )) + })?; + let poll_status = poll_response.status(); + let poll_text = poll_response.text().await.map_err(|error| { + map_puzzle_apimart_request_error(format!( + "读取拼图 APIMart 图片生成任务响应失败:{error}" + )) + })?; + if !poll_status.is_success() { + return Err(map_puzzle_apimart_upstream_error( + poll_status, + poll_text.as_str(), + "查询拼图 APIMart 图片生成任务失败", + )); + } + + let poll_payload = + parse_puzzle_json_payload(poll_text.as_str(), "解析拼图 APIMart 图片生成任务响应失败")?; + let task_status = find_first_puzzle_string_by_key(&poll_payload, "status") + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if matches!(task_status.as_str(), "completed" | "succeeded" | "success") { + let image_urls = extract_puzzle_image_urls(&poll_payload); + if image_urls.is_empty() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": "拼图 APIMart 图片生成成功但未返回图片地址", + })), + ); + } + + return download_puzzle_images_from_urls( + http_client, + task_id.to_string(), + image_urls, + candidate_count, + ) + .await; + } + if matches!( + task_status.as_str(), + "failed" | "error" | "canceled" | "cancelled" + ) { + return Err(map_puzzle_apimart_upstream_error( + poll_status, + poll_text.as_str(), + failure_message, + )); + } + sleep(Duration::from_secs(3)).await; + } + + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": "拼图 APIMart 图片生成超时或未返回图片地址", + })), + ) +} + +async fn download_puzzle_images_from_urls( + http_client: &reqwest::Client, + task_id: String, + image_urls: Vec, + candidate_count: u32, +) -> Result { + let mut images = Vec::with_capacity(candidate_count.clamp(1, 1) as usize); + for image_url in image_urls + .into_iter() + .take(candidate_count.clamp(1, 1) as usize) + { + images.push(download_puzzle_remote_image(http_client, image_url.as_str()).await?); + } + Ok(PuzzleGeneratedImages { task_id, images }) +} + async fn resolve_puzzle_reference_image_as_data_url( state: &AppState, http_client: &reqwest::Client, @@ -4427,6 +4807,36 @@ fn map_puzzle_dashscope_upstream_error( })) } +fn map_puzzle_apimart_request_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": message, + })) +} + +fn map_puzzle_apimart_upstream_error( + upstream_status: reqwest::StatusCode, + raw_text: &str, + fallback_message: &str, +) -> AppError { + let message = parse_puzzle_api_error_message(raw_text, fallback_message); + let raw_excerpt = trim_puzzle_upstream_excerpt(raw_text, 800); + tracing::warn!( + provider = "apimart", + upstream_status = upstream_status.as_u16(), + message = %message, + raw_excerpt = %raw_excerpt, + "拼图 APIMart 上游请求失败" + ); + + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "upstreamStatus": upstream_status.as_u16(), + "message": message, + "rawExcerpt": raw_excerpt, + })) +} + fn parse_puzzle_api_error_message(raw_text: &str, fallback_message: &str) -> String { let trimmed = raw_text.trim(); if trimmed.is_empty() { diff --git a/server-rs/crates/module-auth/src/lib.rs b/server-rs/crates/module-auth/src/lib.rs index defe075a..e6ed8477 100644 --- a/server-rs/crates/module-auth/src/lib.rs +++ b/server-rs/crates/module-auth/src/lib.rs @@ -2540,8 +2540,7 @@ fn format_rfc3339(value: OffsetDateTime) -> Result { } 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 { diff --git a/server-rs/crates/module-puzzle/src/lib.rs b/server-rs/crates/module-puzzle/src/lib.rs index 0e99e07f..ff450e54 100644 --- a/server-rs/crates/module-puzzle/src/lib.rs +++ b/server-rs/crates/module-puzzle/src/lib.rs @@ -3465,10 +3465,7 @@ pub fn puzzle_point_incentive_claimable_points(total_half_points: u64, claimed_p .saturating_sub(claimed_points) } -pub fn puzzle_point_incentive_total_after_spend( - total_half_points: u64, - spent_points: u64, -) -> u64 { +pub fn puzzle_point_incentive_total_after_spend(total_half_points: u64, spent_points: u64) -> u64 { total_half_points.saturating_add(spent_points) } diff --git a/server-rs/crates/shared-contracts/src/puzzle_agent.rs b/server-rs/crates/shared-contracts/src/puzzle_agent.rs index d46d2e29..89dfab68 100644 --- a/server-rs/crates/shared-contracts/src/puzzle_agent.rs +++ b/server-rs/crates/shared-contracts/src/puzzle_agent.rs @@ -13,6 +13,8 @@ pub struct CreatePuzzleAgentSessionRequest { pub picture_description: Option, #[serde(default)] pub reference_image_src: Option, + #[serde(default)] + pub image_model: Option, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] @@ -33,6 +35,8 @@ pub struct ExecutePuzzleAgentActionRequest { #[serde(default)] pub reference_image_src: Option, #[serde(default)] + pub image_model: Option, + #[serde(default)] pub candidate_count: Option, #[serde(default)] pub candidate_id: Option, diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 8bf6f732..23f743a9 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -108,11 +108,10 @@ test('creation hub reflects updated draft title summary and counts after rerende const puzzleButton = screen.getByRole('button', { name: /拼图.*创意礼物/u }); const match3dButton = screen.getByRole('button', { name: /抓大鹅/u }); expect( - puzzleButton.compareDocumentPosition(rpgButton) & + rpgButton.compareDocumentPosition(puzzleButton) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBeTruthy(); - expect((rpgButton as HTMLButtonElement).disabled).toBe(true); - expect(within(rpgButton).getAllByText('敬请期待').length).toBeGreaterThan(0); + expect((rpgButton as HTMLButtonElement).disabled).toBe(false); expect((match3dButton as HTMLButtonElement).disabled).toBe(true); expect( within(match3dButton).getAllByText('敬请期待').length, diff --git a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx index a8f4f8af..932285eb 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.test.tsx @@ -43,7 +43,6 @@ test('creation hub draft card renders compiled work summary fields', () => { expect(html).toContain('玩家是失职返乡的守灯人'); expect(html).toContain('守灯会与沉船商盟争夺航道解释权'); expect(html).toContain('角色扮演'); - expect(html).toContain('敬请期待'); expect(html).toContain('拼图'); expect(html).toContain('创意礼物,生活分享'); expect(html).not.toContain('大鱼吃小鱼'); diff --git a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx index 6add7234..ee5c3df7 100644 --- a/src/components/custom-world-home/CustomWorldCreationStartCard.tsx +++ b/src/components/custom-world-home/CustomWorldCreationStartCard.tsx @@ -1,5 +1,6 @@ import { ArrowRight } from 'lucide-react'; +import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig'; import { getVisiblePlatformCreationTypes, type PlatformCreationTypeId, @@ -27,13 +28,15 @@ export function CustomWorldCreationStartCard({
- 新建作品 + {NEW_WORK_ENTRY_CONFIG.startCard.title}
- 直接选择游戏创作模板,立刻进入对应的共创工作台。 + {NEW_WORK_ENTRY_CONFIG.startCard.description}
- {busy ? '正在开启' : '选择模板'} + {busy + ? NEW_WORK_ENTRY_CONFIG.startCard.busyBadge + : NEW_WORK_ENTRY_CONFIG.startCard.idleBadge}
diff --git a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx index 95ca9844..a9bf5e86 100644 --- a/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx +++ b/src/components/platform-entry/PlatformEntryCreationTypeModal.tsx @@ -1,5 +1,6 @@ import { ArrowRight } from 'lucide-react'; +import { NEW_WORK_ENTRY_CONFIG } from '../../config/newWorkEntryConfig'; import { UnifiedModal } from '../common/UnifiedModal'; import { getVisiblePlatformCreationTypes } from './platformEntryCreationTypes'; @@ -86,8 +87,8 @@ export function PlatformEntryCreationTypeModal({ return ( (null); const [match3dRun, setMatch3DRun] = useState(null); - const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] = - useState<'match3d-result' | 'work-detail'>('match3d-result'); + const [match3dRuntimeReturnStage, setMatch3DRuntimeReturnStage] = useState< + 'match3d-result' | 'work-detail' + >('match3d-result'); const [isMatch3DLoadingLibrary, setIsMatch3DLoadingLibrary] = useState(false); const [bigFishRun, setBigFishRun] = useState(null); @@ -993,8 +1000,10 @@ export function PlatformEntryFlowShellImpl({ const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< string | null >(null); - const [claimingPuzzlePointIncentiveProfileId, setClaimingPuzzlePointIncentiveProfileId] = - useState(null); + const [ + claimingPuzzlePointIncentiveProfileId, + setClaimingPuzzlePointIncentiveProfileId, + ] = useState(null); const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish'); const [profilePlayStats, setProfilePlayStats] = useState(null); @@ -1099,7 +1108,9 @@ export function PlatformEntryFlowShellImpl({ return galleryResponse.items; } catch (error) { setMatch3DGalleryEntries([]); - setMatch3DError(resolveMatch3DErrorMessage(error, '读取抓大鹅广场失败。')); + setMatch3DError( + resolveMatch3DErrorMessage(error, '读取抓大鹅广场失败。'), + ); return []; } }, [resolveMatch3DErrorMessage]); @@ -1293,7 +1304,11 @@ export function PlatformEntryFlowShellImpl({ ); return mergePlatformPublicGalleryEntries( platformBootstrap.publishedGalleryEntries, - [...bigFishPublicEntries, ...match3dPublicEntries, ...puzzlePublicEntries], + [ + ...bigFishPublicEntries, + ...match3dPublicEntries, + ...puzzlePublicEntries, + ], ).slice(0, 6); }, [ isBigFishCreationVisible, @@ -1665,24 +1680,6 @@ export function PlatformEntryFlowShellImpl({ await bigFishFlow.openWorkspace(); }, [bigFishFlow]); - const openMatch3DAgentWorkspace = useCallback(async () => { - setMatch3DSession(null); - setMatch3DProfile(null); - setMatch3DRun(null); - setMatch3DError(null); - setStreamingMatch3DReplyText(''); - setIsStreamingMatch3DReply(false); - await match3dFlow.openWorkspace(); - }, [ - match3dFlow, - setIsStreamingMatch3DReply, - setMatch3DError, - setMatch3DProfile, - setMatch3DRun, - setMatch3DSession, - setStreamingMatch3DReplyText, - ]); - const openPuzzleAgentWorkspace = useCallback(async () => { setPuzzleRun(null); setPuzzleOperation(null); @@ -1729,6 +1726,7 @@ export function PlatformEntryFlowShellImpl({ workTitle: payload.workTitle ?? payload.seedText ?? '', workDescription: payload.workDescription ?? '', pictureDescription: payload.pictureDescription ?? '', + imageModel: payload.imageModel ?? null, }); setPuzzleOperation(response.operation); puzzleFlow.setSession(response.session); @@ -1826,12 +1824,7 @@ export function PlatformEntryFlowShellImpl({ const handleCreationHubCreateType = useCallback( (type: PlatformCreationTypeId) => { - if ( - type === 'rpg' || - type === 'match3d' || - type === 'airp' || - type === 'visual-novel' - ) { + if (type === 'match3d' || type === 'airp' || type === 'visual-novel') { return; } @@ -1839,6 +1832,13 @@ export function PlatformEntryFlowShellImpl({ return; } + if (type === 'rpg') { + runProtectedAction(() => { + void sessionController.openRpgAgentWorkspace(); + }); + return; + } + if (type === 'big-fish') { runProtectedAction(() => { void openBigFishAgentWorkspace(); @@ -1857,6 +1857,7 @@ export function PlatformEntryFlowShellImpl({ openPuzzleAgentWorkspace, prepareCreationLaunch, runProtectedAction, + sessionController, ], ); @@ -2332,8 +2333,8 @@ export function PlatformEntryFlowShellImpl({ propKind === 'extendTime' ? extendLocalPuzzleTime(currentRun) : propKind === 'freezeTime' - ? applyLocalPuzzleFreezeTime(currentRun) - : setLocalPuzzlePaused(currentRun, propKind === 'reference'); + ? applyLocalPuzzleFreezeTime(currentRun) + : setLocalPuzzlePaused(currentRun, propKind === 'reference'); puzzleRunRef.current = nextRun; setPuzzleRun(nextRun); return nextRun; @@ -2367,7 +2368,9 @@ export function PlatformEntryFlowShellImpl({ selectedPuzzleDetail, ); if (isLocalPuzzleRun(puzzleRun)) { - const nextRun = restartLocalPuzzleLevel(puzzleRunRef.current ?? puzzleRun); + const nextRun = restartLocalPuzzleLevel( + puzzleRunRef.current ?? puzzleRun, + ); puzzleRunRef.current = nextRun; setPuzzleRun(nextRun); return; @@ -2531,68 +2534,68 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError, ]); - const advancePuzzleLevel = useCallback(async (target?: { - profileId?: string; - levelId?: string | null; - }) => { - if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) { - return; - } - - const currentLevel = puzzleRun.currentLevel; - if (!currentLevel || currentLevel.status !== 'cleared') { - return; - } - - setIsPuzzleBusy(true); - setIsPuzzleNextLevelGenerating(true); - setPuzzleError(null); - - try { - const targetProfileId = target?.profileId?.trim(); - if ( - targetProfileId && - targetProfileId !== currentLevel.profileId && - puzzleRun.nextLevelMode === 'similarWorks' - ) { - await startPuzzleRunFromProfile( - targetProfileId, - 'puzzle-gallery-detail', - undefined, - false, - null, - ); + const advancePuzzleLevel = useCallback( + async (target?: { profileId?: string; levelId?: string | null }) => { + if (!puzzleRun || isPuzzleBusy || isPuzzleLeaderboardBusy) { return; } - const { run } = isLocalPuzzleRun(puzzleRun) - ? await advanceLocalPuzzleNextLevel({ - run: puzzleRun, - sourceSessionId: - selectedPuzzleDetail?.sourceSessionId ?? - puzzleSession?.sessionId ?? - null, - }) - : await advancePuzzleNextLevel(puzzleRun.runId); - setPuzzleRun(run); - if (!isLocalPuzzleRun(puzzleRun)) { - void platformBootstrap.refreshSaveArchives(); + + const currentLevel = puzzleRun.currentLevel; + if (!currentLevel || currentLevel.status !== 'cleared') { + return; } - } catch (error) { - setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。')); - } finally { - setIsPuzzleNextLevelGenerating(false); - setIsPuzzleBusy(false); - } - }, [ - isPuzzleBusy, - isPuzzleLeaderboardBusy, - platformBootstrap, - puzzleRun, - puzzleSession, - resolvePuzzleErrorMessage, - selectedPuzzleDetail, - startPuzzleRunFromProfile, - ]); + + setIsPuzzleBusy(true); + setIsPuzzleNextLevelGenerating(true); + setPuzzleError(null); + + try { + const targetProfileId = target?.profileId?.trim(); + if ( + targetProfileId && + targetProfileId !== currentLevel.profileId && + puzzleRun.nextLevelMode === 'similarWorks' + ) { + await startPuzzleRunFromProfile( + targetProfileId, + 'puzzle-gallery-detail', + undefined, + false, + null, + ); + return; + } + const { run } = isLocalPuzzleRun(puzzleRun) + ? await advanceLocalPuzzleNextLevel({ + run: puzzleRun, + sourceSessionId: + selectedPuzzleDetail?.sourceSessionId ?? + puzzleSession?.sessionId ?? + null, + }) + : await advancePuzzleNextLevel(puzzleRun.runId); + setPuzzleRun(run); + if (!isLocalPuzzleRun(puzzleRun)) { + void platformBootstrap.refreshSaveArchives(); + } + } catch (error) { + setPuzzleError(resolvePuzzleErrorMessage(error, '准备下一关失败。')); + } finally { + setIsPuzzleNextLevelGenerating(false); + setIsPuzzleBusy(false); + } + }, + [ + isPuzzleBusy, + isPuzzleLeaderboardBusy, + platformBootstrap, + puzzleRun, + puzzleSession, + resolvePuzzleErrorMessage, + selectedPuzzleDetail, + startPuzzleRunFromProfile, + ], + ); const leaveAgentWorkspace = useCallback(() => { enterCreateTab(); @@ -2935,14 +2938,10 @@ export function PlatformEntryFlowShellImpl({ .then((response) => { const updatedWork = response.item; setPuzzleWorks((current) => - current.map((item) => - mergePuzzleWorkSummary(item, updatedWork), - ), + current.map((item) => mergePuzzleWorkSummary(item, updatedWork)), ); setPuzzleGalleryEntries((current) => - current.map((item) => - mergePuzzleWorkSummary(item, updatedWork), - ), + current.map((item) => mergePuzzleWorkSummary(item, updatedWork)), ); setSelectedPuzzleDetail((current) => current ? mergePuzzleWorkSummary(current, updatedWork) : current, @@ -3316,7 +3315,9 @@ export function PlatformEntryFlowShellImpl({ return; } - const restoredSession = await match3dFlow.restoreDraft(item.sourceSessionId); + const restoredSession = await match3dFlow.restoreDraft( + item.sourceSessionId, + ); if (!restoredSession) { await refreshMatch3DShelf().catch(() => undefined); return; @@ -3395,7 +3396,9 @@ export function PlatformEntryFlowShellImpl({ if (isPuzzleGalleryEntry(selectedPublicWorkDetail)) { const work = mapPublicWorkDetailToPuzzleWork(selectedPublicWorkDetail); if (!work) { - setPublicWorkDetailError('当前拼图作品信息不完整,暂时无法进入玩法。'); + setPublicWorkDetailError( + '当前拼图作品信息不完整,暂时无法进入玩法。', + ); return; } setPublicWorkDetailError(null); @@ -3411,7 +3414,9 @@ export function PlatformEntryFlowShellImpl({ if (isMatch3DGalleryEntry(selectedPublicWorkDetail)) { const work = mapPublicWorkDetailToMatch3DWork(selectedPublicWorkDetail); if (!work) { - setPublicWorkDetailError('当前抓大鹅作品信息不完整,暂时无法进入玩法。'); + setPublicWorkDetailError( + '当前抓大鹅作品信息不完整,暂时无法进入玩法。', + ); return; } setPublicWorkDetailError(null); @@ -4487,7 +4492,9 @@ export function PlatformEntryFlowShellImpl({ className="flex h-full min-h-0 flex-col" > } + fallback={ + + } > { setMatch3DProfile(profile); @@ -4565,7 +4575,9 @@ export function PlatformEntryFlowShellImpl({ error={match3dError} onBack={() => { if (match3dRun?.runId && match3dRun.status === 'running') { - void stopMatch3DRun(match3dRun.runId).catch(() => undefined); + void stopMatch3DRun(match3dRun.runId).catch( + () => undefined, + ); } setSelectionStage(match3dRuntimeReturnStage); }} @@ -4596,7 +4608,9 @@ export function PlatformEntryFlowShellImpl({ onClickItem={(payload) => { const runId = payload.runId ?? match3dRun?.runId; if (!runId) { - return Promise.reject(new Error('抓大鹅运行态缺少 runId。')); + return Promise.reject( + new Error('抓大鹅运行态缺少 runId。'), + ); } return clickMatch3DItem(runId, payload); }} @@ -5084,7 +5098,9 @@ export function PlatformEntryFlowShellImpl({ setShowCreationTypeModal(false); }} onSelectRpg={() => { - // RPG 创作入口当前为敬请期待;保留回调防御,避免旧入口绕过锁定态。 + runProtectedAction(() => { + void sessionController.openRpgAgentWorkspace(); + }); }} onSelectBigFish={() => { runProtectedAction(() => { diff --git a/src/components/platform-entry/platformEntryCreationTypes.test.ts b/src/components/platform-entry/platformEntryCreationTypes.test.ts new file mode 100644 index 00000000..b48a4639 --- /dev/null +++ b/src/components/platform-entry/platformEntryCreationTypes.test.ts @@ -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', + ]); +}); diff --git a/src/components/platform-entry/platformEntryCreationTypes.ts b/src/components/platform-entry/platformEntryCreationTypes.ts index 9c6b15a1..0f2ad81b 100644 --- a/src/components/platform-entry/platformEntryCreationTypes.ts +++ b/src/components/platform-entry/platformEntryCreationTypes.ts @@ -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: true, - }, - { - 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, + })); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx index b5dfd64f..667ad424 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.interaction.test.tsx @@ -100,6 +100,7 @@ test('puzzle workspace submits the work form instead of agent chat', () => { workDescription: '一套雨夜猫街主题拼图。', pictureDescription: '一只猫在雨夜灯牌下回头。', referenceImageSrc: null, + imageModel: 'original', }); expect(screen.queryByRole('button', { name: '补充剩余设定' })).toBeNull(); expect(screen.queryByText('旧会话消息不再渲染为聊天入口。')).toBeNull(); @@ -129,10 +130,44 @@ test('puzzle workspace falls back to compile action for restored sessions', () = workDescription: '雾港遗迹拼图', pictureDescription: '潮雾中的灯塔与断桥', referenceImageSrc: null, + imageModel: 'original', candidateCount: 1, }); }); +test('puzzle workspace switches the image model from the description box', () => { + const onCreateFromForm = vi.fn(); + + render( + {}} + 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: '图片模型' })); + 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 +243,6 @@ test('puzzle workspace restores form draft fields and autosaves edits', () => { workDescription: '旧街雨夜的拼图草稿。', pictureDescription: '旧街灯牌下的猫和发光雨伞。', referenceImageSrc: null, + imageModel: 'original', }); }); diff --git a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx index be164543..dcb1572a 100644 --- a/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx +++ b/src/components/puzzle-agent/PuzzleAgentWorkspace.tsx @@ -8,6 +8,12 @@ import type { SendPuzzleAgentMessageRequest, } from '../../../packages/shared/src/contracts/puzzleAgentSession'; import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; +import { + normalizePuzzleImageModel, + PUZZLE_IMAGE_MODEL_ORIGINAL, + 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_ORIGINAL, }; 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_ORIGINAL, }; } @@ -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="画面描述" /> + + setFormState((current) => ({ + ...current, + imageModel, + })) + } + />
+ } + > +
+
+ {shareText} +
+
+ + + ); +} diff --git a/src/components/common/publishShareModalModel.ts b/src/components/common/publishShareModalModel.ts new file mode 100644 index 00000000..3c360d36 --- /dev/null +++ b/src/components/common/publishShareModalModel.ts @@ -0,0 +1,30 @@ +import { buildPublicWorkStagePath } from '../../routing/appPageRoutes'; +import type { SelectionStage } from '../platform-entry/platformEntryTypes'; + +export type PublishShareModalPayload = { + title: string; + publicWorkCode: string; + stage: SelectionStage; +}; + +function buildShareUrl(payload: PublishShareModalPayload) { + const sharePath = buildPublicWorkStagePath( + payload.stage, + payload.publicWorkCode, + ); + + return typeof window === 'undefined' + ? sharePath + : new URL(sharePath, window.location.origin).href; +} + +export function buildPublishShareText(payload: PublishShareModalPayload) { + const publicWorkCode = payload.publicWorkCode.trim(); + const title = payload.title.trim() || '我的作品'; + + return `邀请你来玩《${title}》\n作品号:${publicWorkCode}\n${buildShareUrl({ + ...payload, + publicWorkCode, + title, + })}`; +} diff --git a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx index 63ac1330..54b7e11d 100644 --- a/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx +++ b/src/components/custom-world-home/CustomWorldCreationHub.interaction.test.tsx @@ -319,6 +319,55 @@ test('creation hub shows delete action for persisted rpg drafts', () => { expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); }); +test('creation hub published work delete action is available beside share without opening card', async () => { + const user = userEvent.setup(); + const onDeletePuzzle = vi.fn(); + const onOpenPuzzleDetail = vi.fn(); + + render( + {}} + onCreateType={noopCreateType} + onOpenDraft={() => {}} + onEnterPublished={() => {}} + onOpenPuzzleDetail={onOpenPuzzleDetail} + onDeletePuzzle={onDeletePuzzle} + />, + ); + + expect(screen.getByRole('button', { name: '删除' })).toBeTruthy(); + expect(screen.getByRole('button', { name: '分享' })).toBeTruthy(); + + await user.click(screen.getByRole('button', { name: '删除' })); + + expect(onDeletePuzzle).toHaveBeenCalledWith( + expect.objectContaining({ profileId: 'puzzle-profile-delete' }), + ); + expect(onOpenPuzzleDetail).not.toHaveBeenCalled(); +}); + test('creation hub opens persisted rpg drafts by card click', async () => { const user = userEvent.setup(); const openedItems: CustomWorldWorkSummary[] = []; diff --git a/src/components/custom-world-home/CustomWorldWorkCard.tsx b/src/components/custom-world-home/CustomWorldWorkCard.tsx index fd992a1b..9ab94276 100644 --- a/src/components/custom-world-home/CustomWorldWorkCard.tsx +++ b/src/components/custom-world-home/CustomWorldWorkCard.tsx @@ -268,68 +268,70 @@ export function CustomWorldWorkCard({
- {!isPublished && onDelete ? ( - - ) : null} - {isPublished ? ( - - ) : null} +
+ {onDelete ? ( + + ) : null} + {isPublished ? ( + + ) : null} +
diff --git a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx index f7586beb..6daf450e 100644 --- a/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx +++ b/src/components/platform-entry/PlatformEntryFlowShellImpl.tsx @@ -171,6 +171,9 @@ import { getRpgProfilePlayStats } from '../../services/rpg-entry/rpgProfileClien import { requestRpgRuntimeJson } from '../../services/rpg-runtime/rpgRuntimeRequest'; import type { CustomWorldProfile } from '../../types'; import { useAuthUi } from '../auth/AuthUiContext'; +import { PublishShareModal } from '../common/PublishShareModal'; +import type { PublishShareModalPayload } from '../common/publishShareModalModel'; +import { UnifiedModal } from '../common/UnifiedModal'; import { isBigFishGalleryEntry, isMatch3DGalleryEntry, @@ -227,6 +230,13 @@ type PuzzleSaveArchiveState = { currentLevelId?: unknown; }; +type DeleteCreationWorkConfirmation = { + id: string; + title: string; + detail: string; + run: () => void; +}; + async function resumePuzzleProfileSaveArchiveRaw(worldKey: string) { return requestRpgRuntimeJson< ProfileSaveArchiveResumeResponse @@ -345,7 +355,10 @@ function mapPublicWorkDetailToMatch3DWork( workId: entry.workId, profileId: entry.profileId, ownerUserId: entry.ownerUserId, - sourceSessionId: null, + sourceSessionId: + 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' + ? entry.sourceSessionId + : null, gameName: entry.worldName, themeText: entry.themeTags[0] ?? '经典消除', summary: entry.summaryText, @@ -403,7 +416,10 @@ function mapPublicWorkDetailToPuzzleWork( workId: entry.workId, profileId: entry.profileId, ownerUserId: entry.ownerUserId, - sourceSessionId: null, + sourceSessionId: + 'sourceSessionId' in entry && typeof entry.sourceSessionId === 'string' + ? entry.sourceSessionId + : null, authorDisplayName: entry.authorDisplayName, levelName: entry.worldName, summary: entry.summaryText, @@ -1000,10 +1016,14 @@ export function PlatformEntryFlowShellImpl({ const [deletingCreationWorkId, setDeletingCreationWorkId] = useState< string | null >(null); + const [pendingDeleteCreationWork, setPendingDeleteCreationWork] = + useState(null); const [ claimingPuzzlePointIncentiveProfileId, setClaimingPuzzlePointIncentiveProfileId, ] = useState(null); + const [publishSharePayload, setPublishSharePayload] = + useState(null); const isBigFishCreationVisible = isPlatformCreationTypeVisible('big-fish'); const [profilePlayStats, setProfilePlayStats] = useState(null); @@ -1279,6 +1299,50 @@ export function PlatformEntryFlowShellImpl({ () => agentResultPreview?.qualityFindings ?? [], [agentResultPreview], ); + + const openPublishShareModal = useCallback( + (payload: PublishShareModalPayload) => { + const publicWorkCode = payload.publicWorkCode.trim(); + if (!publicWorkCode) { + return; + } + + setPublishSharePayload({ + ...payload, + publicWorkCode, + title: payload.title.trim() || '我的作品', + }); + }, + [], + ); + + const openRpgPublishShareModal = useCallback( + async (profile: CustomWorldProfile | null | undefined) => { + const profileId = profile?.id?.trim(); + if (!profileId) { + return; + } + const profileName = profile?.name?.trim() || '我的作品'; + + const galleryEntries = await platformBootstrap + .refreshPublishedGallery() + .catch(() => [] as CustomWorldGalleryCard[]); + const galleryEntry = galleryEntries.find( + (entry) => entry.profileId === profileId, + ); + const publicWorkCode = galleryEntry?.publicWorkCode?.trim(); + if (!publicWorkCode) { + return; + } + + openPublishShareModal({ + title: galleryEntry?.worldName || profileName, + publicWorkCode, + stage: 'work-detail', + }); + }, + [openPublishShareModal, platformBootstrap], + ); const agentResultPreviewSourceLabel = useMemo(() => { if (!agentResultPreview?.source) { return null; @@ -1347,6 +1411,13 @@ export function PlatformEntryFlowShellImpl({ const resultViewError = autosaveCoordinator.customWorldAutoSaveError ?? sessionController.customWorldError; + const isSelectedPublicWorkOwned = Boolean( + authUi?.user?.id && + selectedPublicWorkDetail?.ownerUserId === authUi.user.id, + ); + const selectedPublicWorkActionMode = isSelectedPublicWorkOwned + ? 'edit' + : 'remix'; useEffect(() => { if ( @@ -1374,6 +1445,37 @@ export function PlatformEntryFlowShellImpl({ [authUi], ); + const requestDeleteCreationWork = useCallback( + (confirmation: DeleteCreationWorkConfirmation) => { + if (deletingCreationWorkId) { + return; + } + + runProtectedAction(() => { + setPendingDeleteCreationWork(confirmation); + }); + }, + [deletingCreationWorkId, runProtectedAction], + ); + + const closeDeleteCreationWorkConfirmation = useCallback(() => { + if (deletingCreationWorkId) { + return; + } + + setPendingDeleteCreationWork(null); + }, [deletingCreationWorkId]); + + const confirmDeleteCreationWork = useCallback(() => { + const confirmation = pendingDeleteCreationWork; + if (!confirmation || deletingCreationWorkId) { + return; + } + + setPendingDeleteCreationWork(null); + confirmation.run(); + }, [deletingCreationWorkId, pendingDeleteCreationWork]); + const prepareCreationLaunch = useCallback(() => { if (sessionController.isCreatingAgentSession) { return false; @@ -1433,6 +1535,11 @@ export function PlatformEntryFlowShellImpl({ if (payload.action === 'big_fish_publish_game') { void refreshBigFishShelf(); void refreshBigFishGallery(); + openPublishShareModal({ + title: response.session.draft?.title ?? '大鱼吃小鱼', + publicWorkCode: buildBigFishPublicWorkCode(response.session.sessionId), + stage: 'big-fish-runtime', + }); } if (payload.action !== 'big_fish_compile_draft') { return; @@ -1610,6 +1717,11 @@ export function PlatformEntryFlowShellImpl({ buildPuzzlePublicWorkCode(galleryDetail.item.profileId), ), ); + openPublishShareModal({ + title: galleryDetail.item.workTitle || galleryDetail.item.levelName, + publicWorkCode: buildPuzzlePublicWorkCode(galleryDetail.item.profileId), + stage: 'puzzle-gallery-detail', + }); } }, beforeExecuteAction: ({ payload }) => { @@ -1805,6 +1917,7 @@ export function PlatformEntryFlowShellImpl({ setPuzzleError(null); setDeletingCreationWorkId(null); setClaimingPuzzlePointIncentiveProfileId(null); + setPublishSharePayload(null); setProfilePlayStats(null); setProfilePlayStatsError(null); setIsProfilePlayStatsOpen(false); @@ -2623,6 +2736,46 @@ export function PlatformEntryFlowShellImpl({ ], ); + const remodelCurrentPuzzleRuntimeWork = useCallback((profileId: string) => { + const targetProfileId = profileId.trim(); + if (!targetProfileId || isPublicWorkDetailBusy || isPuzzleBusy) { + return; + } + + runProtectedAction(() => { + setIsPublicWorkDetailBusy(true); + setIsPuzzleBusy(true); + setPuzzleError(null); + setPublicWorkDetailError(null); + + void remixPuzzleGalleryWork(targetProfileId) + .then((response) => { + puzzleFlow.setSession(response.session); + setPuzzleOperation(null); + setPuzzleRun(null); + enterCreateTab(); + setSelectionStage('puzzle-result'); + }) + .catch((error) => { + setPuzzleError(resolvePuzzleErrorMessage(error, '改造拼图作品失败。')); + }) + .finally(() => { + setIsPublicWorkDetailBusy(false); + setIsPuzzleBusy(false); + }); + }); + }, [ + enterCreateTab, + isPublicWorkDetailBusy, + isPuzzleBusy, + puzzleFlow, + resolvePuzzleErrorMessage, + runProtectedAction, + setIsPuzzleBusy, + setPuzzleError, + setSelectionStage, + ]); + const leaveAgentWorkspace = useCallback(() => { enterCreateTab(); sessionController.resetSessionViewState(); @@ -2696,34 +2849,32 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - const confirmed = window.confirm( - `确认删除作品《${entry.worldName}》吗?删除后会从你的作品列表和公开广场中移除。`, - ); - if (!confirmed) { - return; - } + requestDeleteCreationWork({ + id: entry.profileId, + title: entry.worldName, + detail: '删除后会从你的作品列表和公开广场中移除。', + run: () => { + setDeletingCreationWorkId(entry.profileId); + platformBootstrap.setPlatformError(null); - setDeletingCreationWorkId(entry.profileId); - platformBootstrap.setPlatformError(null); - - void deleteRpgEntryWorldProfile(entry.profileId) - .then(async (entries) => { - platformBootstrap.setSavedCustomWorldEntries(entries); - await platformBootstrap.refreshCustomWorldWorks().catch(() => []); - await platformBootstrap.refreshPublishedGallery().catch(() => []); - }) - .catch((error) => { - platformBootstrap.setPlatformError( - resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'), - ); - }) - .finally(() => { - setDeletingCreationWorkId(null); - }); + void deleteRpgEntryWorldProfile(entry.profileId) + .then(async (entries) => { + platformBootstrap.setSavedCustomWorldEntries(entries); + await platformBootstrap.refreshCustomWorldWorks().catch(() => []); + await platformBootstrap.refreshPublishedGallery().catch(() => []); + }) + .catch((error) => { + platformBootstrap.setPlatformError( + resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }, }); }, - [deletingCreationWorkId, platformBootstrap, runProtectedAction], + [deletingCreationWorkId, platformBootstrap, requestDeleteCreationWork], ); const handleDeletePublishedWork = useCallback( @@ -2732,47 +2883,51 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - const confirmed = window.confirm( - `确认删除作品《${work.title}》吗?删除后会从你的作品列表和公开广场中移除。`, - ); - if (!confirmed) { - return; - } - setDeletingCreationWorkId(work.workId); - platformBootstrap.setPlatformError(null); + requestDeleteCreationWork({ + id: work.workId, + title: work.title, + detail: + work.status === 'published' + ? '删除后会从你的作品列表和公开广场中移除。' + : '删除后会从你的作品列表中移除。', + run: () => { + setDeletingCreationWorkId(work.workId); + platformBootstrap.setPlatformError(null); - const deleteTask = - work.sourceType === 'published_profile' && work.profileId - ? deleteRpgEntryWorldProfile(work.profileId).then( - async (entries) => { - platformBootstrap.setSavedCustomWorldEntries(entries); - await platformBootstrap - .refreshCustomWorldWorks() - .catch(() => []); - }, - ) - : work.sourceType === 'agent_session' && work.sessionId - ? deleteRpgCreationAgentSession(work.sessionId).then((items) => { - platformBootstrap.setCustomWorldWorkEntries(items); - }) - : Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。')); + const deleteTask = + work.sourceType === 'published_profile' && work.profileId + ? deleteRpgEntryWorldProfile(work.profileId).then( + async (entries) => { + platformBootstrap.setSavedCustomWorldEntries(entries); + await platformBootstrap + .refreshCustomWorldWorks() + .catch(() => []); + }, + ) + : work.sourceType === 'agent_session' && work.sessionId + ? deleteRpgCreationAgentSession(work.sessionId).then( + (items) => { + platformBootstrap.setCustomWorldWorkEntries(items); + }, + ) + : Promise.reject(new Error('当前 RPG 作品缺少可删除 ID。')); - void deleteTask - .then(async () => { - await platformBootstrap.refreshPublishedGallery().catch(() => []); - }) - .catch((error) => { - platformBootstrap.setPlatformError( - resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'), - ); - }) - .finally(() => { - setDeletingCreationWorkId(null); - }); + void deleteTask + .then(async () => { + await platformBootstrap.refreshPublishedGallery().catch(() => []); + }) + .catch((error) => { + platformBootstrap.setPlatformError( + resolveRpgCreationErrorMessage(error, '删除自定义世界失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }, }); }, - [deletingCreationWorkId, platformBootstrap, runProtectedAction], + [deletingCreationWorkId, platformBootstrap, requestDeleteCreationWork], ); const handleDeleteBigFishWork = useCallback( @@ -2781,37 +2936,39 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - const confirmed = window.confirm( - `确认删除作品《${work.title}》吗?删除后会从你的作品列表中移除。`, - ); - if (!confirmed) { - return; - } + requestDeleteCreationWork({ + id: work.workId, + title: work.title, + detail: + work.status === 'published' + ? '删除后会从你的作品列表和公开广场中移除。' + : '删除后会从你的作品列表中移除。', + run: () => { + setDeletingCreationWorkId(work.workId); + setBigFishError(null); - setDeletingCreationWorkId(work.workId); - setBigFishError(null); - - void deleteBigFishWork(work.sourceSessionId) - .then(async (response) => { - setBigFishWorks(response.items); - await refreshBigFishGallery().catch(() => []); - }) - .catch((error) => { - setBigFishError( - resolveBigFishErrorMessage(error, '删除大鱼吃小鱼作品失败。'), - ); - }) - .finally(() => { - setDeletingCreationWorkId(null); - }); + void deleteBigFishWork(work.sourceSessionId) + .then(async (response) => { + setBigFishWorks(response.items); + await refreshBigFishGallery().catch(() => []); + }) + .catch((error) => { + setBigFishError( + resolveBigFishErrorMessage(error, '删除大鱼吃小鱼作品失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }, }); }, [ deletingCreationWorkId, refreshBigFishGallery, + requestDeleteCreationWork, resolveBigFishErrorMessage, - runProtectedAction, + setBigFishError, ], ); @@ -2821,40 +2978,42 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - const displayName = - work.workTitle?.trim() || work.levelName.trim() || '未命名拼图'; - const confirmed = window.confirm( - `确认删除作品《${displayName}》吗?删除后会从你的作品列表和公开广场中移除。`, - ); - if (!confirmed) { - return; - } + const displayName = + work.workTitle?.trim() || work.levelName.trim() || '未命名拼图'; + requestDeleteCreationWork({ + id: work.workId, + title: displayName, + detail: + work.publicationStatus === 'published' + ? '删除后会从你的作品列表和公开广场中移除。' + : '删除后会从你的作品列表中移除。', + run: () => { + setDeletingCreationWorkId(work.workId); + setPuzzleFormDraftPayload(null); + setPuzzleError(null); - setDeletingCreationWorkId(work.workId); - setPuzzleFormDraftPayload(null); - setPuzzleError(null); - - void deletePuzzleWork(work.profileId) - .then((response) => { - setPuzzleWorks(response.items); - void refreshPuzzleGallery(); - }) - .catch((error) => { - setPuzzleError( - resolvePuzzleErrorMessage(error, '删除拼图作品失败。'), - ); - }) - .finally(() => { - setDeletingCreationWorkId(null); - }); + void deletePuzzleWork(work.profileId) + .then((response) => { + setPuzzleWorks(response.items); + void refreshPuzzleGallery(); + }) + .catch((error) => { + setPuzzleError( + resolvePuzzleErrorMessage(error, '删除拼图作品失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }, }); }, [ deletingCreationWorkId, refreshPuzzleGallery, + requestDeleteCreationWork, resolvePuzzleErrorMessage, - runProtectedAction, + setPuzzleError, ], ); @@ -2864,37 +3023,38 @@ export function PlatformEntryFlowShellImpl({ return; } - runProtectedAction(() => { - const confirmed = window.confirm( - `确认删除作品《${work.gameName}》吗?删除后会从你的作品列表中移除。`, - ); - if (!confirmed) { - return; - } + requestDeleteCreationWork({ + id: work.workId, + title: work.gameName, + detail: + work.publicationStatus === 'published' + ? '删除后会从你的作品列表和公开广场中移除。' + : '删除后会从你的作品列表中移除。', + run: () => { + setDeletingCreationWorkId(work.workId); + setMatch3DError(null); - setDeletingCreationWorkId(work.workId); - setMatch3DError(null); - - void deleteMatch3DWork(work.profileId) - .then((response) => { - setMatch3DWorks(response.items); - void refreshMatch3DGallery(); - }) - .catch((error) => { - setMatch3DError( - resolveMatch3DErrorMessage(error, '删除抓大鹅作品失败。'), - ); - }) - .finally(() => { - setDeletingCreationWorkId(null); - }); + void deleteMatch3DWork(work.profileId) + .then((response) => { + setMatch3DWorks(response.items); + void refreshMatch3DGallery(); + }) + .catch((error) => { + setMatch3DError( + resolveMatch3DErrorMessage(error, '删除抓大鹅作品失败。'), + ); + }) + .finally(() => { + setDeletingCreationWorkId(null); + }); + }, }); }, [ deletingCreationWorkId, refreshMatch3DGallery, + requestDeleteCreationWork, resolveMatch3DErrorMessage, - runProtectedAction, setMatch3DError, ], ); @@ -3326,12 +3486,15 @@ export function PlatformEntryFlowShellImpl({ ); const openMatch3DDraft = useCallback( - async (item: Match3DWorkSummary) => { + async ( + item: Match3DWorkSummary, + options: { forceDraft?: boolean } = {}, + ) => { setMatch3DRun(null); setMatch3DError(null); setMatch3DProfile(null); - if (item.publicationStatus === 'published') { + if (item.publicationStatus === 'published' && !options.forceDraft) { openPublicWorkDetail(mapMatch3DWorkToPublicWorkDetail(item)); return; } @@ -3368,6 +3531,19 @@ export function PlatformEntryFlowShellImpl({ ], ); + const openBigFishDraft = useCallback( + async (item: BigFishWorkSummary) => { + setBigFishRun(null); + const restoredSession = await bigFishFlow.restoreDraft( + item.sourceSessionId, + ); + if (!restoredSession) { + await refreshBigFishShelf().catch(() => undefined); + } + }, + [bigFishFlow, refreshBigFishShelf], + ); + const startBigFishRunFromWork = useCallback( ( item: BigFishWorkSummary, @@ -3580,12 +3756,94 @@ export function PlatformEntryFlowShellImpl({ ], ); + const editOwnedPublicWork = useCallback( + (entry: PlatformPublicGalleryCard) => { + if (isPublicWorkDetailBusy) { + return; + } + + runProtectedAction(() => { + setPublicWorkDetailError(null); + + // 中文注释:自有公开作品必须恢复原草稿,不能复用 remix 复制链路。 + if (isBigFishGalleryEntry(entry)) { + const work = mapPublicWorkDetailToBigFishWork(entry); + if (!work?.sourceSessionId?.trim()) { + setPublicWorkDetailError( + '这份大鱼吃小鱼作品缺少原草稿会话,暂时无法编辑。', + ); + return; + } + void openBigFishDraft(work); + return; + } + + if (isPuzzleGalleryEntry(entry)) { + const work = + selectedPuzzleDetail?.profileId === entry.profileId + ? selectedPuzzleDetail + : mapPublicWorkDetailToPuzzleWork(entry); + if (!work?.sourceSessionId?.trim()) { + setPublicWorkDetailError( + '这份拼图作品缺少原草稿会话,暂时无法编辑。', + ); + return; + } + void openPuzzleDraft(work); + return; + } + + if (isMatch3DGalleryEntry(entry)) { + const work = mapPublicWorkDetailToMatch3DWork(entry); + if (!work?.sourceSessionId?.trim()) { + setPublicWorkDetailError( + '这份抓大鹅作品缺少原草稿会话,暂时无法编辑。', + ); + return; + } + void openMatch3DDraft(work, { forceDraft: true }); + return; + } + + const editEntry = + selectedDetailEntry?.profileId === entry.profileId + ? selectedDetailEntry + : null; + if (!editEntry) { + setPublicWorkDetailError('作品详情尚未读取完成。'); + return; + } + + void detailNavigation.openSavedCustomWorldEditor(editEntry); + }); + }, + [ + detailNavigation, + isPublicWorkDetailBusy, + openBigFishDraft, + openMatch3DDraft, + openPuzzleDraft, + runProtectedAction, + selectedDetailEntry, + selectedPuzzleDetail, + ], + ); + const remixSelectedPublicWork = useCallback(() => { if (!selectedPublicWorkDetail) { return; } + if (isSelectedPublicWorkOwned) { + editOwnedPublicWork(selectedPublicWorkDetail); + return; + } remixPublicWork(selectedPublicWorkDetail); - }, [remixPublicWork, selectedPublicWorkDetail]); + }, [ + editOwnedPublicWork, + isSelectedPublicWorkOwned, + remixPublicWork, + selectedPublicWorkDetail, + ]); const handlePublicCodeSearch = useCallback( async (keyword: string) => { @@ -3906,19 +4164,6 @@ export function PlatformEntryFlowShellImpl({ void handlePublicCodeSearch(publicWorkCode); }, [handlePublicCodeSearch, initialPublicWorkCode]); - const openBigFishDraft = useCallback( - async (item: BigFishWorkSummary) => { - setBigFishRun(null); - const restoredSession = await bigFishFlow.restoreDraft( - item.sourceSessionId, - ); - if (!restoredSession) { - await refreshBigFishShelf().catch(() => undefined); - } - }, - [bigFishFlow, refreshBigFishShelf], - ); - useEffect(() => { if (selectionStage === 'platform') { if (isBigFishCreationVisible) { @@ -4209,6 +4454,7 @@ export function PlatformEntryFlowShellImpl({ isMatch3DBusy } error={publicWorkDetailError} + actionMode={selectedPublicWorkActionMode} visibleCoverCount={resolveVisiblePuzzleDetailCoverCount( selectedPublicWorkDetail, puzzleRun, @@ -4250,6 +4496,9 @@ export function PlatformEntryFlowShellImpl({ } isBusy={detailNavigation.isMutatingDetail} error={detailNavigation.detailError} + actionMode={ + detailNavigation.isSelectedWorldOwned ? 'edit' : 'remix' + } onBack={() => { detailNavigation.setDetailError(null); clearSelectedPublicWorkAuthor(); @@ -4262,9 +4511,13 @@ export function PlatformEntryFlowShellImpl({ }} onStart={handleStartSelectedWorld} onRemix={() => { - remixPublicWork( - mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry), - ); + const publicWorkEntry = + mapRpgGalleryCardToPublicWorkDetail(selectedDetailEntry); + if (detailNavigation.isSelectedWorldOwned) { + editOwnedPublicWork(publicWorkEntry); + return; + } + remixPublicWork(publicWorkEntry); }} /> ) : ( @@ -4574,6 +4827,13 @@ export function PlatformEntryFlowShellImpl({ openPublicWorkDetail( mapMatch3DWorkToPublicWorkDetail(profile), ); + openPublishShareModal({ + title: profile.gameName, + publicWorkCode: buildMatch3DPublicWorkCode( + profile.profileId, + ), + stage: 'work-detail', + }); }} onStartTestRun={(profile) => { setMatch3DProfile(profile); @@ -4840,6 +5100,11 @@ export function PlatformEntryFlowShellImpl({ onBack={() => { setSelectionStage(puzzleRuntimeReturnStage); }} + onRemodelWork={ + selectedPuzzleDetail?.publicationStatus === 'published' + ? remodelCurrentPuzzleRuntimeWork + : undefined + } onSwapPieces={(payload) => { void swapPuzzlePiecesInRun(payload); }} @@ -4980,7 +5245,9 @@ export function PlatformEntryFlowShellImpl({ sessionController.agentSession?.stage !== 'published' ? async () => { try { - await enterWorldCoordinator.publishCurrentResult(); + const publishedProfile = + await enterWorldCoordinator.publishCurrentResult(); + void openRpgPublishShareModal(publishedProfile); } catch (error) { sessionController.setCustomWorldError( resolveRpgCreationErrorMessage( @@ -5144,6 +5411,48 @@ export function PlatformEntryFlowShellImpl({ }); }} /> + setPublishSharePayload(null)} + /> + + + + + } + > +
+ {pendingDeleteCreationWork?.detail} +
+
{(searchedPublicUser || publicSearchError) && ( { expect(onLike).toHaveBeenCalledTimes(1); }); +test('PlatformWorkDetailView switches remix action label for owned work edit', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: '作品编辑' })).toBeTruthy(); + expect(screen.queryByRole('button', { name: '作品改造' })).toBeNull(); +}); + test('PlatformWorkDetailView cycles puzzle level cover slides', () => { vi.useFakeTimers(); const { container } = render( diff --git a/src/components/platform-entry/PlatformWorkDetailView.tsx b/src/components/platform-entry/PlatformWorkDetailView.tsx index ffb6f413..f3850a21 100644 --- a/src/components/platform-entry/PlatformWorkDetailView.tsx +++ b/src/components/platform-entry/PlatformWorkDetailView.tsx @@ -8,6 +8,7 @@ import { Gamepad2, GitFork, Heart, + PencilLine, Play, Share2, } from 'lucide-react'; @@ -38,6 +39,7 @@ export interface PlatformWorkDetailViewProps { onLike: () => void; onStart: () => void; onRemix: () => void; + actionMode?: 'remix' | 'edit'; } function formatCompactCount(value: number) { @@ -78,6 +80,7 @@ export function PlatformWorkDetailView({ onLike, onStart, onRemix, + actionMode = 'remix', }: PlatformWorkDetailViewProps) { const coverSlides = useMemo( () => resolvePlatformWorldCoverSlides(entry), @@ -111,6 +114,8 @@ export function PlatformWorkDetailView({ [entry], ); const stats = resolvePlatformWorldStats(entry); + const workActionLabel = actionMode === 'edit' ? '作品编辑' : '作品改造'; + const WorkActionIcon = actionMode === 'edit' ? PencilLine : GitFork; const statItems = [ { label: '游玩', @@ -425,8 +430,8 @@ export function PlatformWorkDetailView({ onClick={onRemix} disabled={isBusy} > - - 作品改造 + + {workActionLabel}
diff --git a/src/components/puzzle-agent/puzzleImageModelOptions.ts b/src/components/puzzle-agent/puzzleImageModelOptions.ts index f7f1d1fa..12498083 100644 --- a/src/components/puzzle-agent/puzzleImageModelOptions.ts +++ b/src/components/puzzle-agent/puzzleImageModelOptions.ts @@ -1,9 +1,7 @@ -export const PUZZLE_IMAGE_MODEL_ORIGINAL = 'original'; export const PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 = 'gpt-image-2'; export const PUZZLE_IMAGE_MODEL_NANOBANANA2 = 'gemini-3.1-flash-image-preview'; export type PuzzleImageModelId = - | typeof PUZZLE_IMAGE_MODEL_ORIGINAL | typeof PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 | typeof PUZZLE_IMAGE_MODEL_NANOBANANA2; @@ -11,7 +9,6 @@ export const PUZZLE_IMAGE_MODEL_OPTIONS: Array<{ id: PuzzleImageModelId; label: string; }> = [ - { id: PUZZLE_IMAGE_MODEL_ORIGINAL, label: '原模型' }, { id: PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, label: 'gpt-image-2' }, { id: PUZZLE_IMAGE_MODEL_NANOBANANA2, label: 'nanobanana2' }, ]; @@ -21,13 +18,13 @@ export function normalizePuzzleImageModel( ): PuzzleImageModelId { return ( PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === value)?.id ?? - PUZZLE_IMAGE_MODEL_ORIGINAL + PUZZLE_IMAGE_MODEL_GPT_IMAGE_2 ); } export function getPuzzleImageModelLabel(model: PuzzleImageModelId) { return ( PUZZLE_IMAGE_MODEL_OPTIONS.find((option) => option.id === model)?.label ?? - '原模型' + 'gpt-image-2' ); } diff --git a/src/components/puzzle-result/PuzzleResultView.test.tsx b/src/components/puzzle-result/PuzzleResultView.test.tsx index 27c7ad72..53e15cd7 100644 --- a/src/components/puzzle-result/PuzzleResultView.test.tsx +++ b/src/components/puzzle-result/PuzzleResultView.test.tsx @@ -235,16 +235,26 @@ describe('PuzzleResultView', () => { fireEvent.click( within(dialog).getByRole('button', { name: /重新生成画面/u }), ); + const confirmDialog = screen.getByRole('dialog', { + name: '确认消耗光点', + }); + expect(within(confirmDialog).getByText('消耗 2 光点')).toBeTruthy(); + fireEvent.click(within(confirmDialog).getByRole('button', { name: '确定' })); expect(onExecuteAction).toHaveBeenCalledWith({ action: 'generate_puzzle_images', levelId: 'puzzle-level-1', promptText: '一只猫在雨夜灯牌下回头。', referenceImageSrc: undefined, - imageModel: 'original', + imageModel: 'gpt-image-2', candidateCount: 1, + workTitle: '暖灯猫街作品', + workDescription: '一套雨夜猫街主题拼图。', + summary: '一只猫在雨夜灯牌下回头。', + themeTags: ['猫咪', '雨夜', '暖灯'], levelsJson: expect.any(String), }); + expect(screen.getByRole('progressbar', { name: '画面生成进度' })).toBeTruthy(); const generatePayload = onExecuteAction.mock.calls[0]![0]; expect(JSON.parse(generatePayload.levelsJson ?? '[]')).toEqual([ expect.objectContaining({ @@ -301,6 +311,7 @@ describe('PuzzleResultView', () => { expect( within(dialog).getByRole('button', { name: /生成画面/u }), ).toBeTruthy(); + expect(within(dialog).getByText('消耗2光点')).toBeTruthy(); expect(within(dialog).queryByText('画面图')).toBeNull(); expect( within(dialog).queryByRole('button', { name: /关卡测试/u }), @@ -359,14 +370,24 @@ describe('PuzzleResultView', () => { target: { value: '新关卡里有一座发光钟楼。' }, }); fireEvent.click(within(dialog).getByRole('button', { name: /生成画面/u })); + fireEvent.click( + within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole( + 'button', + { name: '确定' }, + ), + ); expect(onExecuteAction).toHaveBeenCalledWith({ action: 'generate_puzzle_images', levelId: 'puzzle-level-1775000000000-2', promptText: '新关卡里有一座发光钟楼。', referenceImageSrc: undefined, - imageModel: 'original', + imageModel: 'gpt-image-2', candidateCount: 1, + workTitle: '暖灯猫街作品', + workDescription: '一套雨夜猫街主题拼图。', + summary: '新关卡里有一座发光钟楼。', + themeTags: ['猫咪', '雨夜', '暖灯'], levelsJson: expect.any(String), }); @@ -460,13 +481,23 @@ describe('PuzzleResultView', () => { }); fireEvent.click(screen.getByRole('button', { name: /重新生成画面/u })); + fireEvent.click( + within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole( + 'button', + { name: '确定' }, + ), + ); expect(onExecuteAction).toHaveBeenLastCalledWith({ action: 'generate_puzzle_images', levelId: 'puzzle-level-1', promptText: '屋檐下的猫与暖灯街角。', referenceImageSrc: '/generated-puzzle-assets/history/image.png', - imageModel: 'original', + imageModel: 'gpt-image-2', candidateCount: 1, + workTitle: '暖灯猫街作品', + workDescription: '一套雨夜猫街主题拼图。', + summary: '屋檐下的猫与暖灯街角。', + themeTags: ['猫咪', '雨夜', '暖灯'], levelsJson: expect.any(String), }); }); @@ -491,6 +522,12 @@ describe('PuzzleResultView', () => { fireEvent.click( within(dialog).getByRole('button', { name: /重新生成画面/u }), ); + fireEvent.click( + within(screen.getByRole('dialog', { name: '确认消耗光点' })).getByRole( + 'button', + { name: '确定' }, + ), + ); expect(onExecuteAction).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/components/puzzle-result/PuzzleResultView.tsx b/src/components/puzzle-result/PuzzleResultView.tsx index aa9a7775..aa31afca 100644 --- a/src/components/puzzle-result/PuzzleResultView.tsx +++ b/src/components/puzzle-result/PuzzleResultView.tsx @@ -27,7 +27,7 @@ import { import { readPuzzleReferenceImageAsDataUrl } from '../../services/puzzleReferenceImage'; import { useAuthUi } from '../auth/AuthUiContext'; import { - PUZZLE_IMAGE_MODEL_ORIGINAL, + PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, type PuzzleImageModelId, } from '../puzzle-agent/puzzleImageModelOptions'; import { PuzzleImageModelPicker } from '../puzzle-agent/PuzzleImageModelPicker'; @@ -56,6 +56,8 @@ type DraftEditState = { const PUZZLE_MIN_THEME_TAG_COUNT = 3; const PUZZLE_MAX_THEME_TAG_COUNT = 6; const PUZZLE_AUTOSAVE_DEBOUNCE_MS = 600; +const PUZZLE_IMAGE_GENERATION_POINT_COST = 2; +const PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS = 30; function normalizeThemeTagInput(value: string) { return [ @@ -597,11 +599,29 @@ function PuzzleLevelDetailDialog({ null, ); const [isHistoryPickerOpen, setIsHistoryPickerOpen] = useState(false); + const [isCostConfirmOpen, setIsCostConfirmOpen] = useState(false); + const [isGenerationProgressActive, setIsGenerationProgressActive] = + useState(false); + const [generationCountdown, setGenerationCountdown] = useState(0); + const generationBusySeenRef = useRef(false); const [imageModel, setImageModel] = useState( - PUZZLE_IMAGE_MODEL_ORIGINAL, + PUZZLE_IMAGE_MODEL_GPT_IMAGE_2, ); const formalImageSrc = resolveLevelFormalImageSrc(level); const hasFormalImage = Boolean(formalImageSrc); + const isGenerationProgressVisible = isGenerationProgressActive; + const generationSecondsLeft = isBusy + ? Math.max(generationCountdown, 1) + : generationCountdown; + const generationProgressPercent = Math.max( + 6, + Math.round( + ((PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS - + Math.max(generationSecondsLeft, 0)) / + PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS) * + 100, + ), + ); const handleReferenceImageChange = async ( event: ChangeEvent, @@ -626,6 +646,59 @@ function PuzzleLevelDetailDialog({ } }; + useEffect(() => { + if (!isGenerationProgressActive) { + return; + } + + if (generationCountdown <= 0) { + if (!isBusy) { + setIsGenerationProgressActive(false); + } + return; + } + + const timer = window.setTimeout(() => { + setGenerationCountdown((current) => Math.max(0, current - 1)); + }, 1000); + + return () => window.clearTimeout(timer); + }, [generationCountdown, isBusy, isGenerationProgressActive]); + + useEffect(() => { + if (isGenerationProgressActive && isBusy) { + generationBusySeenRef.current = true; + return; + } + + if ( + isGenerationProgressActive && + !isBusy && + generationBusySeenRef.current + ) { + generationBusySeenRef.current = false; + setIsGenerationProgressActive(false); + setGenerationCountdown(0); + } + + if (!isBusy) { + setIsCostConfirmOpen(false); + } + }, [isBusy, isGenerationProgressActive]); + + const executeGeneration = () => { + setIsCostConfirmOpen(false); + setIsGenerationProgressActive(true); + generationBusySeenRef.current = false; + setGenerationCountdown(PUZZLE_IMAGE_GENERATION_ESTIMATE_SECONDS); + onGenerate( + level.levelId, + level.pictureDescription.trim() || undefined, + referenceImageSrc || undefined, + imageModel, + ); + }; + if (typeof document === 'undefined') { return null; } @@ -801,25 +874,83 @@ function PuzzleLevelDetailDialog({ ) : null} - + {isGenerationProgressVisible ? ( +
+
+
+ + 预计剩余 {generationSecondsLeft} 秒 +
+
+ ) : ( + + )}
+ {isCostConfirmOpen ? ( +
setIsCostConfirmOpen(false)} + > +
event.stopPropagation()} + > +
+ + + +
+ 确认消耗光点 +
+
+
+ 消耗 {PUZZLE_IMAGE_GENERATION_POINT_COST} 光点 +
+
+ + +
+
+
+ ) : null} + {isHistoryPickerOpen ? ( { vi.useRealTimers(); }); +test('首次点击左上返回弹出作品改造引导,保存并退出后不再重复弹出', () => { + const onBack = vi.fn(); + const onRemodelWork = vi.fn(); + window.localStorage.clear(); + + renderPuzzleRuntime( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '返回上一页' })); + + const dialog = screen.getByRole('dialog', { + name: /体验不佳?\s*试试改造功能!/u, + }); + expect(dialog).toBeTruthy(); + expect(onBack).not.toHaveBeenCalled(); + + fireEvent.click(within(dialog).getByRole('button', { name: '保存并退出' })); + + expect(onBack).toHaveBeenCalledTimes(1); + expect( + window.localStorage.getItem( + 'genarrative.puzzle-runtime.exit-remodel-prompt.v1:profile-1', + ), + ).toBe('1'); + + fireEvent.click(screen.getByRole('button', { name: '返回上一页' })); + + expect(screen.queryByRole('dialog')).toBeNull(); + expect(onBack).toHaveBeenCalledTimes(2); + expect(onRemodelWork).not.toHaveBeenCalled(); +}); + +test('首次退出引导的作品改造按钮进入改造流程', () => { + const onRemodelWork = vi.fn(); + window.localStorage.clear(); + + renderPuzzleRuntime( + , + ); + + fireEvent.click(screen.getByRole('button', { name: '返回上一页' })); + fireEvent.click(screen.getByRole('button', { name: '作品改造' })); + + expect(onRemodelWork).toHaveBeenCalledTimes(1); + expect(onRemodelWork).toHaveBeenCalledWith('profile-remodel'); + expect(screen.queryByRole('dialog')).toBeNull(); +}); + test('顶部作者显示头像昵称,底部功能居中放大且不显示等待候选', () => { const runWithoutNext: PuzzleRunSnapshot = { ...clearedRun, diff --git a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx index dc518179..cec5b65e 100644 --- a/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx +++ b/src/components/puzzle-runtime/PuzzleRuntimeShell.tsx @@ -41,6 +41,7 @@ type PuzzleRuntimeShellProps = { isBusy?: boolean; error?: string | null; onBack: () => void; + onRemodelWork?: (profileId: string) => void | Promise; onSwapPieces: (payload: SwapPuzzlePiecesRequest) => void; onDragPiece: (payload: DragPuzzlePieceRequest) => void; onAdvanceNextLevel: (target?: PuzzleNextLevelTarget) => void; @@ -208,6 +209,61 @@ const PUZZLE_CLEAR_DIALOG_DELAY_MS = 500; const PUZZLE_MERGE_FLASH_DURATION_MS = 720; const PUZZLE_HINT_DEMO_DURATION_MS = 1_250; const PUZZLE_PIECE_PRESS_HAPTIC_PATTERN_MS = 12; +const PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX = + 'genarrative.puzzle-runtime.exit-remodel-prompt.v1'; + +const shownExitRemodelPromptProfileIds = new Set(); + +function buildExitRemodelPromptStorageKey(profileId: string) { + return `${PUZZLE_EXIT_REMODEL_PROMPT_STORAGE_PREFIX}:${encodeURIComponent( + profileId, + )}`; +} + +function hasSeenExitRemodelPrompt(profileId: string) { + const normalizedProfileId = profileId.trim(); + if (!normalizedProfileId) { + return true; + } + if (shownExitRemodelPromptProfileIds.has(normalizedProfileId)) { + if (typeof window === 'undefined') { + return true; + } + } + + try { + const seen = + window.localStorage.getItem( + buildExitRemodelPromptStorageKey(normalizedProfileId), + ) === '1'; + if (seen) { + shownExitRemodelPromptProfileIds.add(normalizedProfileId); + } + return seen; + } catch { + return shownExitRemodelPromptProfileIds.has(normalizedProfileId); + } +} + +function markExitRemodelPromptSeen(profileId: string) { + const normalizedProfileId = profileId.trim(); + if (!normalizedProfileId) { + return; + } + shownExitRemodelPromptProfileIds.add(normalizedProfileId); + if (typeof window === 'undefined') { + return; + } + + try { + window.localStorage.setItem( + buildExitRemodelPromptStorageKey(normalizedProfileId), + '1', + ); + } catch { + // 中文注释:隐私模式下 localStorage 可能不可写,内存集合足够兜底本次挂载周期。 + } +} type PuzzlePropDialogState = { propKind: PuzzleRuntimePropKind; @@ -251,6 +307,7 @@ export function PuzzleRuntimeShell({ isBusy = false, error = null, onBack, + onRemodelWork, onSwapPieces, onDragPiece, onAdvanceNextLevel, @@ -263,6 +320,8 @@ export function PuzzleRuntimeShell({ const authUi = useAuthUi(); const [selectedPieceId, setSelectedPieceId] = useState(null); const [isSettingsPanelOpen, setIsSettingsPanelOpen] = useState(false); + const [isExitRemodelPromptOpen, setIsExitRemodelPromptOpen] = + useState(false); const [propDialog, setPropDialog] = useState( null, ); @@ -621,7 +680,10 @@ export function PuzzleRuntimeShell({ }, [onTimeExpired]); const isUiPauseActive = - isSettingsPanelOpen || Boolean(propDialog) || isOriginalOverlayVisible; + isSettingsPanelOpen || + isExitRemodelPromptOpen || + Boolean(propDialog) || + isOriginalOverlayVisible; useEffect(() => { if (previousUiPauseActiveRef.current === isUiPauseActive) { @@ -898,6 +960,7 @@ export function PuzzleRuntimeShell({ const authorAvatarLabel = resolveAuthorAvatarLabel( currentLevel.authorDisplayName, ); + const exitPromptProfileId = currentLevel.profileId.trim(); const leaderboardEntries = (currentLevel.leaderboardEntries ?? []).length > 0 ? currentLevel.leaderboardEntries @@ -909,6 +972,20 @@ export function PuzzleRuntimeShell({ const isInteractionLocked = isBusy || runtimeStatus !== 'playing' || Boolean(propDialog); + const handleBackRequest = () => { + if ( + onRemodelWork && + exitPromptProfileId && + !hasSeenExitRemodelPrompt(exitPromptProfileId) + ) { + markExitRemodelPromptSeen(exitPromptProfileId); + setIsExitRemodelPromptOpen(true); + return; + } + + onBack(); + }; + const openPropDialog = (propKind: PuzzleRuntimePropKind, title: string) => { const canOpen = propKind === 'extendTime' @@ -1016,7 +1093,7 @@ export function PuzzleRuntimeShell({
) : null} + {isExitRemodelPromptOpen ? ( +
+
event.stopPropagation()} + > +
+

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

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