Compare commits
2 Commits
a18f4db4bb
...
d39ac86c27
| Author | SHA1 | Date | |
|---|---|---|---|
| d39ac86c27 | |||
| 801d1d534a |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -29,3 +29,4 @@ temp*build*/
|
||||
/public/generated-characters
|
||||
/.codex-temp
|
||||
/target/
|
||||
/logs
|
||||
|
||||
@@ -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 关闭。
|
||||
|
||||
@@ -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` 通过。
|
||||
@@ -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` 通过。
|
||||
@@ -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` 通过。
|
||||
@@ -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 对象。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":{"message":"story dossier timeout"}}
|
||||
@@ -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 对象。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"error":{"message":"story dossier timeout"}}
|
||||
@@ -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<DashScopeSettings, AppError> {
|
||||
// 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, AppError> {
|
||||
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<GeneratedCharacterVisuals, AppError> {
|
||||
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(
|
||||
let generated = create_openai_image_generation(
|
||||
http_client,
|
||||
image_url.as_str(),
|
||||
"下载角色主形象候选图失败。",
|
||||
settings,
|
||||
prompt,
|
||||
Some(build_character_visual_negative_prompt().as_str()),
|
||||
size,
|
||||
candidate_count,
|
||||
reference_images,
|
||||
"角色主形象生成失败",
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
.await?;
|
||||
|
||||
return Ok(GeneratedCharacterVisuals {
|
||||
task_id,
|
||||
actual_prompt: find_first_string_by_key(&poll_json.payload, "actual_prompt"),
|
||||
Ok(GeneratedCharacterVisuals {
|
||||
task_id: generated.task_id,
|
||||
actual_prompt: generated.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": "角色主形象任务执行超时,请稍后重试。",
|
||||
})),
|
||||
)
|
||||
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<DownloadedGeneratedImage, AppError> {
|
||||
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<ParsedJsonPayload, AppError> {
|
||||
serde_json::from_str::<Value>(raw_text)
|
||||
.map(|payload| ParsedJsonPayload { payload })
|
||||
.map_err(|error| {
|
||||
fn map_image_request_error(message: String) -> AppError {
|
||||
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::<Value>(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 {
|
||||
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<String>) {
|
||||
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<String> {
|
||||
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<String> {
|
||||
find_first_string_by_key(payload, "task_id")
|
||||
}
|
||||
|
||||
fn extract_image_urls(payload: &Value) -> Vec<String> {
|
||||
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<ParsedImageDataUrl> {
|
||||
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<String>,
|
||||
@@ -2032,10 +1739,6 @@ struct DownloadedGeneratedImage {
|
||||
extension: String,
|
||||
}
|
||||
|
||||
struct ParsedJsonPayload {
|
||||
payload: Value,
|
||||
}
|
||||
|
||||
struct ParsedImageDataUrl {
|
||||
mime_type: String,
|
||||
bytes: Vec<u8>,
|
||||
@@ -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]
|
||||
|
||||
@@ -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<u8>,
|
||||
}
|
||||
|
||||
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(
|
||||
let reference_images = reference_image
|
||||
.as_ref()
|
||||
.map(|value| vec![value.clone()])
|
||||
.unwrap_or_default();
|
||||
let generated = create_openai_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(
|
||||
&http_client,
|
||||
generated.image_url.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();
|
||||
|
||||
@@ -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<JsonValue> {
|
||||
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::<Vec<_>>()
|
||||
}),
|
||||
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::<Vec<_>>();
|
||||
|
||||
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::<JsonValue>(&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!({
|
||||
|
||||
@@ -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;
|
||||
|
||||
632
server-rs/crates/api-server/src/openai_image_generation.rs
Normal file
632
server-rs/crates/api-server/src/openai_image_generation.rs
Normal file
@@ -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<String>,
|
||||
pub images: Vec<DownloadedOpenAiImage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DownloadedOpenAiImage {
|
||||
pub bytes: Vec<u8>,
|
||||
pub mime_type: String,
|
||||
pub extension: String,
|
||||
}
|
||||
|
||||
// 中文注释:RPG 图片资产与拼图一样走 APIMart 的 OpenAI 兼容图片入口,避免把密钥或供应商协议暴露到前端。
|
||||
pub(crate) fn require_openai_image_settings(
|
||||
state: &AppState,
|
||||
) -> Result<OpenAiImageSettings, AppError> {
|
||||
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, AppError> {
|
||||
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<OpenAiGeneratedImages, AppError> {
|
||||
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<OpenAiGeneratedImages, AppError> {
|
||||
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<String>,
|
||||
candidate_count: u32,
|
||||
) -> Result<OpenAiGeneratedImages, AppError> {
|
||||
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<String>,
|
||||
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<DownloadedOpenAiImage> {
|
||||
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<DownloadedOpenAiImage, AppError> {
|
||||
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<ParsedJsonPayload, AppError> {
|
||||
serde_json::from_str::<Value>(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::<Value>(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<String>) {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,11 @@ pub(crate) fn build_custom_world_landmark_seed_batch_prompt(
|
||||
[
|
||||
"请根据下面的世界核心信息,批量生成场景框架名单。".to_string(),
|
||||
if is_opening_batch {
|
||||
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 {
|
||||
if batch_count == 1 {
|
||||
"本批场景必须是玩家进入世界时所在的开局场景。".to_string()
|
||||
} else {
|
||||
"第一条场景必须是玩家进入世界时所在的开局场景,后续条目才是普通关键场景。".to_string()
|
||||
}
|
||||
} else {
|
||||
"本批只生成普通关键场景,不要再生成开局场景。".to_string()
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ export function useRoleVisualCandidateWorkflow() {
|
||||
promptText,
|
||||
referenceImageDataUrls,
|
||||
candidateCount: 1,
|
||||
imageModel: 'wan2.7-image-pro',
|
||||
imageModel: 'gpt-image-2',
|
||||
size: '1024*1024',
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user