From 801d1d534ab4b7860e58af6f5c2b0001dfee329a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=AB=98=E7=89=A9?= <253518756@qq.com> Date: Sun, 3 May 2026 00:17:50 +0800 Subject: [PATCH] 1 --- .gitignore | 1 + docs/technical/README.md | 3 + ...DMARK_SEED_BATCH_TIMEOUT_FIX_2026-05-02.md | 40 ++ ...OLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md | 43 ++ ...RATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md | 76 +++ ...8-000002-upstream_status_failed.input.json | 18 + ...8-000002-upstream_status_failed.output.txt | 1 + ...8-000003-upstream_status_failed.input.json | 18 + ...8-000003-upstream_status_failed.output.txt | 1 + .../api-server/src/character_visual_assets.rs | 470 +++---------- .../crates/api-server/src/custom_world_ai.rs | 134 ++-- .../src/custom_world_foundation_draft.rs | 334 ++++++++- server-rs/crates/api-server/src/main.rs | 1 + .../api-server/src/openai_image_generation.rs | 632 ++++++++++++++++++ .../src/prompt/rpg/foundation_draft.rs | 12 +- .../useRoleVisualCandidateWorkflow.ts | 2 +- 16 files changed, 1337 insertions(+), 449 deletions(-) create mode 100644 docs/technical/RPG_FOUNDATION_DRAFT_LANDMARK_SEED_BATCH_TIMEOUT_FIX_2026-05-02.md create mode 100644 docs/technical/RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md create mode 100644 docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md create mode 100644 server-rs/crates/api-server/logs/llm-raw/1777733782126-16788-000002-upstream_status_failed.input.json create mode 100644 server-rs/crates/api-server/logs/llm-raw/1777733782126-16788-000002-upstream_status_failed.output.txt create mode 100644 server-rs/crates/api-server/logs/llm-raw/1777733782128-16788-000003-upstream_status_failed.input.json create mode 100644 server-rs/crates/api-server/logs/llm-raw/1777733782128-16788-000003-upstream_status_failed.output.txt create mode 100644 server-rs/crates/api-server/src/openai_image_generation.rs diff --git a/.gitignore b/.gitignore index 7b5bde66..399e24b2 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ temp*build*/ /public/generated-characters /.codex-temp /target/ +/logs diff --git a/docs/technical/README.md b/docs/technical/README.md index 441cde9f..799d12df 100644 --- a/docs/technical/README.md +++ b/docs/technical/README.md @@ -5,6 +5,9 @@ ## 文档列表 - [PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md](./PUZZLE_RUNTIME_FRONTEND_LOGIC_REHOME_2026-05-02.md):记录拼图正式平台入口移动、交换、合并、拆分和通关裁决收回前端即时运行态,排行榜、下一关和游玩记录继续由后端持久化处理。 +- [RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md](./RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md):记录 `agent-foundation-*-dossier-batch-*` 无搜索 Responses 请求超时后的本地养成档案兜底,避免底稿主链被尾部角色润色阶段阻断。 +- [RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md](./RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md):记录 RPG 角色主图与场景幕背景图统一迁移到 APIMart OpenAI 兼容 `gpt-image-2` 生图入口的边界、配置和验收口径。 +- [RPG_FOUNDATION_DRAFT_LANDMARK_SEED_BATCH_TIMEOUT_FIX_2026-05-02.md](./RPG_FOUNDATION_DRAFT_LANDMARK_SEED_BATCH_TIMEOUT_FIX_2026-05-02.md):记录 `agent-foundation-landmark-seed-batch-1` 无搜索 Responses 请求超时的根因,并将场景骨架批次收敛为单场景生成。 - [PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md](./PROFILE_MAIN_ROUTE_VITE_PROXY_FIX_2026-05-02.md):记录“我的”和“存档”页面在本地把 `/api/profile/*` 请求落到 Vite SPA fallback、导致 HTML 被当 JSON 解析的根因,以及 `/api/profile` 代理补齐与回归测试。 - [SERVER_RS_DDD_WP_DEL_CLEANUP_2026-05-01.md](./SERVER_RS_DDD_WP_DEL_CLEANUP_2026-05-01.md):记录 `WP-DEL 删除旧层与命名收口`,物理删除旧 runtime story HTTP DTO、前端 `Rpg*` alias、旧 `/api/custom-world/*` 非 runtime 前缀、Puzzle `local-next-level` 入口和 `/generated-*` 资产直读代理;生成资产读取统一走 OSS read-url 链路。 - [SERVER_RS_DDD_WP_API_BFF_CLOSURE_2026-05-01.md](./SERVER_RS_DDD_WP_API_BFF_CLOSURE_2026-05-01.md):记录 `WP-API api-server BFF` 收尾,补齐 `/api/llm/chat/completions` 的 `stream=true` SSE 代理,明确手机号/微信配置门控和角色动画资产占位不阻塞本次 BFF 关闭。 diff --git a/docs/technical/RPG_FOUNDATION_DRAFT_LANDMARK_SEED_BATCH_TIMEOUT_FIX_2026-05-02.md b/docs/technical/RPG_FOUNDATION_DRAFT_LANDMARK_SEED_BATCH_TIMEOUT_FIX_2026-05-02.md new file mode 100644 index 00000000..03e38e88 --- /dev/null +++ b/docs/technical/RPG_FOUNDATION_DRAFT_LANDMARK_SEED_BATCH_TIMEOUT_FIX_2026-05-02.md @@ -0,0 +1,40 @@ +# RPG foundation draft 场景骨架批次超时修正(2026-05-02) + +## 背景 + +现场底稿生成失败信息: + +```text +agent-foundation-landmark-seed-batch-1 LLM 请求失败:LLM 请求超时,累计尝试 2 次 +``` + +本次 `logs/llm-raw` 对应请求体没有 `tools` / `web_search` 字段,说明 2026-05-01 已落地的联网搜索降级并不是本次失败根因。失败发生在无搜索 Responses 请求自身超时。 + +## 根因 + +`agent-foundation-landmark-seed-batch-1` 原本一次要求模型生成 2 个场景。每个场景又必须包含: + +1. 场景基础字段。 +2. `sceneTaskDescription`。 +3. 3 条 `actBackgroundPromptTexts`。 +4. 3 条 `actEventDescriptions`。 +5. 3 个 `actNPCNames`。 +6. 相连场景和进入钩子。 + +这个批次的输出密度明显高于角色 outline 批次。深海题材现场输入下,单次 prompt 已要求开局场景和普通关键场景同时生成,Responses 请求在默认 30 秒超时窗口内连续两次未完成,最终导致 operation 失败。 + +## 落地策略 + +1. 保持 `FOUNDATION_DRAFT_LANDMARK_COUNT = 2` 不变,仍生成 1 个开局场景和 1 个普通关键场景。 +2. 将 `FOUNDATION_LANDMARK_BATCH_SIZE` 从 `2` 收敛为 `1`。 +3. 第一批只生成开局场景,并继续作为 `camp` 写入。 +4. 第二批只生成普通关键场景,带上已生成场景名作为 forbidden names,避免重复开局场景。 +5. 单场景开局 prompt 不再写“一次性生成开局场景和普通关键场景”,避免模型在 batch_count=1 时被旧文案诱导多产。 + +## 验收标准 + +1. `generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields` 中必须捕获 2 个 `场景框架名单` 请求。 +2. 第 1 个场景请求必须明确“本批场景必须是玩家进入世界时所在的开局场景”。 +3. 第 2 个场景请求必须明确“本批只生成普通关键场景”,并带上已生成开局场景名的禁止重复约束。 +4. 编译后的 `camp` 仍来自第 1 个生成场景,`landmarks` 仍只保留后续普通关键场景。 +5. 运行 `cargo test -p api-server custom_world_foundation_draft --manifest-path server-rs/Cargo.toml` 通过。 diff --git a/docs/technical/RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md b/docs/technical/RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md new file mode 100644 index 00000000..5d179660 --- /dev/null +++ b/docs/technical/RPG_FOUNDATION_DRAFT_ROLE_DOSSIER_TIMEOUT_FALLBACK_2026-05-02.md @@ -0,0 +1,43 @@ +# RPG foundation draft 角色养成档案超时兜底(2026-05-02) + +## 背景 + +场景骨架批次拆小后,现场底稿生成继续失败在角色养成档案阶段: + +```text +agent-foundation-story-dossier-batch-1 LLM 请求失败:LLM 请求超时,累计尝试 2 次 +``` + +同一轮 `logs/llm-raw` 还出现过: + +```text +agent-foundation-playable-dossier-batch-1 LLM 请求失败:LLM 请求超时,累计尝试 2 次 +``` + +这些请求体都没有 `tools` / `web_search` 字段,说明本次不是联网搜索降级问题,而是无搜索 Responses 请求在角色 `dossier` 阶段自身超时。 + +## 根因 + +`dossier` 阶段要求模型为角色补齐 `backstoryReveal`、`skills` 和 `initialItems`。这部分属于结果页和运行时可继续编辑的养成档案润色,不是底稿主链必须依赖 LLM 才能成立的业务真相。 + +在深海题材现场输入下,即使单个可扮演角色的养成档案请求也发生超时;继续只靠减小 batch size 不能保证主链稳定。 + +## 落地策略 + +1. `narrative` 阶段仍保持 LLM 生成,因为它补的是角色背景、性格、动机和行动风格。 +2. `dossier` 阶段改为 LLM 优先。 +3. 若 `dossier` 阶段发生请求超时、连接失败、上游失败、空响应或 JSON 解析失败,不再让底稿 operation 失败。 +4. 兜底使用当前角色对象本地生成: + - `backstoryReveal.publicSummary` + - 4 个固定好感档位章节,档位为 `15 / 30 / 60 / 90` + - 3 个技能 + - 3 个初始物品 +5. 本地兜底只补缺字段,不覆盖模型已经成功生成的完整字段。 +6. SpacetimeDB 仍只接收 api-server 生成后的确定性 `draftProfile`;不新增外部 I/O。 + +## 验收标准 + +1. `story-dossier` 超时后,底稿生成继续完成,角色仍包含 `backstoryReveal / skills / initialItems`。 +2. fallback 后的角色名必须与输入角色名一致,不能增删改名。 +3. `backstoryReveal.chapters` 必须恰好 4 个,`affinityRequired` 固定为 `15 / 30 / 60 / 90`。 +4. `cargo test -p api-server custom_world_foundation_draft --manifest-path server-rs/Cargo.toml` 通过。 diff --git a/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md b/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md new file mode 100644 index 00000000..8040f66e --- /dev/null +++ b/docs/technical/RPG_IMAGE_GENERATION_GPT_IMAGE_2_MIGRATION_2026-05-02.md @@ -0,0 +1,76 @@ +# RPG 图片生成 gpt-image-2 迁移 2026-05-02 + +## 背景 + +RPG 创作链路里有两类正式图片资产需要统一模型: + +1. 角色主图候选生成。 +2. 场景幕背景图生成。 + +旧实现中角色主图默认使用 `wan2.7-image-pro`,场景图根据是否有参考图分别使用 DashScope 文生图与图生图模型。拼图链路已经接入 APIMart 的 OpenAI 兼容 `/images/generations`,并以 `gpt-image-2` 作为默认图片模型,因此本次 RPG 图片迁移复用同一类服务端配置与请求口径。 + +## 落地范围 + +1. `POST /api/assets/character-visual/generate` + - 前端默认 `imageModel` 改为 `gpt-image-2`。 + - 后端把空值、历史 `wan2.7-image-pro`、`wan2.7-image` 统一归一为 `gpt-image-2`。 + - 继续保留角色主图 prompt、负向 prompt、审核失败后原创安全 prompt 兜底、PNG 去绿幕/去白底、OSS 草稿与发布链路。 +2. `POST /api/runtime/custom-world/scene-image` + - 文生图与参考图生图统一走 `gpt-image-2`。 + - 参考图继续只支持 Data URL 与 `/generated-*` 旧路径,经服务端回读后传给上游。 + - 继续保留场景 prompt 编译、负向 prompt、OSS、`asset_object` 与 `asset_entity_binding` 链路。 +3. 自动草稿资产生成中的角色主图与幕背景图分别复用上述后端函数,因此同步使用 `gpt-image-2`。 + +## 上游协议 + +服务端使用: + +```text +POST {APIMART_BASE_URL}/images/generations +Authorization: Bearer {APIMART_API_KEY} +model = gpt-image-2 +``` + +请求体统一包含: + +1. `model` +2. `prompt` +3. `n` +4. `size` +5. 有参考图时增加 `image_urls` + +尺寸归一规则: + +1. `1024*1024`、`1024x1024`、`1:1` -> `1:1` +2. `1280*720`、`1600*900`、`16:9` -> `16:9` + +响应解析兼容同步 `data[].url`、`data[].b64_json` 与异步 `task_id` / `GET /tasks/{task_id}` 结构。 + +## 非范围 + +1. 不迁移角色动作图片序列帧或视频模型。 +2. 不迁移 Custom World 封面图生成。 +3. 不改变 SpacetimeDB 表结构、migration 或 bindings。 +4. 不改变前端 UI 面板文案。 + +## 配置 + +本次复用已有 APIMart 配置: + +```text +APIMART_BASE_URL=https://api.apimart.ai/v1 +APIMART_API_KEY=... +APIMART_IMAGE_REQUEST_TIMEOUT_MS=180000 +``` + +`APIMART_API_KEY` 缺失时,角色主图与场景图返回 `SERVICE_UNAVAILABLE`,`details.provider = "apimart"`。 + +## 验收 + +1. 角色主图生成请求上游 `model` 为 `gpt-image-2`。 +2. 场景图生成请求上游 `model` 为 `gpt-image-2`。 +3. 旧前端或历史草稿传 `wan2.7-image-pro` 时不会回退旧模型。 +4. 场景参考图生成仍能把参考图 Data URL 放入 `image_urls`。 +5. 角色主图生成后仍执行原有 PNG 透明背景处理与 OSS 写入。 +6. `cargo test -p api-server character_visual --manifest-path server-rs/Cargo.toml` 通过。 +7. `cargo test -p api-server custom_world_ai --manifest-path server-rs/Cargo.toml` 通过。 diff --git a/server-rs/crates/api-server/logs/llm-raw/1777733782126-16788-000002-upstream_status_failed.input.json b/server-rs/crates/api-server/logs/llm-raw/1777733782126-16788-000002-upstream_status_failed.input.json new file mode 100644 index 00000000..5a9e6883 --- /dev/null +++ b/server-rs/crates/api-server/logs/llm-raw/1777733782126-16788-000002-upstream_status_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": false, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。" + }, + { + "role": "user", + "content": "请为下面这一批场景角色补全养成档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n世界核心信息:\n世界:雾港归航\n副标题:失灯旧案\n世界概述:守灯人与群岛议会围绕沉船旧案对峙。\n世界基调:海雾悬疑\n玩家核心目标:查清父亲沉船真相\n主要势力:群岛议会、灯塔署\n核心冲突:守灯塔的旧档案被人改写。\n开局归处:旧灯塔归舍(海雾边缘的守灯人旧居。)\n关键场景:旧灯塔(雾中仍亮着错位灯火)、沉船湾(退潮后露出旧船骨)\n本批角色:\n- 议长甲 / 群岛议长\n身份:遮掩者\n框架描述:压住旧档的人\n预设好感:-10\n关系切入口:旧档案\n标签:议会\n- 潮医乙 / 潮汐医师\n身份:证人\n框架描述:知道沉船伤痕\n预设好感:20\n关系切入口:救治记录\n标签:证人\n出现场景:沉船湾\n输出 JSON 模板:\n{\n \"storyNpcs\": [\n {\n \"name\": \"角色名称\",\n \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },\n \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],\n \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]\n }\n ]\n}\n要求:\n- 必须只补全本批角色,name 必须与本批角色完全一致,不得增删改名。\n- 每个角色必须包含:name、backstoryReveal、skills、initialItems。\n- backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。" + } + ] +} \ No newline at end of file diff --git a/server-rs/crates/api-server/logs/llm-raw/1777733782126-16788-000002-upstream_status_failed.output.txt b/server-rs/crates/api-server/logs/llm-raw/1777733782126-16788-000002-upstream_status_failed.output.txt new file mode 100644 index 00000000..d6fde52f --- /dev/null +++ b/server-rs/crates/api-server/logs/llm-raw/1777733782126-16788-000002-upstream_status_failed.output.txt @@ -0,0 +1 @@ +{"error":{"message":"story dossier timeout"}} \ No newline at end of file diff --git a/server-rs/crates/api-server/logs/llm-raw/1777733782128-16788-000003-upstream_status_failed.input.json b/server-rs/crates/api-server/logs/llm-raw/1777733782128-16788-000003-upstream_status_failed.input.json new file mode 100644 index 00000000..d1910bfa --- /dev/null +++ b/server-rs/crates/api-server/logs/llm-raw/1777733782128-16788-000003-upstream_status_failed.input.json @@ -0,0 +1,18 @@ +{ + "provider": "ark", + "protocol": "responses", + "model": "deepseek-v3-2-251201", + "stream": false, + "attempt": 1, + "maxTokens": null, + "messages": [ + { + "role": "system", + "content": "你是严格的世界草稿 JSON 生成器。\n只输出一个 JSON 对象,不要输出 Markdown、代码块、解释或额外文字。" + }, + { + "role": "user", + "content": "请为下面这一批场景角色补全养成档案。\n你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。\n世界核心信息:\n世界:雾港归航\n副标题:失灯旧案\n世界概述:守灯人与群岛议会围绕沉船旧案对峙。\n世界基调:海雾悬疑\n玩家核心目标:查清父亲沉船真相\n主要势力:群岛议会、灯塔署\n核心冲突:守灯塔的旧档案被人改写。\n开局归处:旧灯塔归舍(海雾边缘的守灯人旧居。)\n关键场景:旧灯塔(雾中仍亮着错位灯火)、沉船湾(退潮后露出旧船骨)\n本批角色:\n- 雾商丙 / 雾港商人\n身份:中间人\n框架描述:贩卖航线的人\n预设好感:5\n关系切入口:伪造海图\n标签:商人\n- 灯童丁 / 灯塔学徒\n身份:目击者\n框架描述:听见夜钟的人\n预设好感:30\n关系切入口:夜钟\n标签:学徒\n出现场景:旧灯塔\n输出 JSON 模板:\n{\n \"storyNpcs\": [\n {\n \"name\": \"角色名称\",\n \"backstoryReveal\": { \"publicSummary\": \"公开摘要\", \"chapters\": [{ \"affinityRequired\": 15, \"title\": \"羁绊章节\", \"summary\": \"章节摘要\" }] },\n \"skills\": [{ \"name\": \"技能名\", \"summary\": \"技能摘要\", \"style\": \"风格\" }],\n \"initialItems\": [{ \"name\": \"物品名\", \"category\": \"道具\", \"quantity\": 1, \"rarity\": \"common\", \"description\": \"描述\", \"tags\": [\"标签\"] }]\n }\n ]\n}\n要求:\n- 必须只补全本批角色,name 必须与本批角色完全一致,不得增删改名。\n- 每个角色必须包含:name、backstoryReveal、skills、initialItems。\n- backstoryReveal 必须包含 publicSummary 和 4 个 chapters,chapters.affinityRequired 固定为 15、30、60、90。\n- skills 默认 3 个;initialItems 默认 3 个;不要输出 backstory、personality、motivation、combatStyle。\n- 所有生成文本都必须使用中文。\n- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。" + } + ] +} \ No newline at end of file diff --git a/server-rs/crates/api-server/logs/llm-raw/1777733782128-16788-000003-upstream_status_failed.output.txt b/server-rs/crates/api-server/logs/llm-raw/1777733782128-16788-000003-upstream_status_failed.output.txt new file mode 100644 index 00000000..d6fde52f --- /dev/null +++ b/server-rs/crates/api-server/logs/llm-raw/1777733782128-16788-000003-upstream_status_failed.output.txt @@ -0,0 +1 @@ +{"error":{"message":"story dossier timeout"}} \ No newline at end of file diff --git a/server-rs/crates/api-server/src/character_visual_assets.rs b/server-rs/crates/api-server/src/character_visual_assets.rs index bbe8f84d..e89c7157 100644 --- a/server-rs/crates/api-server/src/character_visual_assets.rs +++ b/server-rs/crates/api-server/src/character_visual_assets.rs @@ -1,7 +1,4 @@ -use std::{ - collections::BTreeMap, - time::{Duration, Instant}, -}; +use std::collections::BTreeMap; use axum::{ Json, @@ -38,17 +35,20 @@ use crate::{ build_fallback_moderation_safe_character_visual_prompt, }, http_error::AppError, + openai_image_generation::{ + DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, OpenAiImageSettings, + build_openai_image_http_client, create_openai_image_generation, + require_openai_image_settings, + }, platform_errors::map_oss_error, request_context::RequestContext, state::AppState, }; -use tokio::time::sleep; -const CHARACTER_VISUAL_MODEL: &str = "wan2.7-image-pro"; +const CHARACTER_VISUAL_MODEL: &str = GPT_IMAGE_2_MODEL; const CHARACTER_VISUAL_ASSET_KIND: &str = "character_visual"; const CHARACTER_VISUAL_ENTITY_KIND: &str = "character"; const CHARACTER_VISUAL_SLOT: &str = "primary_visual"; -const CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS: u64 = 2_500; const CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS: u8 = 2; #[derive(Clone, Debug)] @@ -79,7 +79,7 @@ pub async fn generate_character_visual( let fallback_prompt = build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str()); let character_id = normalize_required_text(payload.character_id.as_str(), "character"); - let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL); + let model = resolve_character_visual_model(payload.image_model.as_str()); let size = normalize_required_text(payload.size.as_str(), "1024*1024"); let candidate_count = payload.candidate_count.clamp(1, 4); @@ -94,8 +94,8 @@ pub async fn generate_character_visual( .map_err(|error| character_visual_error_response(&request_context, error))?; let result = async { - let settings = require_dashscope_settings(&state)?; - let http_client = build_dashscope_http_client(&settings)?; + let settings = require_openai_image_settings(&state)?; + let http_client = build_openai_image_http_client(&settings)?; state .ai_task_service() @@ -121,6 +121,7 @@ pub async fn generate_character_visual( "sourceMode": payload.source_mode, "size": size, "referenceImageCount": payload.reference_image_data_urls.len(), + "provider": "apimart", }) .to_string(), ), @@ -192,7 +193,7 @@ pub async fn generate_character_visual( ), structured_payload_json: Some( json!({ - "provider": "dashscope", + "provider": "apimart", "taskId": generated.task_id, "model": model, "imageCount": generated.images.len(), @@ -307,7 +308,7 @@ pub(crate) async fn generate_character_primary_visual_for_profile( let fallback_prompt = build_fallback_moderation_safe_character_visual_prompt(payload.prompt_text.as_str()); let character_id = normalize_required_text(payload.character_id.as_str(), "character"); - let model = normalize_required_text(payload.image_model.as_str(), CHARACTER_VISUAL_MODEL); + let model = resolve_character_visual_model(payload.image_model.as_str()); let size = normalize_required_text(payload.size.as_str(), "1024*1024"); create_visual_task( state, @@ -317,8 +318,8 @@ pub(crate) async fn generate_character_primary_visual_for_profile( &model, &prompt, )?; - let settings = require_dashscope_settings(state)?; - let http_client = build_dashscope_http_client(&settings)?; + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; state .ai_task_service() .start_task(task_id.as_str(), current_utc_micros()) @@ -768,47 +769,17 @@ fn build_character_visual_job_payload(task: AiTaskSnapshot) -> CharacterAssetJob } } -fn require_dashscope_settings(state: &AppState) -> Result { - // Stage 2 的真实图片生成统一走 DashScope,这里先把配置缺失拦在业务入口前。 - let base_url = state.config.dashscope_base_url.trim().trim_end_matches('/'); - if base_url.is_empty() { - return Err( - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "dashscope", - "reason": "DASHSCOPE_BASE_URL 未配置", - })), +fn resolve_character_visual_model(value: &str) -> String { + // 中文注释:旧前端和历史草稿可能仍传 wan2.7-image-pro;RPG 主图当前统一归一到 gpt-image-2。 + let trimmed = value.trim(); + if !trimmed.is_empty() && trimmed != CHARACTER_VISUAL_MODEL { + tracing::warn!( + requested_model = trimmed, + effective_model = CHARACTER_VISUAL_MODEL, + "角色主形象图片模型已归一到 gpt-image-2" ); } - - let api_key = state - .config - .dashscope_api_key - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({ - "provider": "dashscope", - "reason": "DASHSCOPE_API_KEY 未配置", - })) - })?; - - Ok(DashScopeSettings { - base_url: base_url.to_string(), - api_key: api_key.to_string(), - request_timeout_ms: state.config.dashscope_image_request_timeout_ms.max(1), - }) -} -fn build_dashscope_http_client(settings: &DashScopeSettings) -> Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(settings.request_timeout_ms)) - .build() - .map_err(|error| { - AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ - "provider": "dashscope", - "message": format!("构造 DashScope HTTP 客户端失败:{error}"), - })) - }) + CHARACTER_VISUAL_MODEL.to_string() } async fn resolve_reference_image_as_data_url( @@ -868,9 +839,7 @@ async fn resolve_reference_image_as_data_url( .get(signed.signed_url) .send() .await - .map_err(|error| { - map_dashscope_request_error(format!("读取角色主形象参考图失败:{error}")) - })?; + .map_err(|error| map_image_request_error(format!("读取角色主形象参考图失败:{error}")))?; let status = response.status(); let content_type = response .headers() @@ -879,7 +848,7 @@ async fn resolve_reference_image_as_data_url( .unwrap_or("image/png") .to_string(); let body = response.bytes().await.map_err(|error| { - map_dashscope_request_error(format!("读取角色主形象参考图内容失败:{error}")) + map_image_request_error(format!("读取角色主形象参考图内容失败:{error}")) })?; if !status.is_success() { return Err( @@ -911,7 +880,7 @@ async fn resolve_reference_image_as_data_url( async fn create_character_visual_generation( http_client: &reqwest::Client, - settings: &DashScopeSettings, + settings: &OpenAiImageSettings, model: &str, prompt: &str, fallback_prompt: &str, @@ -922,12 +891,13 @@ async fn create_character_visual_generation( let mut active_prompt = prompt; let mut moderation_fallback_applied = false; let mut last_moderation_error = String::new(); + let model = resolve_character_visual_model(model); for attempt_index in 0..CHARACTER_VISUAL_MODERATION_FALLBACK_MAX_ATTEMPTS { match create_character_visual_generation_once( http_client, settings, - model, + model.as_str(), active_prompt, size, candidate_count, @@ -944,7 +914,7 @@ async fn create_character_visual_generation( if attempt_index == 0 && !fallback_prompt.trim().is_empty() && fallback_prompt.trim() != prompt.trim() - && is_dashscope_moderation_error(&error) => + && is_image_moderation_error(&error) => { last_moderation_error = error.body_text(); active_prompt = fallback_prompt; @@ -954,7 +924,7 @@ async fn create_character_visual_generation( } } - Err(map_dashscope_request_error(format!( + Err(map_image_request_error(format!( "角色主形象安全兜底重试未返回结果:{}", last_moderation_error.if_empty_then("上游内容审核仍未通过。") ))) @@ -962,183 +932,44 @@ async fn create_character_visual_generation( async fn create_character_visual_generation_once( http_client: &reqwest::Client, - settings: &DashScopeSettings, - model: &str, + settings: &OpenAiImageSettings, + _model: &str, prompt: &str, size: &str, candidate_count: u32, reference_images: &[String], ) -> Result { - let mut content = vec![json!({ "text": prompt })]; - for image in reference_images { - content.push(json!({ "image": image })); - } - - let response = http_client - .post(format!( - "{}/services/aigc/image-generation/generation", - settings.base_url - )) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header("X-DashScope-Async", "enable") - .json(&json!({ - "model": model, - "input": { - "messages": [ - { - "role": "user", - "content": content, - } - ], - }, - "parameters": { - "n": candidate_count, - "size": size, - "negative_prompt": build_character_visual_negative_prompt(), - "prompt_extend": true, - "watermark": false, - }, - })) - .send() - .await - .map_err(|error| map_dashscope_request_error(format!("创建角色主形象任务失败:{error}")))?; - let response_status = response.status(); - let response_text = response.text().await.map_err(|error| { - map_dashscope_request_error(format!("读取角色主形象任务响应失败:{error}")) - })?; - if !response_status.is_success() { - return Err(map_dashscope_upstream_error( - response_text.as_str(), - "创建角色主形象任务失败。", - )); - } - let response_json = parse_json_payload(response_text.as_str(), "创建角色主形象任务失败。")?; - let task_id = extract_task_id(&response_json.payload).ok_or_else(|| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", - "message": "角色主形象任务未返回 task_id", - })) - })?; - - let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); - while Instant::now() < deadline { - let poll_response = http_client - .get(format!("{}/tasks/{}", settings.base_url, task_id)) - .header( - reqwest::header::AUTHORIZATION, - format!("Bearer {}", settings.api_key), - ) - .send() - .await - .map_err(|error| { - map_dashscope_request_error(format!("查询角色主形象任务失败:{error}")) - })?; - let poll_status = poll_response.status(); - let poll_text = poll_response.text().await.map_err(|error| { - map_dashscope_request_error(format!("读取角色主形象任务状态失败:{error}")) - })?; - if !poll_status.is_success() { - return Err(map_dashscope_upstream_error( - poll_text.as_str(), - "查询角色主形象任务失败。", - )); - } - let poll_json = parse_json_payload(poll_text.as_str(), "查询角色主形象任务失败。")?; - let task_status = find_first_string_by_key(&poll_json.payload, "task_status") - .unwrap_or_default() - .trim() - .to_string(); - if task_status == "SUCCEEDED" { - let image_urls = extract_image_urls(&poll_json.payload); - if image_urls.is_empty() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", - "message": "角色主形象生成成功,但没有返回可下载图片。", - })), - ); - } - - let mut images = Vec::with_capacity(image_urls.len()); - for image_url in image_urls { - images.push( - download_generated_image( - http_client, - image_url.as_str(), - "下载角色主形象候选图失败。", - ) - .await?, - ); - } - - return Ok(GeneratedCharacterVisuals { - task_id, - actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"), - submitted_prompt: prompt.to_string(), - moderation_fallback_applied: false, - images, - }); - } - if matches!(task_status.as_str(), "FAILED" | "UNKNOWN" | "CANCELED") { - return Err(map_dashscope_upstream_error( - poll_text.as_str(), - "角色主形象任务执行失败。", - )); - } - - sleep(Duration::from_millis( - CHARACTER_VISUAL_TASK_POLL_INTERVAL_MS, - )) - .await; - } - - Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", - "message": "角色主形象任务执行超时,请稍后重试。", - })), + let generated = create_openai_image_generation( + http_client, + settings, + prompt, + Some(build_character_visual_negative_prompt().as_str()), + size, + candidate_count, + reference_images, + "角色主形象生成失败", ) + .await?; + + Ok(GeneratedCharacterVisuals { + task_id: generated.task_id, + actual_prompt: generated.actual_prompt, + submitted_prompt: prompt.to_string(), + moderation_fallback_applied: false, + images: generated + .images + .into_iter() + .map(downloaded_openai_to_character_visual_image) + .collect(), + }) } -async fn download_generated_image( - http_client: &reqwest::Client, - image_url: &str, - fallback_message: &str, -) -> Result { - let response = http_client - .get(image_url) - .send() - .await - .map_err(|error| map_dashscope_request_error(format!("{fallback_message}:{error}")))?; - let status = response.status(); - let content_type = response - .headers() - .get(reqwest::header::CONTENT_TYPE) - .and_then(|value| value.to_str().ok()) - .unwrap_or("image/jpeg") - .to_string(); - let body = response - .bytes() - .await - .map_err(|error| map_dashscope_request_error(format!("{fallback_message}:{error}")))?; - if !status.is_success() { - return Err( - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", - "message": fallback_message, - "status": status.as_u16(), - })), - ); - } - - let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); - let mut bytes = body.to_vec(); - let mut extension = mime_to_extension(normalized_mime_type.as_str()).to_string(); - let mut mime_type = normalized_mime_type; +fn downloaded_openai_to_character_visual_image( + image: DownloadedOpenAiImage, +) -> DownloadedGeneratedImage { + let mut bytes = image.bytes; + let mut extension = image.extension; + let mut mime_type = image.mime_type; if mime_type == "image/png" && let Some(optimized) = try_apply_background_alpha_to_png(bytes.as_slice()) @@ -1148,11 +979,11 @@ async fn download_generated_image( mime_type = "image/png".to_string(); } - Ok(DownloadedGeneratedImage { + DownloadedGeneratedImage { bytes, mime_type, extension, - }) + } } /// 统一的 PNG 透明背景后处理入口。 @@ -1339,79 +1170,32 @@ fn map_character_visual_oss_error(error: platform_oss::OssError) -> AppError { map_oss_error(error, "aliyun-oss") } -fn parse_json_payload( - raw_text: &str, - fallback_message: &str, -) -> Result { - serde_json::from_str::(raw_text) - .map(|payload| ParsedJsonPayload { payload }) - .map_err(|error| { - AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", - "message": format!("{fallback_message}:解析响应失败:{error}"), - })) - }) -} - -fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { - if raw_text.trim().is_empty() { - return fallback_message.to_string(); - } - - if let Ok(parsed) = serde_json::from_str::(raw_text) { - if let Some(message) = parsed - .pointer("/error/message") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return message.to_string(); - } - if let Some(message) = parsed - .get("message") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return message.to_string(); - } - if let Some(code) = parsed - .pointer("/error/code") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return format!("{fallback_message}({code})"); - } - if let Some(code) = parsed - .get("code") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - { - return format!("{fallback_message}({code})"); - } - } - - raw_text.trim().to_string() -} - -fn map_dashscope_request_error(message: String) -> AppError { +fn map_image_request_error(message: String) -> AppError { AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", + "provider": "apimart", "message": message, })) } -fn map_dashscope_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { +#[cfg(test)] +fn map_image_upstream_error(raw_text: &str, fallback_message: &str) -> AppError { + let message = match raw_text.trim() { + "" => fallback_message.to_string(), + value => value.to_string(), + }; AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ - "provider": "dashscope", - "message": parse_api_error_message(raw_text, fallback_message), + "provider": "apimart", + "message": message, "raw": raw_text.trim(), })) } -fn is_dashscope_moderation_error(error: &AppError) -> bool { +#[cfg(test)] +fn is_image_test_moderation_error(error: &AppError) -> bool { + is_image_moderation_error(error) +} + +fn is_image_moderation_error(error: &AppError) -> bool { let text = error.body_text(); let normalized = text.to_ascii_lowercase(); normalized.contains("ipinfringementsuspect") @@ -1424,77 +1208,6 @@ fn is_dashscope_moderation_error(error: &AppError) -> bool { || text.contains("知识产权") } -fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { - match value { - Value::Array(entries) => { - for entry in entries { - collect_strings_by_key(entry, target_key, results); - } - } - Value::Object(object) => { - for (key, nested_value) in object { - if key == target_key - && let Some(text) = nested_value - .as_str() - .map(str::trim) - .filter(|value| !value.is_empty()) - { - results.push(text.to_string()); - continue; - } - collect_strings_by_key(nested_value, target_key, results); - } - } - _ => {} - } -} - -fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { - let mut results = Vec::new(); - collect_strings_by_key(value, target_key, &mut results); - results.into_iter().next() -} - -fn extract_task_id(payload: &Value) -> Option { - find_first_string_by_key(payload, "task_id") -} - -fn extract_image_urls(payload: &Value) -> Vec { - let mut urls = Vec::new(); - collect_strings_by_key(payload, "image", &mut urls); - collect_strings_by_key(payload, "url", &mut urls); - let mut deduped = Vec::new(); - for url in urls { - if !deduped.contains(&url) { - deduped.push(url); - } - } - deduped -} - -fn normalize_downloaded_image_mime_type(content_type: &str) -> String { - let mime_type = content_type - .split(';') - .next() - .map(str::trim) - .unwrap_or("image/jpeg"); - match mime_type { - "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { - mime_type.to_string() - } - _ => "image/jpeg".to_string(), - } -} - -fn mime_to_extension(mime_type: &str) -> &str { - match mime_type { - "image/png" => "png", - "image/webp" => "webp", - "image/gif" => "gif", - _ => "jpg", - } -} - fn parse_image_data_url(value: &str) -> Option { let body = value.trim().strip_prefix("data:")?; let (mime_type, data) = body.split_once(";base64,")?; @@ -2012,12 +1725,6 @@ impl EmptyFallback for String { } } -struct DashScopeSettings { - base_url: String, - api_key: String, - request_timeout_ms: u64, -} - struct GeneratedCharacterVisuals { task_id: String, actual_prompt: Option, @@ -2032,10 +1739,6 @@ struct DownloadedGeneratedImage { extension: String, } -struct ParsedJsonPayload { - payload: Value, -} - struct ParsedImageDataUrl { mime_type: String, bytes: Vec, @@ -2066,13 +1769,22 @@ mod tests { } #[test] - fn dashscope_ip_infringement_error_uses_moderation_fallback() { - let error = map_dashscope_upstream_error( + fn legacy_character_visual_model_normalizes_to_gpt_image_2() { + assert_eq!( + resolve_character_visual_model("wan2.7-image-pro"), + "gpt-image-2" + ); + assert_eq!(resolve_character_visual_model(""), "gpt-image-2"); + } + + #[test] + fn image_ip_infringement_error_uses_moderation_fallback() { + let error = map_image_upstream_error( r#"{"request_id":"a18fb05d","output":{"task_id":"cb768c95","task_status":"FAILED","code":"IPInfringementSuspect","message":"Input data is suspected of being involved in IP infringement."}}"#, "角色主形象任务执行失败。", ); - assert!(is_dashscope_moderation_error(&error)); + assert!(is_image_test_moderation_error(&error)); } #[test] diff --git a/server-rs/crates/api-server/src/custom_world_ai.rs b/server-rs/crates/api-server/src/custom_world_ai.rs index b6b10402..73eaae75 100644 --- a/server-rs/crates/api-server/src/custom_world_ai.rs +++ b/server-rs/crates/api-server/src/custom_world_ai.rs @@ -36,6 +36,10 @@ use crate::{ }, http_error::AppError, llm_model_routing::CREATION_TEMPLATE_LLM_MODEL, + openai_image_generation::{ + DownloadedOpenAiImage, GPT_IMAGE_2_MODEL, build_openai_image_http_client, + create_openai_image_generation, require_openai_image_settings, + }, platform_errors::map_oss_error, prompt::scene_background::{ DEFAULT_CUSTOM_WORLD_SCENE_IMAGE_NEGATIVE_PROMPT, SceneImagePromptLandmark, @@ -312,6 +316,8 @@ struct DownloadedRemoteImage { bytes: Vec, } +const RPG_SCENE_IMAGE_MODEL: &str = GPT_IMAGE_2_MODEL; + struct CoverPromptContext { opening_act_title: String, opening_act_summary: String, @@ -443,7 +449,7 @@ pub async fn generate_custom_world_scene_image( let owner_user_id = authenticated.claims().user_id().to_string(); let normalized = normalize_scene_image_request(payload) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; - require_dashscope_settings(&state) + require_openai_image_settings(&state) .map_err(|error| custom_world_ai_error_response(&request_context, error))?; let asset_id = format!("custom-scene-{}", current_utc_millis()); let asset = execute_billable_asset_operation( @@ -452,8 +458,8 @@ pub async fn generate_custom_world_scene_image( "scene_image", asset_id.as_str(), async { - let settings = require_dashscope_settings(&state)?; - let http_client = build_dashscope_http_client(&settings)?; + let settings = require_openai_image_settings(&state)?; + let http_client = build_openai_image_http_client(&settings)?; let reference_image = if let Some(reference_image_src) = normalized.reference_image_src.as_deref() { Some( @@ -468,46 +474,32 @@ pub async fn generate_custom_world_scene_image( } else { None }; - let generated = if let Some(reference_image) = reference_image.as_deref() { - create_reference_image_generation( - &http_client, - &settings, - state.config.dashscope_reference_image_model.as_str(), - normalized.prompt.as_str(), - normalized.size.as_str(), - &[reference_image.to_string()], - Some(normalized.negative_prompt.as_str()), - "创建参考图场景编辑任务失败", - "参考图场景编辑未返回图片地址", - "scene-edit", - ) - .await - } else { - create_text_to_image_generation( - &http_client, - &settings, - state.config.dashscope_scene_image_model.as_str(), - normalized.prompt.as_str(), - Some(normalized.negative_prompt.as_str()), - normalized.size.as_str(), - "创建场景图片生成任务失败", - "查询场景图片任务失败", - "场景图片生成任务失败", - "场景图片生成超时或未返回图片地址", - ) - .await - }?; - let scene_model = if reference_image.is_some() { - state.config.dashscope_reference_image_model.clone() - } else { - state.config.dashscope_scene_image_model.clone() - }; - let downloaded = download_remote_image( + let reference_images = reference_image + .as_ref() + .map(|value| vec![value.clone()]) + .unwrap_or_default(); + let generated = create_openai_image_generation( &http_client, - generated.image_url.as_str(), - "下载生成图片失败", + &settings, + normalized.prompt.as_str(), + Some(normalized.negative_prompt.as_str()), + normalized.size.as_str(), + 1, + &reference_images, + "场景图片生成失败", ) .await?; + let downloaded = generated + .images + .into_iter() + .next() + .map(downloaded_openai_to_custom_world_image) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": "场景图片生成成功但未返回图片。", + })) + })?; let upload = PreparedAssetUpload { prefix: LegacyAssetPrefix::CustomWorldScenes, path_segments: vec![ @@ -539,7 +531,7 @@ pub async fn generate_custom_world_scene_image( image_src: String::new(), asset_id: asset_id.clone(), source_type: "generated".to_string(), - model: Some(scene_model), + model: Some(RPG_SCENE_IMAGE_MODEL.to_string()), size: Some(normalized.size), task_id: Some(generated.task_id), prompt: Some(normalized.prompt), @@ -588,27 +580,30 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile( }), }; let normalized = normalize_scene_image_request(payload)?; - let settings = require_dashscope_settings(state)?; - let http_client = build_dashscope_http_client(&settings)?; - let generated = create_text_to_image_generation( + let settings = require_openai_image_settings(state)?; + let http_client = build_openai_image_http_client(&settings)?; + let generated = create_openai_image_generation( &http_client, &settings, - state.config.dashscope_scene_image_model.as_str(), normalized.prompt.as_str(), Some(normalized.negative_prompt.as_str()), normalized.size.as_str(), - "创建场景图片生成任务失败", - "查询场景图片任务失败", - "场景图片生成任务失败", - "场景图片生成超时或未返回图片地址", - ) - .await?; - let downloaded = download_remote_image( - &http_client, - generated.image_url.as_str(), - "下载生成图片失败", + 1, + &[], + "场景图片生成失败", ) .await?; + let downloaded = generated + .images + .into_iter() + .next() + .map(downloaded_openai_to_custom_world_image) + .ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": "场景图片生成成功但未返回图片。", + })) + })?; let asset_id = format!("custom-scene-{}", current_utc_millis()); let upload = PreparedAssetUpload { prefix: LegacyAssetPrefix::CustomWorldScenes, @@ -633,7 +628,7 @@ pub(crate) async fn generate_custom_world_scene_image_for_profile( slot: "scene_image", source_job_id: Some(generated.task_id.clone()), }; - let model = state.config.dashscope_scene_image_model.clone(); + let model = RPG_SCENE_IMAGE_MODEL.to_string(); let prompt = normalized.prompt.clone(); let asset = persist_custom_world_asset( state, @@ -1634,6 +1629,14 @@ async fn download_remote_image( }) } +fn downloaded_openai_to_custom_world_image(image: DownloadedOpenAiImage) -> DownloadedRemoteImage { + DownloadedRemoteImage { + extension: image.extension, + mime_type: image.mime_type, + bytes: image.bytes, + } +} + fn optimize_uploaded_cover_image( parsed_data_url: &ParsedImageDataUrl, crop_rect: &CustomWorldCoverCropRect, @@ -2451,6 +2454,12 @@ mod tests { serde_json::from_slice(&body).expect("body should be valid json") } + fn build_state_without_apimart_key() -> AppState { + let mut config = AppConfig::default(); + config.apimart_api_key = None; + AppState::new(config).expect("state should build") + } + fn build_state_without_dashscope_key() -> AppState { let mut config = AppConfig::default(); config.dashscope_api_key = None; @@ -2458,8 +2467,8 @@ mod tests { } #[tokio::test] - async fn scene_image_returns_service_unavailable_when_dashscope_missing() { - let state = build_state_without_dashscope_key(); + async fn scene_image_returns_service_unavailable_when_apimart_missing() { + let state = build_state_without_apimart_key(); let request_context = build_request_context("POST /api/runtime/custom-world/scene-image"); let authenticated = build_authenticated(&state); @@ -2482,7 +2491,7 @@ mod tests { })), ) .await - .expect_err("missing dashscope should fail"); + .expect_err("missing apimart should fail"); let payload = read_error_response(response).await; assert_eq!( @@ -2491,7 +2500,7 @@ mod tests { ); assert_eq!( payload["error"]["details"]["provider"], - Value::String("dashscope".to_string()) + Value::String("apimart".to_string()) ); } @@ -2612,6 +2621,11 @@ mod tests { assert_eq!(normalized.prompt, manual_prompt); } + #[test] + fn scene_image_response_model_is_gpt_image_2() { + assert_eq!(RPG_SCENE_IMAGE_MODEL, "gpt-image-2"); + } + #[tokio::test] async fn cover_image_returns_service_unavailable_when_dashscope_missing() { let state = build_state_without_dashscope_key(); diff --git a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs index 44b60817..f18845fa 100644 --- a/server-rs/crates/api-server/src/custom_world_foundation_draft.rs +++ b/server-rs/crates/api-server/src/custom_world_foundation_draft.rs @@ -165,7 +165,8 @@ const FOUNDATION_DRAFT_PLAYABLE_COUNT: usize = 1; const FOUNDATION_DRAFT_STORY_COUNT: usize = 8; const FOUNDATION_DRAFT_LANDMARK_COUNT: usize = 2; const FOUNDATION_ROLE_OUTLINE_BATCH_SIZE: usize = 2; -const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 2; +// 中文注释:单个场景已经包含三幕事件、三幕背景图 prompt 和 NPC 分配;按 1 个场景拆批,避免 landmark seed 大 JSON 在 Responses 请求中超时。 +const FOUNDATION_LANDMARK_BATCH_SIZE: usize = 1; const FOUNDATION_ROLE_DETAIL_BATCH_SIZE: usize = 2; const WORLD_ATTRIBUTE_SLOT_IDS: [&str; 6] = ["axis_a", "axis_b", "axis_c", "axis_d", "axis_e", "axis_f"]; @@ -586,7 +587,7 @@ async fn expand_foundation_role_entries( .as_str(), to_batch_progress(progress_range, processed_count, base_entries.len()), ); - let raw = request_foundation_json_stage( + let raw_result = request_foundation_json_stage( llm_client, build_custom_world_role_batch_prompt(framework, role_type, batch, stage), format!( @@ -610,8 +611,20 @@ async fn expand_foundation_role_entries( "角色档案补全阶段没有返回有效内容。", enable_web_search, ) - .await?; - merged_entries.extend(array_field(&raw, role_key(role_type))); + .await; + match raw_result { + Ok(raw) => merged_entries.extend(array_field(&raw, role_key(role_type))), + Err(error) if stage == "dossier" => { + warn!( + error = %error, + role_type, + batch_index = batch_index + 1, + "foundation draft 角色养成档案 LLM 补全失败,使用本地结构化兜底" + ); + merged_entries.extend(build_fallback_role_dossier_entries(batch)); + } + Err(error) => return Err(error), + } processed_count = processed_count .saturating_add(batch.len()) .min(base_entries.len()); @@ -635,6 +648,103 @@ async fn expand_foundation_role_entries( Ok(merge_entries_by_name(base_entries, &merged_entries)) } +fn build_fallback_role_dossier_entries(entries: &[JsonValue]) -> Vec { + entries + .iter() + .enumerate() + .map(|(index, entry)| build_fallback_role_dossier_entry(entry, index)) + .collect() +} + +fn build_fallback_role_dossier_entry(entry: &JsonValue, index: usize) -> JsonValue { + let name = json_text(entry, "name").unwrap_or_else(|| format!("角色{}", index + 1)); + let title = json_text(entry, "title").unwrap_or_default(); + let role = json_text(entry, "role").unwrap_or_else(|| "关键角色".to_string()); + let description = json_text(entry, "description").unwrap_or_else(|| role.clone()); + let backstory = json_text(entry, "backstory").unwrap_or_else(|| description.clone()); + let motivation = json_text(entry, "motivation").unwrap_or_else(|| description.clone()); + let tag = json_string_array(entry, "tags") + .and_then(|items| items.first().cloned()) + .unwrap_or_else(|| role.clone()); + let item_prefix = if title.trim().is_empty() { + name.clone() + } else { + title.clone() + }; + + json!({ + "name": name.clone(), + "backstoryReveal": { + "publicSummary": format!("{name}的公开档案围绕“{description}”展开。"), + "chapters": [ + { + "affinityRequired": 15, + "title": "初识", + "summary": format!("{name}以{role}身份进入玩家视野,留下与“{tag}”有关的第一条线索。"), + }, + { + "affinityRequired": 30, + "title": "试探", + "summary": format!("{name}开始透露“{backstory}”背后的压力,但仍保留关键隐情。"), + }, + { + "affinityRequired": 60, + "title": "共同行动", + "summary": format!("{name}围绕“{motivation}”与玩家形成更明确的合作或冲突。"), + }, + { + "affinityRequired": 90, + "title": "真相", + "summary": format!("{name}交出与“{description}”相关的核心选择,关系走向定型。"), + }, + ], + }, + "skills": [ + { + "name": format!("{tag}洞察"), + "summary": format!("围绕“{description}”判断局势与隐藏线索。"), + "style": "侦查", + }, + { + "name": format!("{item_prefix}协助"), + "summary": format!("以{role}身份为玩家提供行动支援。"), + "style": "支援", + }, + { + "name": "临场应变", + "summary": format!("在压力升级时根据“{motivation}”调整行动。"), + "style": "应变", + }, + ], + "initialItems": [ + { + "name": format!("{item_prefix}记录"), + "category": "道具", + "quantity": 1, + "rarity": "common", + "description": format!("记录{name}与“{description}”相关的线索。"), + "tags": [tag.clone()], + }, + { + "name": format!("{tag}信物"), + "category": "道具", + "quantity": 1, + "rarity": "common", + "description": format!("能证明{name}身份和立场的随身物。"), + "tags": [role.clone()], + }, + { + "name": "备用补给", + "category": "消耗品", + "quantity": 1, + "rarity": "common", + "description": format!("{name}在关键行动前准备的基础补给。"), + "tags": ["补给"], + }, + ], + }) +} + fn emit_foundation_draft_progress( on_progress: &mut (impl FnMut(CustomWorldFoundationDraftProgress) + Send), phase_label: &str, @@ -2570,6 +2680,60 @@ mod tests { ); } + #[test] + fn role_dossier_fallback_keeps_names_and_required_fields() { + let entries = vec![json!({ + "name": "埃琳娜·沃克", + "title": "深渊学者", + "role": "深海科研联盟成员", + "description": "执着研究深海生物发光现象的年轻科学家", + "backstory": "她长期追踪发光生物与古代遗迹之间的联系。", + "motivation": "用氧气补给换取玩家的目击信息", + "tags": ["科研人员", "偏执学者"] + })]; + + let fallback = build_fallback_role_dossier_entries(&entries); + let first = fallback.first().expect("fallback entry should exist"); + + assert_eq!(first.get("name"), Some(&json!("埃琳娜·沃克"))); + assert_eq!( + first + .get("backstoryReveal") + .and_then(|value| value.get("chapters")) + .and_then(JsonValue::as_array) + .map(Vec::len), + Some(4) + ); + assert_eq!( + first + .get("backstoryReveal") + .and_then(|value| value.get("chapters")) + .and_then(JsonValue::as_array) + .map(|chapters| { + chapters + .iter() + .filter_map(|chapter| chapter.get("affinityRequired")) + .cloned() + .collect::>() + }), + Some(vec![json!(15), json!(30), json!(60), json!(90)]) + ); + assert_eq!( + first + .get("skills") + .and_then(JsonValue::as_array) + .map(Vec::len), + Some(3) + ); + assert_eq!( + first + .get("initialItems") + .and_then(JsonValue::as_array) + .map(Vec::len), + Some(3) + ); + } + #[tokio::test] async fn generate_custom_world_foundation_draft_uses_seed_text_and_normalizes_fields() { let request_capture = Arc::new(Mutex::new(Vec::new())); @@ -2595,7 +2759,10 @@ mod tests { r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#, ), llm_response( - r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"},{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#, + r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"}]}"#, + ), + llm_response( + r#"{"landmarks":[{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#, ), llm_response( r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#, @@ -2624,15 +2791,24 @@ mod tests { .expect("request capture should lock") .clone(); let request_text = captured_requests.join("\n---request---\n"); + let landmark_seed_requests = captured_requests + .iter() + .filter(|request| request.contains("场景框架名单")) + .collect::>(); - assert!(captured_requests.len() >= 17); + assert!(captured_requests.len() >= 18); assert!(request_text.contains("在失真的海图上追查一场被篡改的沉船事故。")); assert!(request_text.contains("世界核心骨架")); assert!(request_text.contains("attributeSchema")); assert!(request_text.contains("可扮演角色框架名单")); assert!(request_text.contains("场景角色框架名单")); assert!(request_text.contains("场景框架名单")); - assert!(request_text.contains("第一条场景必须是玩家进入世界时所在的开局场景")); + assert_eq!(landmark_seed_requests.len(), 2); + assert!(landmark_seed_requests[0].contains("本批场景必须是玩家进入世界时所在的开局场景")); + assert!(landmark_seed_requests[0].contains("必须生成恰好 1 个场景")); + assert!(landmark_seed_requests[1].contains("本批只生成普通关键场景")); + assert!(landmark_seed_requests[1].contains("这些场景已经生成,禁止重复:旧灯塔")); + assert!(!landmark_seed_requests[0].contains("一次性生成开局场景和普通关键场景")); assert!(request_text.contains("camp 只表示玩家开局时的落脚处占位")); assert!(!request_text.contains("camp.sceneTaskDescription")); assert!(!request_text.contains("camp.actBackgroundPromptTexts")); @@ -2845,6 +3021,150 @@ mod tests { ); } + #[tokio::test] + async fn role_dossier_timeout_uses_local_fallback_and_keeps_generation_alive() { + let request_capture = Arc::new(Mutex::new(Vec::new())); + let server_url = spawn_mock_server_with_statuses( + request_capture.clone(), + vec![ + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"name":"雾港归航","subtitle":"失灯旧案","summary":"守灯人与群岛议会围绕沉船旧案对峙。","tone":"海雾悬疑","playerGoal":"查清父亲沉船真相","templateWorldType":"WUXIA","majorFactions":["群岛议会","灯塔署"],"coreConflicts":["守灯塔的旧档案被人改写。"],"attributeSchema":{"slots":[{"name":"灯骨"},{"name":"潮步"},{"name":"灯识"},{"name":"雾魄"},{"name":"旧约"},{"name":"回澜"}]},"camp":{"name":"旧灯塔归舍","description":"海雾边缘的守灯人旧居。"}}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"playableNpcs":[{"name":"岑灯","title":"返乡守灯人","role":"主角代理","description":"追查旧案的人","visualDescription":"灰蓝旧灯披风压着海盐痕,腰侧挂旧海图筒和短灯杖。","actionDescription":"举灯照海图,短杖点地辨认潮声。","sceneVisualDescription":"旧灯塔回廊被海雾压低,墙上挂满潮湿航线图。","initialAffinity":24,"relationshipHooks":["旧案牵连"],"tags":["守灯人"]}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"storyNpcs":[{"name":"议长甲","title":"群岛议长","role":"遮掩者","description":"压住旧档的人","visualDescription":"深色议会长袍垂到靴边,银扣像封蜡,手里总夹着旧档袋。","actionDescription":"抬手下令封锁,动作缓慢却压迫感强。","sceneVisualDescription":"他常出现在议会石厅高处,旧档柜阴影切过半张脸。","initialAffinity":-10,"relationshipHooks":["旧档案"],"tags":["议会"]},{"name":"潮医乙","title":"潮汐医师","role":"证人","description":"知道沉船伤痕","visualDescription":"浅灰防潮医袍挽到肘部,药箱铜扣发暗,袖口沾着海盐。","actionDescription":"俯身检查伤痕并快速记录潮汐症状,动作谨慎而利落。","sceneVisualDescription":"他常在沉船湾临时诊台前,背后是湿木棚和摇晃药灯。","initialAffinity":20,"relationshipHooks":["救治记录"],"tags":["证人"]}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"storyNpcs":[{"name":"雾商丙","title":"雾港商人","role":"中间人","description":"贩卖航线的人","visualDescription":"暗绿长外套挂满防水口袋,帽檐压低,腰间藏着卷曲海图。","actionDescription":"摊开假海图低声议价,手指总按着袖中短刃。","sceneVisualDescription":"他常站在雾港货棚阴影里,周围堆着封蜡货箱和潮湿灯牌。","initialAffinity":5,"relationshipHooks":["伪造海图"],"tags":["商人"]},{"name":"灯童丁","title":"灯塔学徒","role":"目击者","description":"听见夜钟的人","visualDescription":"瘦小学徒披着过大的灯塔制服,怀里抱黄铜小灯和旧钥匙。","actionDescription":"抱灯快步穿过回廊,听见夜钟时会突然停住回头。","sceneVisualDescription":"他常出现在灯塔螺旋楼梯间,雾光从窄窗切进灰墙。","initialAffinity":30,"relationshipHooks":["夜钟"],"tags":["学徒"]}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"storyNpcs":[{"name":"船魂戊","title":"沉船残魂","role":"异类","description":"困在潮声里","visualDescription":"半透明水渍轮廓披着破碎船员衣,胸口嵌着发暗船钉。","actionDescription":"随潮声漂移抬手指路,情绪激烈时水雾会拉长身影。","sceneVisualDescription":"它常浮在沉船湾退潮泥滩上,身后旧船骨像黑色肋骨。","initialAffinity":-20,"relationshipHooks":["沉船真相"],"tags":["异类"]},{"name":"巡海己","title":"巡海队长","role":"追捕者","description":"封锁海岸线","visualDescription":"深蓝巡海甲衣覆着雨水,肩章锋利,手握带灯长枪。","actionDescription":"举枪封路并用灯束扫过海岸,步伐整齐带压迫感。","sceneVisualDescription":"他常立在封锁栈桥尽头,巡海灯和铁链把退路切断。","initialAffinity":-15,"relationshipHooks":["封锁令"],"tags":["巡海"]}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"storyNpcs":[{"name":"档吏庚","title":"旧档吏","role":"保管者","description":"藏起原始卷宗","visualDescription":"褐色旧档袍袖口磨白,背着沉重文书匣,眼镜后目光闪躲。","actionDescription":"翻找卷宗时动作极快,被追问便把文书匣抱紧后退。","sceneVisualDescription":"他常守在潮湿档案室深处,旧柜标签被盐雾泡卷。","initialAffinity":10,"relationshipHooks":["原始卷宗"],"tags":["档案"]},{"name":"潮女辛","title":"听潮女","role":"引路人","description":"听懂海雾低语","visualDescription":"银灰长发被贝壳绳束起,披轻薄潮纹披肩,赤足沾水。","actionDescription":"侧耳听潮后抬手指向雾中路径,步伐像避开暗流。","sceneVisualDescription":"她常站在礁石浅水间,海雾绕过脚踝,远处灯火错位。","initialAffinity":35,"relationshipHooks":["海雾低语"],"tags":["引路"]}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"landmarks":[{"name":"旧灯塔","description":"雾中仍亮着错位灯火","visualDescription":"旧灯塔立在雾港高礁上,灯室漏出错位光束,石阶和回廊留出可站立空间。","sceneTaskDescription":"首次进入旧灯塔时,追查被篡改的灯火航线记录。","actBackgroundPromptTexts":["雾港高礁上的旧灯塔亮起错位灯火,灯童丁抱灯站在螺旋楼梯口。","潮湿档案室里灯火忽明忽暗,档吏庚抱紧文书匣,海图在桌面卷起。","灯室玻璃被海风震响,灯童丁指向错位航线,远处沉船湾雾光浮现。"],"actEventDescriptions":["灯童丁听见夜钟后发现灯火记录被人动过。","档吏庚试图带走原始卷宗,冲突在灯塔档案室升级。","灯童丁交出旧钥匙,玩家必须决定是否立刻追向沉船湾。"],"actNPCNames":["灯童丁","档吏庚","灯童丁"],"connectedLandmarkNames":["沉船湾"],"entryHook":"灯火按被篡改的航线闪烁。"}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"landmarks":[{"name":"沉船湾","description":"退潮后露出旧船骨","visualDescription":"退潮泥滩露出黑色旧船骨,破帆挂在礁石间,临时诊台灯影摇晃。","sceneTaskDescription":"首次进入沉船湾时,辨认旧船骨里残留的沉船真相。","actBackgroundPromptTexts":["沉船湾退潮泥滩露出旧船骨,船魂戊浮在黑色肋骨般的船梁旁。","湿木棚下潮医乙翻看伤痕记录,海水漫过脚边,巡海灯逼近湾口。","旧船骨深处传出暗号,船魂戊指向被封住的货舱,雾中灯塔光线错位。"],"actEventDescriptions":["船魂戊在退潮声里显形,指认父亲留下的暗号。","潮医乙发现伤痕与官方记录不符,巡海封锁让局势升级。","船魂戊带玩家接近旧货舱,必须在追捕前取走关键证物。"],"actNPCNames":["船魂戊","潮医乙","船魂戊"],"connectedLandmarkNames":["旧灯塔"],"entryHook":"旧船骨里传出父亲留下的暗号。"}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"playableNpcs":[{"name":"岑灯","backstory":"被停职的守灯人返乡后发现父亲沉船案被改写。","personality":"克制执拗","motivation":"查清父亲沉船真相","combatStyle":"借灯火与海图周旋"}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"playableNpcs":[{"name":"岑灯","backstoryReveal":{"publicSummary":"返乡守灯人的旧案羁绊。","chapters":[{"affinityRequired":15,"title":"返乡","summary":"回到旧灯塔。"},{"affinityRequired":30,"title":"旧档","summary":"发现档案错页。"},{"affinityRequired":60,"title":"沉船","summary":"接近沉船湾。"},{"affinityRequired":90,"title":"真相","summary":"直面议会遮掩。"}]},"skills":[{"name":"读灯","summary":"辨认灯火暗号","style":"侦查"}],"initialItems":[{"name":"旧海图","category":"道具","quantity":1,"rarity":"common","description":"父亲留下的海图。","tags":["线索"]}]}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"storyNpcs":[{"name":"议长甲","backstory":"长期维持群岛议会体面并遮掩沉船旧案。","personality":"冷硬周密","motivation":"压住旧案","combatStyle":"以权令封锁线索"}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"storyNpcs":[{"name":"潮医乙","backstory":"他保存着沉船伤痕和潮汐症状的旧记录。","personality":"谨慎利落","motivation":"保住证据","combatStyle":"以医疗知识支援判断"}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"storyNpcs":[{"name":"雾商丙","backstory":"他长期倒卖雾港航线和假海图。","personality":"圆滑警惕","motivation":"从旧案里脱身","combatStyle":"以情报和交易周旋"}]}"#, + ), + }, + MockHttpResponse { + status_code: 200, + body: llm_response( + r#"{"storyNpcs":[{"name":"船魂戊","backstory":"它被沉船旧案困在潮声和船骨之间。","personality":"激烈执拗","motivation":"让真相重新浮上海面","combatStyle":"借潮声与残影指路"}]}"#, + ), + }, + MockHttpResponse { + status_code: 504, + body: r#"{"error":{"message":"story dossier timeout"}}"#.to_string(), + }, + MockHttpResponse { + status_code: 504, + body: r#"{"error":{"message":"story dossier timeout"}}"#.to_string(), + }, + ], + ); + let llm_client = build_test_llm_client(server_url); + let session = build_test_session(); + + let result = generate_custom_world_foundation_draft(&llm_client, &session, false, |_| {}) + .await + .expect("dossier fallback should keep draft generation alive"); + let draft_profile = serde_json::from_str::(&result.draft_profile_json) + .expect("draft profile should parse"); + let first_story = draft_profile + .get("storyNpcs") + .and_then(JsonValue::as_array) + .and_then(|entries| entries.first()) + .expect("first story role should exist"); + + assert_eq!(first_story.get("name"), Some(&json!("议长甲"))); + assert_eq!( + first_story + .get("backstoryReveal") + .and_then(|value| value.get("chapters")) + .and_then(JsonValue::as_array) + .map(Vec::len), + Some(4) + ); + assert_eq!( + first_story + .get("skills") + .and_then(JsonValue::as_array) + .map(Vec::len), + Some(3) + ); + assert_eq!( + first_story + .get("initialItems") + .and_then(JsonValue::as_array) + .map(Vec::len), + Some(3) + ); + let request_text = request_capture + .lock() + .expect("request capture should lock") + .join("\n---request---\n"); + assert!(request_text.contains("请为下面这一批场景角色补全养成档案")); + } + #[test] fn generated_scene_batch_first_entry_becomes_opening_camp() { let fallback_camp = json!({ diff --git a/server-rs/crates/api-server/src/main.rs b/server-rs/crates/api-server/src/main.rs index 538d14cf..84a86ed1 100644 --- a/server-rs/crates/api-server/src/main.rs +++ b/server-rs/crates/api-server/src/main.rs @@ -40,6 +40,7 @@ mod login_options; mod logout; mod logout_all; mod match3d; +mod openai_image_generation; mod password_entry; mod password_management; mod phone_auth; diff --git a/server-rs/crates/api-server/src/openai_image_generation.rs b/server-rs/crates/api-server/src/openai_image_generation.rs new file mode 100644 index 00000000..b3322fea --- /dev/null +++ b/server-rs/crates/api-server/src/openai_image_generation.rs @@ -0,0 +1,632 @@ +use std::time::{Duration, Instant}; + +use axum::http::StatusCode; +use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD}; +use reqwest::header; +use serde_json::{Map, Value, json}; +use tokio::time::sleep; + +use crate::{http_error::AppError, state::AppState}; + +pub(crate) const GPT_IMAGE_2_MODEL: &str = "gpt-image-2"; + +#[derive(Clone, Debug)] +pub(crate) struct OpenAiImageSettings { + pub base_url: String, + pub api_key: String, + pub request_timeout_ms: u64, +} + +#[derive(Clone, Debug)] +pub(crate) struct OpenAiGeneratedImages { + pub task_id: String, + pub actual_prompt: Option, + pub images: Vec, +} + +#[derive(Clone, Debug)] +pub(crate) struct DownloadedOpenAiImage { + pub bytes: Vec, + pub mime_type: String, + pub extension: String, +} + +// 中文注释:RPG 图片资产与拼图一样走 APIMart 的 OpenAI 兼容图片入口,避免把密钥或供应商协议暴露到前端。 +pub(crate) fn require_openai_image_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(OpenAiImageSettings { + base_url: base_url.to_string(), + api_key: api_key.to_string(), + request_timeout_ms: state.config.apimart_image_request_timeout_ms.max(1), + }) +} + +pub(crate) fn build_openai_image_http_client( + settings: &OpenAiImageSettings, +) -> Result { + reqwest::Client::builder() + .timeout(Duration::from_millis(settings.request_timeout_ms)) + .build() + .map_err(|error| { + AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({ + "provider": "apimart", + "message": format!("构造 APIMart 图片生成 HTTP 客户端失败:{error}"), + })) + }) +} + +pub(crate) async fn create_openai_image_generation( + http_client: &reqwest::Client, + settings: &OpenAiImageSettings, + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[String], + failure_context: &str, +) -> Result { + let request_body = build_openai_image_request_body( + prompt, + negative_prompt, + size, + candidate_count, + reference_images, + ); + let response = http_client + .post(format!("{}/images/generations", settings.base_url)) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .header(header::CONTENT_TYPE, "application/json") + .json(&request_body) + .send() + .await + .map_err(|error| { + map_openai_image_request_error(format!( + "{failure_context}:创建图片生成任务失败:{error}" + )) + })?; + let response_status = response.status(); + let response_text = response.text().await.map_err(|error| { + map_openai_image_request_error(format!("{failure_context}:读取图片生成响应失败:{error}")) + })?; + if !response_status.is_success() { + return Err(map_openai_image_upstream_error( + response_status.as_u16(), + response_text.as_str(), + failure_context, + )); + } + + let response_json = parse_json_payload(response_text.as_str(), failure_context)?; + let image_urls = extract_image_urls(&response_json.payload); + if !image_urls.is_empty() { + return download_images_from_urls( + http_client, + format!("apimart-{}", current_utc_micros()), + image_urls, + candidate_count, + ) + .await; + } + let b64_images = extract_b64_images(&response_json.payload); + if !b64_images.is_empty() { + return Ok(images_from_base64( + format!("apimart-{}", current_utc_micros()), + b64_images, + candidate_count, + )); + } + + let task_id = extract_task_id(&response_json.payload).ok_or_else(|| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": format!("{failure_context}:上游未返回 task_id 或图片"), + })) + })?; + + wait_openai_generated_images( + http_client, + settings, + task_id.as_str(), + candidate_count, + failure_context, + ) + .await +} + +pub(crate) fn build_openai_image_request_body( + prompt: &str, + negative_prompt: Option<&str>, + size: &str, + candidate_count: u32, + reference_images: &[String], +) -> Value { + let mut body = Map::from_iter([ + ( + "model".to_string(), + Value::String(GPT_IMAGE_2_MODEL.to_string()), + ), + ( + "prompt".to_string(), + Value::String(build_prompt_with_negative(prompt, negative_prompt)), + ), + ("n".to_string(), json!(candidate_count.clamp(1, 4))), + ( + "size".to_string(), + Value::String(normalize_image_size(size)), + ), + ]); + + if !reference_images.is_empty() { + body.insert("image_urls".to_string(), json!(reference_images)); + } + + Value::Object(body) +} + +fn build_prompt_with_negative(prompt: &str, negative_prompt: Option<&str>) -> String { + let prompt = prompt.trim(); + let Some(negative_prompt) = negative_prompt + .map(str::trim) + .filter(|value| !value.is_empty()) + else { + return prompt.to_string(); + }; + + format!("{prompt}\n避免:{negative_prompt}") +} + +fn normalize_image_size(size: &str) -> String { + match size.trim() { + "1024*1024" | "1024x1024" | "1:1" => "1:1", + "1280*720" | "1280x720" | "1600*900" | "1600x900" | "16:9" => "16:9", + value if !value.is_empty() => value, + _ => "1:1", + } + .to_string() +} + +async fn wait_openai_generated_images( + http_client: &reqwest::Client, + settings: &OpenAiImageSettings, + task_id: &str, + candidate_count: u32, + failure_context: &str, +) -> Result { + let deadline = Instant::now() + Duration::from_millis(settings.request_timeout_ms); + sleep(Duration::from_secs(10)).await; + + while Instant::now() < deadline { + let poll_response = http_client + .get(format!("{}/tasks/{}", settings.base_url, task_id)) + .header( + header::AUTHORIZATION, + format!("Bearer {}", settings.api_key), + ) + .send() + .await + .map_err(|error| { + map_openai_image_request_error(format!( + "{failure_context}:查询图片生成任务失败:{error}" + )) + })?; + let poll_status = poll_response.status(); + let poll_text = poll_response.text().await.map_err(|error| { + map_openai_image_request_error(format!( + "{failure_context}:读取图片生成任务响应失败:{error}" + )) + })?; + if !poll_status.is_success() { + return Err(map_openai_image_upstream_error( + poll_status.as_u16(), + poll_text.as_str(), + failure_context, + )); + } + + let poll_json = parse_json_payload(poll_text.as_str(), failure_context)?; + let task_status = find_first_string_by_key(&poll_json.payload, "status") + .or_else(|| find_first_string_by_key(&poll_json.payload, "task_status")) + .unwrap_or_default() + .trim() + .to_ascii_lowercase(); + if matches!(task_status.as_str(), "completed" | "succeeded" | "success") { + let image_urls = extract_image_urls(&poll_json.payload); + if image_urls.is_empty() { + let b64_images = extract_b64_images(&poll_json.payload); + if b64_images.is_empty() { + return Err(AppError::from_status(StatusCode::BAD_GATEWAY).with_details( + json!({ + "provider": "apimart", + "message": format!("{failure_context}:任务成功但未返回图片"), + }), + )); + } + + let mut generated = + images_from_base64(task_id.to_string(), b64_images, candidate_count); + generated.actual_prompt = + find_first_string_by_key(&poll_json.payload, "actual_prompt"); + return Ok(generated); + } + + let mut generated = download_images_from_urls( + http_client, + task_id.to_string(), + image_urls, + candidate_count, + ) + .await?; + generated.actual_prompt = find_first_string_by_key(&poll_json.payload, "actual_prompt"); + return Ok(generated); + } + if matches!( + task_status.as_str(), + "failed" | "error" | "canceled" | "cancelled" | "unknown" + ) { + return Err(map_openai_image_upstream_error( + poll_status.as_u16(), + poll_text.as_str(), + failure_context, + )); + } + sleep(Duration::from_secs(3)).await; + } + + Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": format!("{failure_context}:图片生成超时或未返回图片地址"), + })), + ) +} + +async fn download_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, 4) as usize); + for image_url in image_urls + .into_iter() + .take(candidate_count.clamp(1, 4) as usize) + { + images.push(download_remote_image(http_client, image_url.as_str()).await?); + } + Ok(OpenAiGeneratedImages { + task_id, + actual_prompt: None, + images, + }) +} + +fn images_from_base64( + task_id: String, + b64_images: Vec, + candidate_count: u32, +) -> OpenAiGeneratedImages { + let images = b64_images + .into_iter() + .take(candidate_count.clamp(1, 4) as usize) + .filter_map(|raw| decode_generated_image_base64(raw.as_str())) + .collect(); + + OpenAiGeneratedImages { + task_id, + actual_prompt: None, + images, + } +} + +fn decode_generated_image_base64(raw: &str) -> Option { + let bytes = BASE64_STANDARD.decode(raw.trim()).ok()?; + let mime_type = infer_image_mime_type(bytes.as_slice()); + Some(DownloadedOpenAiImage { + extension: mime_to_extension(mime_type.as_str()).to_string(), + mime_type, + bytes, + }) +} + +pub(crate) async fn download_remote_image( + http_client: &reqwest::Client, + image_url: &str, +) -> Result { + let response = + http_client.get(image_url).send().await.map_err(|error| { + map_openai_image_request_error(format!("下载生成图片失败:{error}")) + })?; + let status = response.status(); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or("image/jpeg") + .to_string(); + let body = response.bytes().await.map_err(|error| { + map_openai_image_request_error(format!("读取生成图片内容失败:{error}")) + })?; + if !status.is_success() { + return Err( + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": "下载生成图片失败", + "status": status.as_u16(), + })), + ); + } + + let normalized_mime_type = normalize_downloaded_image_mime_type(content_type.as_str()); + Ok(DownloadedOpenAiImage { + extension: mime_to_extension(normalized_mime_type.as_str()).to_string(), + mime_type: normalized_mime_type, + bytes: body.to_vec(), + }) +} + +fn parse_json_payload( + raw_text: &str, + failure_context: &str, +) -> Result { + serde_json::from_str::(raw_text) + .map(|payload| ParsedJsonPayload { payload }) + .map_err(|error| { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": format!("{failure_context}:解析响应失败:{error}"), + "rawExcerpt": truncate_raw(raw_text), + })) + }) +} + +fn map_openai_image_request_error(message: String) -> AppError { + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": message, + })) +} + +fn map_openai_image_upstream_error( + upstream_status: u16, + raw_text: &str, + failure_context: &str, +) -> AppError { + let message = parse_api_error_message(raw_text, failure_context); + tracing::warn!( + provider = "apimart", + upstream_status, + raw_excerpt = %truncate_raw(raw_text), + message, + "APIMart 图片生成上游错误" + ); + AppError::from_status(StatusCode::BAD_GATEWAY).with_details(json!({ + "provider": "apimart", + "message": message, + "upstreamStatus": upstream_status, + "rawExcerpt": truncate_raw(raw_text), + })) +} + +fn parse_api_error_message(raw_text: &str, fallback_message: &str) -> String { + if raw_text.trim().is_empty() { + return fallback_message.to_string(); + } + + if let Ok(parsed) = serde_json::from_str::(raw_text) { + for pointer in [ + "/error/message", + "/message", + "/output/message", + "/data/message", + ] { + if let Some(message) = parsed + .pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return message.to_string(); + } + } + for pointer in ["/error/code", "/code", "/output/code", "/data/code"] { + if let Some(code) = parsed + .pointer(pointer) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + return format!("{fallback_message}({code})"); + } + } + } + + raw_text.trim().to_string() +} + +fn collect_strings_by_key(value: &Value, target_key: &str, results: &mut Vec) { + match value { + Value::Array(entries) => { + for entry in entries { + collect_strings_by_key(entry, target_key, results); + } + } + Value::Object(object) => { + for (key, nested_value) in object { + if key == target_key { + match nested_value { + Value::String(text) => { + let text = text.trim(); + if !text.is_empty() { + results.push(text.to_string()); + continue; + } + } + Value::Array(entries) => { + for entry in entries { + if let Some(text) = entry + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + results.push(text.to_string()); + } + } + } + _ => {} + } + } + collect_strings_by_key(nested_value, target_key, results); + } + } + _ => {} + } +} + +fn find_first_string_by_key(value: &Value, target_key: &str) -> Option { + let mut results = Vec::new(); + collect_strings_by_key(value, target_key, &mut results); + results.into_iter().next() +} + +fn extract_task_id(payload: &Value) -> Option { + find_first_string_by_key(payload, "task_id") + .or_else(|| find_first_string_by_key(payload, "taskId")) + .or_else(|| find_first_string_by_key(payload, "id")) +} + +fn extract_image_urls(payload: &Value) -> Vec { + let mut urls = Vec::new(); + collect_strings_by_key(payload, "url", &mut urls); + collect_strings_by_key(payload, "image", &mut urls); + collect_strings_by_key(payload, "image_url", &mut urls); + let mut deduped = Vec::new(); + for url in urls { + if (url.starts_with("http://") || url.starts_with("https://")) && !deduped.contains(&url) { + deduped.push(url); + } + } + deduped +} + +fn extract_b64_images(payload: &Value) -> Vec { + let mut values = Vec::new(); + collect_strings_by_key(payload, "b64_json", &mut values); + values +} + +fn normalize_downloaded_image_mime_type(content_type: &str) -> String { + let mime_type = content_type + .split(';') + .next() + .map(str::trim) + .unwrap_or("image/jpeg"); + match mime_type { + "image/png" | "image/webp" | "image/jpeg" | "image/jpg" | "image/gif" => { + mime_type.to_string() + } + _ => "image/jpeg".to_string(), + } +} + +fn mime_to_extension(mime_type: &str) -> &str { + match mime_type { + "image/png" => "png", + "image/webp" => "webp", + "image/gif" => "gif", + _ => "jpg", + } +} + +fn infer_image_mime_type(bytes: &[u8]) -> String { + if bytes.starts_with(b"\x89PNG\r\n\x1A\n") { + return "image/png".to_string(); + } + if bytes.starts_with(b"\xFF\xD8\xFF") { + return "image/jpeg".to_string(); + } + if bytes.starts_with(b"RIFF") && bytes.get(8..12) == Some(b"WEBP") { + return "image/webp".to_string(); + } + if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") { + return "image/gif".to_string(); + } + "image/png".to_string() +} + +fn truncate_raw(raw_text: &str) -> String { + raw_text.chars().take(800).collect() +} + +fn current_utc_micros() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + let duration = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should be after unix epoch"); + i64::try_from(duration.as_micros()).expect("current unix micros should fit in i64") +} + +struct ParsedJsonPayload { + payload: Value, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gpt_image_2_request_normalizes_legacy_sizes_and_reference_images() { + let body = build_openai_image_request_body( + "雾海神殿", + Some("文字,水印"), + "1280*720", + 2, + &["data:image/png;base64,abcd".to_string()], + ); + + assert_eq!(body["model"], GPT_IMAGE_2_MODEL); + assert_eq!(body["size"], "16:9"); + assert_eq!(body["n"], 2); + assert_eq!(body["image_urls"][0], "data:image/png;base64,abcd"); + assert!(body["prompt"].as_str().unwrap_or_default().contains("避免")); + } + + #[test] + fn b64_json_response_decodes_png_image() { + let images = images_from_base64( + "task-1".to_string(), + vec![BASE64_STANDARD.encode(b"\x89PNG\r\n\x1A\nrest")], + 1, + ); + + assert_eq!(images.images.len(), 1); + assert_eq!(images.images[0].mime_type, "image/png"); + assert_eq!(images.images[0].extension, "png"); + } +} diff --git a/server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs b/server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs index 945581f5..c8ae7a9a 100644 --- a/server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs +++ b/server-rs/crates/api-server/src/prompt/rpg/foundation_draft.rs @@ -153,7 +153,11 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt( [ "请根据下面的世界核心信息,批量生成场景框架名单。".to_string(), if is_opening_batch { - "这一步必须一次性生成开局场景和普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string() + if batch_count == 1 { + "这一步只生成开局场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string() + } else { + "这一步必须一次性生成开局场景和普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string() + } } else { "这一步必须一次性生成普通关键场景的场景骨架、默认生图描述、逐幕背景描述、幕 NPC 分配和相连场景信息。".to_string() }, @@ -162,7 +166,11 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt( build_framework_summary_text(framework, 0), if story_npc_names.is_empty() { "".to_string() } else { format!("可用场景角色名单:{}", story_npc_names.join("、")) }, if is_opening_batch { - "第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string() + if batch_count == 1 { + "本批场景必须是玩家进入世界时所在的开局场景。".to_string() + } else { + "第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string() + } } else { "本批只生成普通关键场景,不要再生成开局场景。".to_string() }, diff --git a/src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts b/src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts index 275210cf..576c6c5e 100644 --- a/src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts +++ b/src/components/rpg-creation-asset-studio/useRoleVisualCandidateWorkflow.ts @@ -24,7 +24,7 @@ export function useRoleVisualCandidateWorkflow() { promptText, referenceImageDataUrls, candidateCount: 1, - imageModel: 'wan2.7-image-pro', + imageModel: 'gpt-image-2', size: '1024*1024', }); };