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 --- .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, + })) + } + />