From 571cb00fe4027028e4944dbc987c655bc53c0729 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 24 Sep 2026 15:40:30 +0000 Subject: [PATCH 1/2] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20external=20v1=20?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E5=9C=BA=E6=99=AF=E7=94=9F=E6=88=90=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E5=B9=B6=E8=BF=81=E7=A7=BB=20AGC=20=E7=BE=8E=E6=9C=AF?= =?UTF-8?q?=E5=8C=85=E8=83=8C=E6=99=AF=E9=98=B6=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - api-server 新增 POST /api/external/v1/editor/scenes/generations,复用 editor:image-generate scope、幂等键和站内场景生图队列 - editor_project.rs 抽取站内与外部共用的场景生图 payload 构造函数,站内 handler 改为调用共享函数 - 同步更新 external v1 OpenAPI 契约与 MCP 派生排除标记,登记 docs/README.md 索引 - AGC 客户端美术包背景阶段改走新场景路由,stylePreset 固定 custom + customStyle 保留现有风格文案 - external_generation_state 快照与 recovery_scan 账本白名单支持新场景路由 - direct_runtime 背景资源识别同时兼容新场景路由与旧通用路由,新增旧路由回放测试 - 更新 genarrative-external-editor-api skill 参考文档 - 新增主规范、里程碑规范与实施计划三份 SDD 文档 --- .../references/api-operations.md | 6 +- .../references/requests-and-outputs.md | 2 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 97 ++++++++++----- .../src/agent/generation/canvas_generation.rs | 38 ++++-- .../generation/external_generation_state.rs | 24 ++++ .../src/agent/runtime_driver/recovery_scan.rs | 1 + docs/README.md | 1 + .../genarrative-external-v1.openapi.json | 117 +++++++++++++++++- ...®¡划】ExternalV1游戏场景生成路由-2026-09-24.md | 42 +++++++ ...‹碑】ExternalV1游戏场景生成路由-2026-09-24.md | 45 +++++++ ...–¹案】ExternalV1游戏场景生成路由-2026-09-24.md | 75 +++++++++++ .../crates/api-server/src/editor_project.rs | 67 ++++++++-- .../api-server/src/external_editor_api.rs | 110 ++++++++++++++-- .../api-server/src/modules/external_api.rs | 17 ++- 14 files changed, 578 insertions(+), 64 deletions(-) create mode 100644 docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md create mode 100644 docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md create mode 100644 docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md diff --git a/.codex/skills/genarrative-external-editor-api/references/api-operations.md b/.codex/skills/genarrative-external-editor-api/references/api-operations.md index 1f9996968..0990076dd 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-operations.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-operations.md @@ -30,6 +30,7 @@ The hosted MCP offers the following tools. Choose the task tool when its action | `PATCH /api/external/v1/editor/assets/{assetId}` | `organize_asset_library` (`update_asset`) | `update_editor_asset` | | `DELETE /api/external/v1/editor/assets/{assetId}` | `delete_resources` (`delete_asset`) | `delete_editor_asset` | | `POST /api/external/v1/editor/images/generations` | `generate_image`, `modify_image` (`variation`, fixed `kind="quick-edit"`) | `generate_external_editor_image` | +| `POST /api/external/v1/editor/scenes/generations` | structured game-scene generation (no hosted MCP tool yet) | `generate_external_editor_scene` | | `POST /api/external/v1/editor/images/edits` | `modify_image` (`edit`) | `edit_external_editor_image` | | `POST /api/external/v1/editor/images/background-removals` | `modify_image` (`remove_background`) | `remove_external_editor_image_background` | | `POST /api/external/v1/editor/icon-spritesheets/generations` | `generate_icon_spritesheet` | `generate_external_editor_icon_spritesheet` | @@ -89,6 +90,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP | Capability | POST path | Required body fields | Common optional body fields | | ------------------- | ------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Image generation | `/api/external/v1/editor/images/generations` | `prompt` | `kind`, `style`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | +| Game scene | `/api/external/v1/editor/scenes/generations` | `sceneContent`, `stylePreset` | `customStyle` (required when `stylePreset="custom"`), `model`, `aspectRatio`, `imageSize`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Image edit/redraw | `/api/external/v1/editor/images/edits` | `prompt`, `sourceReferenceId` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `targetLayerId`, `canvasCompletion` | | Background removal | `/api/external/v1/editor/images/background-removals` | `sourceImageSrc` | `projectId`, `sourceResourceId`, `targetLayerId`, static-image `assetKind`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Icon spritesheet | `/api/external/v1/editor/icon-spritesheets/generations` | `referenceId`, `iconDescriptions`, `sliceMode` | `gridX`, `gridY`, `sliceCount`, `style`, `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | @@ -98,7 +100,7 @@ Every generation row requires a stable `Idempotency-Key` header and returns HTTP | Sound effect | `/api/external/v1/editor/audios/sound-effects/generations` | `prompt` | `model`, `duration`, `loop`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Background music | `/api/external/v1/editor/audios/background-music/generations` | `gptDescriptionPrompt`, `makeInstrumental` | `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | -Poll all nine through: +Poll all ten through: ```text GET /api/external/v1/generations/{operationId} @@ -140,7 +142,7 @@ The icon-spritesheet primary `referenceId` is intentionally stricter than ordina Use OpenAPI as the final authority; these common values are a routing aid: - Image `kind`: `spec`, `character`, `quick-edit`, `ui-design`, `publication-material`; ordinary image generation may omit it. -- External v1 currently has no structured game-scene generation operation. Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. +- Game scenes must use the dedicated structured route `POST /api/external/v1/editor/scenes/generations` (`sceneContent` + `stylePreset`; `customStyle` required for `custom`). Do not send `kind: "scene"` or `assetKind: "scene"` through generic image generation; the server rejects both before queueing. The scene route assembles the full provider prompt server-side and never accepts a caller-assembled `prompt`. - Image `model`: `gpt-image-2`, `gemini-3.1-flash-image-preview`, `nanobanana2`, `nano-banana`. - Image `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`. - Image `imageSize`: `0.5K`, `1K`, `2K`. diff --git a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md index ac208fc27..10e41f319 100644 --- a/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md +++ b/.codex/skills/genarrative-external-editor-api/references/requests-and-outputs.md @@ -148,7 +148,7 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep ## Art Spec and Image Request -Generic External v1 image generation does not expose the main-site structured game-scene contract. `kind: "scene"` and `assetKind: "scene"` are both invalid and return HTTP `400` before any generation job is queued. Do not replace the structured scene fields and server-owned prompt assembly with a generic image prompt. +Game scenes have a dedicated structured route: `POST /api/external/v1/editor/scenes/generations` with `sceneContent` and `stylePreset` (`customStyle` required when `stylePreset` is `custom`). The server assembles the full provider prompt; a caller-assembled `prompt` is not accepted. `kind: "scene"` and `assetKind: "scene"` remain invalid on generic image generation and return HTTP `400` before any generation job is queued. When maintaining a reusable art spec, carry it in `generationInputs.artSpec` and reflect important constraints in the prompt. This is an example with both canvas and library destinations, not a requirement for every generation: diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 0b816ffee..9fa582804 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -2626,6 +2626,30 @@ fn direct_taonier_reference_matches_local_source( .unwrap_or(false) } +fn direct_taonier_background_asset_identity( + root: &Path, + expected_reference_source: Option<&DirectTaonierArtAssetIdentity>, +) -> Option { + direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + GameCreationAppAssetKind::Scene, + "/api/external/v1/editor/scenes/generations", + "scene", + expected_reference_source, + ) + .or_else(|| { + direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + GameCreationAppAssetKind::Scene, + "/api/external/v1/editor/images/generations", + "spec", + expected_reference_source, + ) + }) +} + fn direct_taonier_art_base_is_valid(root: &Path) -> bool { let Some(art_spec) = direct_taonier_art_asset_identity( root, @@ -2637,14 +2661,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool { ) else { return false; }; - let Some(background) = direct_taonier_art_asset_identity( - root, - DIRECT_CODEX_BACKGROUND_ASSET_PATH, - GameCreationAppAssetKind::Scene, - "/api/external/v1/editor/images/generations", - "spec", - Some(&art_spec), - ) else { + let Some(background) = direct_taonier_background_asset_identity(root, Some(&art_spec)) else { return false; }; let _ = (background, art_spec); @@ -3625,15 +3642,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( }) .flatten(); let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| { - direct_taonier_art_asset_identity( - root, - DIRECT_CODEX_BACKGROUND_ASSET_PATH, - GameCreationAppAssetKind::Scene, - "/api/external/v1/editor/images/generations", - "spec", - Some(art_spec), - ) - .is_some() + direct_taonier_background_asset_identity(root, Some(art_spec)).is_some() }); let art_spec = match existing_art_spec { Some(identity) => identity, @@ -3709,15 +3718,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( } }; if mode.regenerates_existing() - || direct_taonier_art_asset_identity( - root, - DIRECT_CODEX_BACKGROUND_ASSET_PATH, - GameCreationAppAssetKind::Scene, - "/api/external/v1/editor/images/generations", - "spec", - Some(&art_spec), - ) - .is_none() + || direct_taonier_background_asset_identity(root, Some(&art_spec)).is_none() { emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图"); let outcome = match generate_direct_taonier_art_asset_at( @@ -8421,8 +8422,8 @@ mod tests { vec!["taonier-resource-icon-spec".to_string()], ), GameCreationAppAssetKind::Scene => ( - "/api/external/v1/editor/images/generations", - "spec", + "/api/external/v1/editor/scenes/generations", + "scene", vec!["taonier-resource-icon-spec".to_string()], ), _ => ( @@ -8806,6 +8807,46 @@ mod tests { ); } + #[test] + fn legacy_route_background_registration_still_counts_as_valid_base() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "legacy-background-route", "旧路由背景登记") + .expect("init project"); + std::fs::create_dir_all(root.path().join("assets")).expect("assets dir"); + register_direct_taonier_art_asset_fixture( + root.path(), + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + GameCreationAppAssetKind::IconSpec, + ); + std::fs::write( + root.path().join(DIRECT_CODEX_BACKGROUND_ASSET_PATH), + tiny_visible_png(), + ) + .expect("background bytes"); + register_local_asset_at( + root.path(), + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + GameCreationAppAssetKind::Scene, + "image/png", + "platform-art", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("taonier-project".to_string()), + resource_id: Some("taonier-resource-game-background".to_string()), + asset_object_id: Some("taonier-object-game-background".to_string()), + task_id: Some("taonier-task-game-background".to_string()), + prompt: None, + model: Some("gpt-image-2".to_string()), + generation_route: Some("/api/external/v1/editor/images/generations".to_string()), + generation_kind: Some("spec".to_string()), + reference_resource_ids: vec!["taonier-resource-icon-spec".to_string()], + }, + ) + .expect("register legacy-route background"); + + assert!(direct_taonier_art_base_is_valid(root.path())); + } + fn tiny_visible_png() -> Vec { let mut bytes = Vec::new(); let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs index 30b035d8a..867de221d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/canvas_generation.rs @@ -2835,14 +2835,15 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d && art_spec_asset_type == Some("icon-spec") } GameCreationAppAssetKind::Scene => { - snapshot.endpoint == "/api/external/v1/editor/images/generations" - && snapshot.generation_kind == "spec" + snapshot.endpoint == "/api/external/v1/editor/scenes/generations" + && snapshot.generation_kind == "scene" && platform_art_runtime_references_match_request_contract( &snapshot.reference_resource_ids, GameCreationAppAssetKind::Scene, ) - && request_asset_kind.as_deref() == Some("game-background") - && art_spec_asset_type == Some("background") + && json_string_field(&request_body, "sceneContent") + .is_some_and(|value| !value.trim().is_empty()) + && json_string_field(&request_body, "stylePreset").as_deref() == Some("custom") } GameCreationAppAssetKind::IconSpritesheet => { snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" @@ -3248,6 +3249,7 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at GameCreationAppAssetKind::Image => "image", GameCreationAppAssetKind::Character => "character", GameCreationAppAssetKind::PublicationMaterial => "publication-material", + GameCreationAppAssetKind::Scene => "scene", _ => "spec", }; let is_canonical_art_spritesheet = @@ -3291,6 +3293,25 @@ pub(in crate::agent) async fn request_platform_art_asset_with_runtime_options_at }, }), ) + } else if options.asset_kind == GameCreationAppAssetKind::Scene { + ( + "/api/external/v1/editor/scenes/generations", + serde_json::json!({ + "sceneContent": generation_prompt, + "stylePreset": "custom", + "customStyle": prompt_text!("media.scene_style"), + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "referenceImageSrcs": references.ordered.clone(), + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + ) } else { ( "/api/external/v1/editor/images/generations", @@ -13272,14 +13293,13 @@ mod canvas_generation_tests { let background = write_retained_stage( root, "run-background-canonical-and-user-references", - "/api/external/v1/editor/images/generations", + "/api/external/v1/editor/scenes/generations", serde_json::json!({ - "prompt": "保留账本参考合同", - "kind": "spec", - "assetKind": "game-background", + "sceneContent": "保留账本参考合同", + "stylePreset": "custom", + "customStyle": "原创横屏 Web 游戏场景背景", "projectId": "test-canvas-project", "assetFolderId": "test-asset-folder", - "generationInputs": { "artSpec": { "assetType": "background" } }, "referenceImageSrcs": ["resource-icon-spec", "user-reference-1"], }), ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs index f2332c009..cf6e4ecfc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation/external_generation_state.rs @@ -735,6 +735,30 @@ pub(super) fn platform_art_generation_runtime_request_snapshot( .collect::, _>>()?; (generation_kind, reference_resource_ids) } + "/api/external/v1/editor/scenes/generations" => { + if json_string_field(&request_body, "sceneContent") + .is_none_or(|value| value.trim().is_empty()) + { + return Err("External Editor 场景生成账本请求缺少 sceneContent".to_string()); + } + let reference_resource_ids = request_body + .get("referenceImageSrcs") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| { + "External Editor 场景生成账本请求缺少 referenceImageSrcs".to_string() + })? + .iter() + .map(|value| { + value + .as_str() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| "External Editor 场景生成账本引用资源 ID 无效".to_string()) + }) + .collect::, _>>()?; + ("scene".to_string(), reference_resource_ids) + } "/api/external/v1/editor/icon-spritesheets/generations" => { let reference_resource_id = json_string_field(&request_body, "referenceId") .or_else(|| json_string_field(&request_body, "referenceImageSrc")) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs index cc9152047..f5e07ddf2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/recovery_scan.rs @@ -555,6 +555,7 @@ fn platform_art_generation_request_snapshot_is_valid(payload: &serde_json::Value endpoint, Some( "/api/external/v1/editor/images/generations" + | "/api/external/v1/editor/scenes/generations" | "/api/external/v1/editor/icon-spritesheets/generations" ) ); diff --git a/docs/README.md b/docs/README.md index 378eab802..6dbdc9e67 100644 --- a/docs/README.md +++ b/docs/README.md @@ -23,6 +23,7 @@ - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。 - [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。 +- [External v1 游戏场景生成路由](./technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md):external v1 结构化场景生成专用路由与 AGC 美术包背景阶段迁移合同。 ## AI 游戏创作与 Agent Runtime diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index c120926f0..927babb33 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -1052,7 +1052,60 @@ "$ref": "#/components/responses/UpstreamError" } }, - "description": "支持普通生图、规范图、角色图、快速编辑参考图、UI 设计图和宣发素材生成。kind 可取 spec、character、quick-edit、ui-design、publication-material。" + "description": "支持普通生图、规范图、角色图、快速编辑参考图、UI 设计图和宣发素材生成。kind 可取 spec、character、quick-edit、ui-design、publication-material。游戏场景不接受本接口的 kind/assetKind = scene,必须使用 /api/external/v1/editor/scenes/generations 提交结构化场景意图。" + } + }, + "/api/external/v1/editor/scenes/generations": { + "post": { + "x-mcp-excluded": true, + "tags": ["Editor Images"], + "operationId": "generateExternalEditorScene", + "summary": "生成编辑器游戏场景(结构化场景意图)", + "description": "只接受结构化场景意图:sceneContent + stylePreset(custom 时必须提供 customStyle),完整 Provider Prompt 由服务端组装,不接受调用方提交的完整 prompt。入队后按 kind/assetKind = scene 持久化,产物保存 scene.generate V2 配方;队列、计费、资源入库与画布写回与站内场景路由一致。", + "security": [ + { + "ExternalApiKey": [] + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/IdempotencyKey" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditorSceneGenerationRequest" + } + } + } + }, + "responses": { + "202": { + "description": "生成任务已持久化入队", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalEditorGenerationSubmissionResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "502": { + "$ref": "#/components/responses/UpstreamError" + } + } } }, "/api/external/v1/editor/images/edits": { @@ -2965,6 +3018,68 @@ } } }, + "EditorSceneGenerationRequest": { + "type": "object", + "required": ["sceneContent", "stylePreset"], + "properties": { + "sceneContent": { + "type": "string", + "minLength": 1, + "description": "画面内容(结构化场景意图主体)。纯空白在入队前返回 400。" + }, + "stylePreset": { + "type": "string", + "enum": ["anime", "watercolor", "flat", "stop-motion", "custom"], + "description": "视觉风格预设。custom 时必须同时提供非空 customStyle,否则返回 400。" + }, + "customStyle": { + "type": ["string", "null"], + "description": "自定义画风描述,仅 stylePreset = custom 时使用。" + }, + "model": { + "type": ["string", "null"], + "description": "图片模型,省略时使用服务端默认场景模型。" + }, + "aspectRatio": { + "type": ["string", "null"], + "default": "16:9" + }, + "imageSize": { + "type": ["string", "null"], + "default": "1K" + }, + "referenceImageSrcs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "可选参考图,沿用普通图片生成的参考图口径。" + }, + "projectId": { + "type": ["string", "null"] + }, + "generationInputs": { + "$ref": "#/components/schemas/JsonValue" + }, + "assetFolderId": { + "type": ["string", "null"] + }, + "assetLabel": { + "type": ["string", "null"], + "description": "省略或纯空白时统一使用「游戏场景」。" + }, + "canvasCompletion": { + "anyOf": [ + { + "$ref": "#/components/schemas/EditorCanvasGenerationCompletion" + }, + { + "type": "null" + } + ] + } + } + }, "EditorImageGenerationRequest": { "type": "object", "required": ["prompt"], diff --git a/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md new file mode 100644 index 000000000..78a248478 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md @@ -0,0 +1,42 @@ +# 【实施计划】External v1 游戏场景生成路由 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md` | +| Status | ready | +| Owner | Agent | + +## 修改边界 + +- 允许修改: + - `server-rs/crates/api-server/src/editor_project.rs`(抽取场景组装/入队共享逻辑) + - `server-rs/crates/api-server/src/external_editor_api.rs`(新 handler) + - `server-rs/crates/api-server/src/modules/external_api.rs`(路由表与契约矩阵) + - `docs/openapi/genarrative-external-v1.openapi.json` 与对应契约测试 + - AGC `src-tauri/src/agent/generation/canvas_generation.rs`、`src/agent/direct_runtime/mod.rs`、`src/agent/generation/external_generation_state.rs`、`src/agent/runtime_driver/recovery_scan.rs`、`src/project/manifest.rs`(背景阶段路由与身份口径) + - `.codex/skills/genarrative-external-editor-api/references/`(外部 API 说明) +- 明确不修改:站内场景路由行为、通用图片入口校验、SpacetimeDB schema、计费、动画/抠图链路(issue #495)。 + +## 实现顺序 + +1. 后端:把 `generate_editor_scene` 的「payload → `EditorImageGenerationRequest`」段抽成 `pub(crate)` 共享函数(含 options 标准化、Prompt 组装、generationInputs 重建、assetLabel 规范化),站内 handler 改为调用它。 +2. 后端:`external_editor_api.rs` 新增 `generate_external_editor_scene`:`require_scope(SCOPE_EDITOR_IMAGE_GENERATE)` + `require_idempotency_key` + 共享函数 + `enqueue_editor_image_generation_for_owner` + `external_generation_accepted_response`。 +3. 后端:`modules/external_api.rs` 路由表与 `PROTECTED_ROUTES` 各加 `/api/external/v1/editor/scenes/generations`(POST)。 +4. 后端:补单测/契约测试(鉴权、参数 400、幂等重放、与站内同 Prompt);同步 OpenAPI JSON 与契约测试。 +5. 客户端:`canvas_generation.rs` 请求构造为 Scene 拆专属分支——新路由 + `EditorSceneGenerateRequest` 形状 body(`sceneContent` = 现有场景 prompt 文本,`stylePreset = "custom"`,`customStyle` = 现有风格描述文案,其余字段沿用);修 Scene 保留账本匹配器(新路由 + `sceneContent` 口径),统一 `game-background`/`scene` 不一致。 +6. 客户端:身份/对账常量迁移——`direct_runtime/mod.rs` 背景身份三元组路由与 kind、`manifest.rs` 期望路由、`recovery_scan.rs`、`external_generation_state.rs`;保持旧路由登记可读。 +7. 文档:外部 API Skill references 补新路由;运行编码与 diff 检查。 + +## 验证命令 + +1. `cargo test -p api-server`(重点:external_editor_api、editor_project 场景相关) +2. `cargo test -p shared-contracts` +3. AGC:`cargo test` 于 `apps/ai-game-creator-shell/src-tauri`(定向:canvas_generation、direct_runtime、recovery_scan、manifest) +4. `npm run check:encoding`、`git diff --check`、`npm run check:doc-index` +5. smoke:`npm run dev:api-server` 后 `curl /healthz`,再用测试 Key POST 新路由验证受理与重放。 + +## 风险与回滚点 + +- 共享逻辑抽取改变站内路由行为:以「相同输入 Prompt 逐字一致」单测为门禁;回滚点为抽取提交本身。 +- 旧路由登记的身份记录在新常量下读不到:保留旧路由 + `spec` 的读取分支,仅新增写入走新路由;回滚点为常量迁移提交。 +- 幂等重放依赖现有外部生成重放链路(issue #495 问题一不涉及图片生成入口,实测正常);若 smoke 暴露重放异常,停止合并并记录。 diff --git a/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md new file mode 100644 index 000000000..72ce291c3 --- /dev/null +++ b/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md @@ -0,0 +1,45 @@ +# 【里程碑】External v1 游戏场景生成路由 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-24 | +| Parent Spec | `docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md` | + +## 背景与触发 + +平台自 `72f268e08`(游戏场景需求 v1.0)起在通用图片生成入口拒绝 `kind = scene` / `assetKind = scene`,要求场景必须走结构化专用路由;但 external v1 没有场景路由,AGC 客户端陶泥儿美术包的背景阶段仍向 `/api/external/v1/editor/images/generations` 提交 `assetKind = scene`,被平台参数校验拒绝(HTTP 400),新项目美术包无法生成(实测产物 `gameagent-f91a64dd`)。同时发现背景阶段保留账本匹配器期望 `assetKind = "game-background"`,与实际请求线上的 `"scene"` 不一致,中断恢复匹配失效。 + +## 目标 + +1. external v1 新增 `POST /api/external/v1/editor/scenes/generations`,与站内场景路由共享同一场景 Prompt 组装与入队实现,鉴权、幂等、计费、持久化语义与现役外部生成入口一致。 +2. AGC 美术包背景阶段迁移到新路由,以结构化场景意图提交,`reuse-or-create` 与 `regenerate` 均恢复可用,出图风格与尺寸不变。 +3. 背景阶段的路由、manifest 身份登记、保留账本匹配与恢复扫描常量整圈对齐到新路由与实际线上值;历史登记保持可读。 + +## 不在本里程碑内 + +- 不改站内场景路由行为与通用图片入口的 scene 拒绝校验。 +- 不改 SpacetimeDB schema、队列类型、计费档位。 +- 不迁移历史背景登记数据。 +- 不处理 issue #495 的两个既有问题。 + +## 依赖与前置条件 + +- `shared-contracts`:`EditorSceneGenerateRequest` 已存在,直接复用。 +- `api-server`:站内 `generate_editor_scene` 的组装与入队逻辑抽取为共享实现。 +- AGC `src-tauri`:背景阶段请求构造、保留账本与身份常量迁移。 + +## 验收标准 + +- [ ] 新路由契约测试通过:路由矩阵与鉴权、参数 400、同键重放返回原任务、同键不同请求 409。 +- [ ] 外部场景路由与站内路由对相同输入产出相同的后端 Prompt 与入队 payload(共享实现单测对照)。 +- [ ] `docs/openapi/genarrative-external-v1.openapi.json` 与实现一致,相关契约检查通过。 +- [ ] 客户端定向测试:背景阶段请求命中新路由且为结构化字段;保留账本匹配器与实际请求口径一致;`reuse-or-create` 与 `regenerate` 路径回归通过。 +- [ ] 本地真实栈 smoke:`npm run dev:api-server` + 测试 Key 下美术包背景阶段受理成功(Provider 出图按环境可用性记录为已验证或未验证)。 + +## 证据要求 + +- 自动化:`cargo test -p api-server`(契约与场景相关单测)、AGC `src-tauri` 定向测试、`npm run check:encoding`、`git diff --check`。 +- 运行时:本地 api-server 受理 smoke。 +- 边界:鉴权 401/403、幂等重放、参数失败关闭。 diff --git a/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md new file mode 100644 index 000000000..3a229f6f5 --- /dev/null +++ b/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md @@ -0,0 +1,75 @@ +# 【技术方案】External v1 游戏场景生成路由 + +更新时间:`2026-09-24` + +## 目标 + +为 `/api/external/v1` 补齐游戏场景生成的结构化专用路由,使 AGC 客户端(陶泥儿美术包背景阶段)在平台收紧 `kind = scene` / `assetKind = scene` 边界校验后仍有合规的场景生成入口: + +```text +AGC 美术包背景阶段(结构化场景意图) +-> POST /api/external/v1/editor/scenes/generations +-> 后端确定性组装场景 Prompt(与站内场景路由同一实现) +-> 现有 editor_image_generation 队列与 Worker +-> 现有计费、幂等、失败、资源持久化与 canvasCompletion +``` + +同时修复 AGC 客户端背景阶段保留账本匹配口径与实际请求不一致的既有隐患。 + +## 非目标 + +- 不改动站内 `/api/editor/scenes/generations` 的请求字段、Prompt 组装结果与计费语义。 +- 不放松通用 `/api/editor/images/generations` 与 `/api/external/v1/editor/images/generations` 对 `kind = scene` / `assetKind = scene` 的拒绝。 +- 不新增场景 Worker、任务表、计费档位或 SpacetimeDB schema。 +- 不改变美术包背景图的出图风格与尺寸(16:9 / 1K)。 +- 不处理 issue #495 的抠图重放 409 与动画 compact 字段问题(独立排期)。 + +## 入口与边界 + +- 系统入口:AGC 客户端 Direct 美术包流程的背景阶段(`reuse-or-create` 与 `regenerate` 均经过)。 +- 涉及模块:`api-server`(external v1 路由与场景 Prompt 组装)、`shared-contracts`(DTO 复用)、AGC `src-tauri`(请求构造、保留账本、身份对账与恢复扫描常量)。 +- 正式状态来源:`external_generation_job` 队列记录与项目 manifest 登记,与现役外部生成入口一致。 + +## 必须成立的行为 + +### 正常路径 + +1. 新路由 `POST /api/external/v1/editor/scenes/generations` 接受与站内场景路由相同的 `EditorSceneGenerateRequest` 字段(`sceneContent`、`stylePreset`、`customStyle?`、`model?`、`aspectRatio?`、`imageSize?`、`referenceImageSrcs?`、`projectId?`、`generationInputs?`、`assetFolderId?`、`assetLabel?`、`canvasCompletion?`),不接受调用方组装后的完整 `prompt`。 +2. 场景 Prompt 由后端经与站内路由完全相同的组装实现生成;两路由只共享这一份组装逻辑。 +3. 鉴权复用现有 `editor:image-generate` scope;与现役外部生成入口一样强制 `Idempotency-Key` 请求头。 +4. 受理响应与现役外部生成入口同形(operationId 异步受理信封),轮询继续走 `/api/external/v1/generations/{operation_id}`。 +5. 入队后 `kind = scene`、`assetKind = scene`,队列类型、Worker、计费与持久化与站内场景路由一致;队列标题与任务摘要口径不变。 +6. AGC 美术包背景阶段以 `stylePreset = custom` + `customStyle` 承载现有风格描述,`sceneContent` 承载 brief 衍生的画面内容,出图风格与比例不因迁移改变。 + +### 失败、重试与幂等 + +1. 缺少 `sceneContent`、非法 `stylePreset`、自定义风格缺少 `customStyle` 等参数错误返回 400,与站内路由同语义。 +2. 缺少或非法幂等键、越权 scope 的拒绝语义与现役外部生成入口一致。 +3. 同一幂等键 + 同一请求重放返回原任务,不新建任务、不重复扣费;同键不同请求返回 409。 +4. Provider 失败、取消与 lease 耗尽沿用现有扣退费语义。 + +### 权限、归属与数据边界 + +1. 资源归属、项目绑定与素材文件夹解析沿用现役外部生成入口的 owner 口径。 +2. 任务摘要只展示 `generationInputs.fields` 的「画面内容」,不把后端完整 Prompt 暴露到任务侧栏。 +3. 场景产物以 `assetKind = scene` 持久化并保存 `scene.generate` V2 配方,与站内产物口径一致。 + +## 契约与迁移 + +- API / DTO / OpenAPI:新增 external v1 场景路由,DTO 复用 `shared-contracts` 的 `EditorSceneGenerateRequest`;同一次变更同步 `docs/openapi/genarrative-external-v1.openapi.json` 与契约测试(路由矩阵、鉴权、参数 400、幂等重放)。 +- SpacetimeDB schema / migration / bindings:不变。 +- 兼容与迁移策略:AGC 客户端背景阶段的路由、manifest 身份登记、保留账本匹配与恢复扫描常量整圈迁移到新路由;历史已登记的背景身份(旧通用路由 + `kind = spec`)保持可读,不做数据迁移。 + +## 验收标准与证据 + +| 条款 | 验收方式 | 证据 | +| ---- | -------- | ---- | +| 新路由受理/参数校验/鉴权/幂等重放 | api-server 契约测试与单测 | 待补 | +| 与站内路由同一 Prompt 组装结果 | 共享实现的单测对照 | 待补 | +| OpenAPI 与实现一致 | 契约测试 + `check:openapi` 类门禁 | 待补 | +| 美术包背景端到端(reuse-or-create / regenerate) | 客户端定向测试 + 本地真实栈 smoke | 待补 | +| 中断恢复:保留账本匹配与身份对账 | 客户端定向测试 | 待补 | + +## 未决问题与决策 + +- `stylePreset` 取舍:已决策——AGC 美术包背景固定 `custom` + `customStyle` 承载现有风格文案,不绑定预设风格(2026-09-24,与用户确认)。 diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index f6ec1d0ed..9a6aad4bf 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2692,13 +2692,9 @@ fn normalize_editor_scene_asset_label(asset_label: Option) -> String { resolve_editor_generated_asset_label(asset_label, "游戏场景") } -pub async fn generate_editor_scene( - State(state): State, - Extension(request_context): Extension, - Extension(authenticated): Extension, - payload: Result, JsonRejection>, -) -> Result, AppError> { - let Json(payload) = parse_editor_generation_json_payload(payload)?; +pub(crate) fn build_editor_scene_image_generation_payload( + payload: EditorSceneGenerateRequest, +) -> Result { let generation_options = normalize_editor_scene_generation_options( payload.model.as_deref(), payload.aspect_ratio.as_deref(), @@ -2717,8 +2713,7 @@ pub async fn generate_editor_scene( })) })?; let generation_inputs = build_editor_scene_generation_inputs(&payload, &generation_options); - let caller = EditorGenerationCaller::from_authenticated(&authenticated); - let image_payload = EditorImageGenerationRequest { + Ok(EditorImageGenerationRequest { prompt, size: None, kind: Some("scene".to_string()), @@ -2737,7 +2732,18 @@ pub async fn generate_editor_scene( asset_label: Some(normalize_editor_scene_asset_label(payload.asset_label)), source_resource_id: None, canvas_completion: payload.canvas_completion, - }; + }) +} + +pub async fn generate_editor_scene( + State(state): State, + Extension(request_context): Extension, + Extension(authenticated): Extension, + payload: Result, JsonRejection>, +) -> Result, AppError> { + let Json(payload) = parse_editor_generation_json_payload(payload)?; + let image_payload = build_editor_scene_image_generation_payload(payload)?; + let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { let queue_job = enqueue_editor_image_generation_for_owner( &state, @@ -13269,6 +13275,47 @@ mod tests { thread, }; + #[test] + fn scene_image_generation_payload_uses_shared_scene_contract() { + let payload = build_editor_scene_image_generation_payload(EditorSceneGenerateRequest { + scene_content: "雨夜小镇的街道".to_string(), + style_preset: "custom".to_string(), + custom_style: Some("像素水彩混合".to_string()), + model: None, + aspect_ratio: None, + image_size: None, + reference_image_srcs: vec!["resource-ref-1".to_string()], + project_id: Some("project-1".to_string()), + generation_inputs: None, + asset_folder_id: Some("folder-1".to_string()), + asset_label: Some(" ".to_string()), + canvas_completion: None, + }) + .expect("合法场景意图必须组装成功"); + + assert_eq!(payload.kind.as_deref(), Some("scene")); + assert_eq!(payload.asset_kind.as_deref(), Some("scene")); + assert_eq!(payload.aspect_ratio.as_deref(), Some("16:9")); + assert_eq!(payload.image_size.as_deref(), Some("1K")); + assert_eq!(payload.asset_label.as_deref(), Some("游戏场景")); + assert_eq!( + payload.reference_image_srcs.as_deref(), + Some(["resource-ref-1".to_string()].as_slice()) + ); + assert!(payload.prompt.contains("雨夜小镇的街道")); + assert!(payload.prompt.contains("像素水彩混合")); + let generation_inputs = payload + .generation_inputs + .as_ref() + .expect("场景 generationInputs 必须存在"); + assert_eq!(generation_inputs["version"], json!(2)); + assert_eq!(generation_inputs["action"], json!("scene.generate")); + assert_eq!( + generation_inputs["references"], + json!([{ "id": "reference" }]) + ); + } + #[test] fn background_removal_options_preserve_queue_parameters_and_legacy_identity() { for (fields, mode, color) in [ diff --git a/server-rs/crates/api-server/src/external_editor_api.rs b/server-rs/crates/api-server/src/external_editor_api.rs index 2a5eb877f..07bd86ad6 100644 --- a/server-rs/crates/api-server/src/external_editor_api.rs +++ b/server-rs/crates/api-server/src/external_editor_api.rs @@ -7,7 +7,9 @@ use axum::{ use serde::de::DeserializeOwned; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; -use shared_contracts::assets::EditorCanvasGenerationCompletionPayload; +use shared_contracts::assets::{ + EditorCanvasGenerationCompletionPayload, EditorSceneGenerateRequest, +}; use shared_contracts::external_generation::{ ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse, ExternalGenerationJobStatus, @@ -38,12 +40,13 @@ use crate::{ EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest, EditorImageGenerationRequest, EditorProjectListQuery, EditorProjectListView, EditorProjectPayload, EditorProjectResourcePayload, EditorProjectSummaryListResponse, - EditorUiDesignAssetExtractionRequest, current_utc_micros, - editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record, - editor_asset_payload_from_record, editor_idempotent_create_id, - editor_project_payload_from_record, editor_project_resource_payload_from_record, - editor_project_summary_from_record, enqueue_editor_background_removal_for_owner, - enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner, + EditorUiDesignAssetExtractionRequest, build_editor_scene_image_generation_payload, + current_utc_micros, editor_asset_folder_payload_from_record, + editor_asset_library_payload_from_record, editor_asset_payload_from_record, + editor_idempotent_create_id, editor_project_payload_from_record, + editor_project_resource_payload_from_record, editor_project_summary_from_record, + enqueue_editor_background_removal_for_owner, enqueue_editor_image_edit_for_owner, + enqueue_editor_image_generation_for_owner, enqueue_editor_ui_design_asset_extraction_for_owner, ensure_generic_editor_image_generation_contract, map_editor_project_error, normalize_editor_persisted_media_src, normalize_optional_string, @@ -750,6 +753,29 @@ pub async fn generate_external_editor_image( Ok(external_generation_accepted_response(&request_context, job)) } +pub async fn generate_external_editor_scene( + State(state): State, + Extension(request_context): Extension, + Extension(principal): Extension, + headers: HeaderMap, + payload: Result, JsonRejection>, +) -> Result { + let Json(payload) = parse_editor_generation_json_payload(payload)?; + require_scope(&principal, SCOPE_EDITOR_IMAGE_GENERATE)?; + let idempotency_key = require_idempotency_key(&headers)?; + let project_id = payload.project_id.clone(); + let image_payload = build_editor_scene_image_generation_payload(payload)?; + let job = enqueue_editor_image_generation_for_owner( + &state, + &request_context, + &editor_generation_caller(&principal, project_id), + image_payload, + Some(idempotency_key), + ) + .await?; + Ok(external_generation_accepted_response(&request_context, job)) +} + pub async fn edit_external_editor_image( State(state): State, Extension(request_context): Extension, @@ -1792,6 +1818,61 @@ mod tests { .await; } + #[tokio::test] + async fn external_scene_generation_rejects_invalid_intent_before_queueing() { + let state = AppState::new(crate::config::AppConfig::default()) + .expect("external scene test state should build"); + state.fail_test_editor_generation_enqueue(); + let app = Router::new() + .route( + "/api/external/v1/editor/scenes/generations", + post(generate_external_editor_scene), + ) + .layer(Extension(request_context(false))) + .layer(Extension(ExternalApiPrincipal::for_test( + "user-external-scene", + &[SCOPE_EDITOR_IMAGE_GENERATE], + ))) + .with_state(state.clone()); + + for (case_name, request_body) in [ + ( + "empty sceneContent", + json!({"sceneContent": " ", "stylePreset": "anime"}), + ), + ( + "unknown stylePreset", + json!({"sceneContent": "雨夜小镇", "stylePreset": "oil-painting"}), + ), + ( + "custom without customStyle", + json!({"sceneContent": "雨夜小镇", "stylePreset": "custom"}), + ), + ("missing sceneContent", json!({"stylePreset": "anime"})), + ] { + let response = app + .clone() + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri("/api/external/v1/editor/scenes/generations") + .header("content-type", "application/json") + .header(IDEMPOTENCY_KEY_HEADER, "scene-contract-test") + .body(Body::from(request_body.to_string())) + .expect("external scene request should build"), + ) + .await + .expect("external scene response should return"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST, "{case_name}"); + assert_eq!( + state.test_editor_generation_enqueue_attempts(), + 0, + "{case_name} must fail before queueing", + ); + } + } + #[tokio::test] async fn external_background_removal_rejects_invalid_parameters_before_queueing() { let state = AppState::new(crate::config::AppConfig::default()) @@ -2221,6 +2302,21 @@ mod tests { } } + #[test] + fn external_openapi_documents_dedicated_scene_generation_route() { + let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse"); + + let operation = &parsed["paths"]["/api/external/v1/editor/scenes/generations"]["post"]; + assert_eq!( + operation["requestBody"]["content"]["application/json"]["schema"]["$ref"], + json!("#/components/schemas/EditorSceneGenerationRequest") + ); + assert_eq!( + parsed["components"]["schemas"]["EditorSceneGenerationRequest"]["required"], + json!(["sceneContent", "stylePreset"]) + ); + } + #[test] fn exported_openapi_json_contains_external_editor_routes_and_security() { let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse"); diff --git a/server-rs/crates/api-server/src/modules/external_api.rs b/server-rs/crates/api-server/src/modules/external_api.rs index 214baf8e3..dff6580ea 100644 --- a/server-rs/crates/api-server/src/modules/external_api.rs +++ b/server-rs/crates/api-server/src/modules/external_api.rs @@ -19,12 +19,12 @@ use crate::{ delete_external_editor_project, edit_external_editor_image, extract_external_editor_ui_design_assets, generate_external_editor_background_music, generate_external_editor_character_animation, generate_external_editor_icon_spritesheet, - generate_external_editor_image, generate_external_editor_sound_effect, - generate_external_editor_video, get_external_editor_asset_library, - get_external_editor_generation_job, get_external_editor_project, - list_external_editor_projects, load_recent_external_editor_project, openapi_json, - remove_external_editor_image_background, rename_external_editor_project, - save_external_editor_canvas, update_external_editor_asset, + generate_external_editor_image, generate_external_editor_scene, + generate_external_editor_sound_effect, generate_external_editor_video, + get_external_editor_asset_library, get_external_editor_generation_job, + get_external_editor_project, list_external_editor_projects, + load_recent_external_editor_project, openapi_json, remove_external_editor_image_background, + rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset, update_external_editor_asset_folder, }, external_mcp, @@ -111,6 +111,10 @@ pub fn router(state: AppState) -> Router { "/api/external/v1/editor/images/generations", post(generate_external_editor_image), ), + ( + "/api/external/v1/editor/scenes/generations", + post(generate_external_editor_scene), + ), ( "/api/external/v1/editor/images/edits", post(edit_external_editor_image), @@ -257,6 +261,7 @@ mod route_contract_tests { ), ("/api/external/v1/generations/{operation_id}", &["GET"]), ("/api/external/v1/editor/images/generations", &["POST"]), + ("/api/external/v1/editor/scenes/generations", &["POST"]), ("/api/external/v1/editor/images/edits", &["POST"]), ( "/api/external/v1/editor/images/background-removals", -- 2.52.0 From 2370dc509b89cf6de7804d6a1c59d610bfe46167 Mon Sep 17 00:00:00 2001 From: Linghong Date: Thu, 24 Sep 2026 16:18:40 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E5=9C=BA=E6=99=AF=E7=94=9F=E6=88=90=E7=9A=84=E5=B9=82=E7=AD=89?= =?UTF-8?q?=E4=B8=8E=E7=BB=93=E6=9E=9C=E8=BF=94=E5=9B=9E=E5=8D=8F=E8=AE=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 站内场景入口读取并透传可选幂等键,复用既有队列去重逻辑 场景配方保留 AGC 来源标记,恢复可下载的队列完成结果 补充非法幂等键、场景结果序列化和幂等命名空间回归测试 同步 OpenAPI、技术方案、实施计划及排障记录 --- .../genarrative-external-v1.openapi.json | 3 +- ...®¡划】ExternalV1游戏场景生成路由-2026-09-24.md | 3 +- ...‹碑】ExternalV1游戏场景生成路由-2026-09-24.md | 3 +- docs/project-memory/shared-memory/pitfalls.md | 6 ++ ...–¹案】ExternalV1游戏场景生成路由-2026-09-24.md | 5 +- server-rs/crates/api-server/src/app.rs | 38 ++++++++++ .../api-server/src/editor_generation_queue.rs | 13 ++++ .../crates/api-server/src/editor_project.rs | 70 ++++++++++++++++++- 8 files changed, 134 insertions(+), 7 deletions(-) diff --git a/docs/openapi/genarrative-external-v1.openapi.json b/docs/openapi/genarrative-external-v1.openapi.json index 927babb33..63a51cbac 100644 --- a/docs/openapi/genarrative-external-v1.openapi.json +++ b/docs/openapi/genarrative-external-v1.openapi.json @@ -3059,7 +3059,8 @@ "type": ["string", "null"] }, "generationInputs": { - "$ref": "#/components/schemas/JsonValue" + "$ref": "#/components/schemas/JsonValue", + "description": "场景配方由服务端重建;仅保留 source 精确等于 ai-game-creator-client 的客户端来源标记,用于选择 AGC 队列结果与幂等命名空间。调用方 fields、action 和引用 provenance 不会覆盖服务端配方。" }, "assetFolderId": { "type": ["string", "null"] diff --git a/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md index 78a248478..ec8a48ea5 100644 --- a/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md +++ b/docs/project-memory/plans/【实施计划】ExternalV1游戏场景生成路由-2026-09-24.md @@ -15,7 +15,7 @@ - `docs/openapi/genarrative-external-v1.openapi.json` 与对应契约测试 - AGC `src-tauri/src/agent/generation/canvas_generation.rs`、`src/agent/direct_runtime/mod.rs`、`src/agent/generation/external_generation_state.rs`、`src/agent/runtime_driver/recovery_scan.rs`、`src/project/manifest.rs`(背景阶段路由与身份口径) - `.codex/skills/genarrative-external-editor-api/references/`(外部 API 说明) -- 明确不修改:站内场景路由行为、通用图片入口校验、SpacetimeDB schema、计费、动画/抠图链路(issue #495)。 +- 明确不修改:站内场景生成规则、通用图片入口校验、SpacetimeDB schema、计费、动画/抠图链路(issue #495);站内入口补齐现有 AGC 账号模式的幂等与结果协议。 ## 实现顺序 @@ -26,6 +26,7 @@ 5. 客户端:`canvas_generation.rs` 请求构造为 Scene 拆专属分支——新路由 + `EditorSceneGenerateRequest` 形状 body(`sceneContent` = 现有场景 prompt 文本,`stylePreset = "custom"`,`customStyle` = 现有风格描述文案,其余字段沿用);修 Scene 保留账本匹配器(新路由 + `sceneContent` 口径),统一 `game-background`/`scene` 不一致。 6. 客户端:身份/对账常量迁移——`direct_runtime/mod.rs` 背景身份三元组路由与 kind、`manifest.rs` 期望路由、`recovery_scan.rs`、`external_generation_state.rs`;保持旧路由登记可读。 7. 文档:外部 API Skill references 补新路由;运行编码与 diff 检查。 +8. 审查修复:站内场景 handler 读取并透传可选幂等键;共享组装只保留精确 AGC 来源标记。增加非法键路由测试与场景 payload 到队列可下载结果的回归测试,同步 OpenAPI 元数据说明。 ## 验证命令 diff --git a/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md index 72ce291c3..a760cad64 100644 --- a/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md +++ b/docs/project-memory/plans/【里程碑】ExternalV1游戏场景生成路由-2026-09-24.md @@ -19,7 +19,7 @@ ## 不在本里程碑内 -- 不改站内场景路由行为与通用图片入口的 scene 拒绝校验。 +- 保持站内场景生成规则,补齐账号模式幂等与结果协议;不改通用图片入口的 scene 拒绝校验。 - 不改 SpacetimeDB schema、队列类型、计费档位。 - 不迁移历史背景登记数据。 - 不处理 issue #495 的两个既有问题。 @@ -32,6 +32,7 @@ ## 验收标准 +- [x] 账号场景入口透传可选幂等键、入队前拒绝非法键;场景配方保留 AGC 来源标记且队列完成结果包含可下载资源(路由校验及组装到结果序列化的定向测试)。 - [ ] 新路由契约测试通过:路由矩阵与鉴权、参数 400、同键重放返回原任务、同键不同请求 409。 - [ ] 外部场景路由与站内路由对相同输入产出相同的后端 Prompt 与入队 payload(共享实现单测对照)。 - [ ] `docs/openapi/genarrative-external-v1.openapi.json` 与实现一致,相关契约检查通过。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 0a3a42c64..e8ab901aa 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -1,5 +1,11 @@ # 踩坑与排障记录 +## 2026-09-24 AGC 生成路由迁移必须核对账号队列契约 + +- AGC 普通账号会把 external editor 路由映射到站内入口;只验证 API Key 路由不足以证明客户端链路可用。 +- 站内入口必须读取客户端持久化的 `Idempotency-Key`,场景配方重建须保留精确的 `generationInputs.source = ai-game-creator-client` 标记。否则可能按请求 ID 重复入队,且普通队列 consumer 不保存客户端轮询所需的可下载 `result`。 +- 场景其余执行字段和引用身份仍由服务端重建,不整体信任调用方 `generationInputs`。验证应覆盖来源标记经过组装、队列清理和结果序列化的完整纯逻辑链路,以及账号路由的幂等键校验。 + ## 同一祖先下的多个项目会各自弹一次 UAC - **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。 diff --git a/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md b/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md index 3a229f6f5..9bb76550d 100644 --- a/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md +++ b/docs/technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md @@ -18,7 +18,7 @@ AGC 美术包背景阶段(结构化场景意图) ## 非目标 -- 不改动站内 `/api/editor/scenes/generations` 的请求字段、Prompt 组装结果与计费语义。 +- 保持站内 `/api/editor/scenes/generations` 的场景字段、Prompt 和计费规则;补齐 AGC 账号模式所需的可选幂等键与队列结果协议。 - 不放松通用 `/api/editor/images/generations` 与 `/api/external/v1/editor/images/generations` 对 `kind = scene` / `assetKind = scene` 的拒绝。 - 不新增场景 Worker、任务表、计费档位或 SpacetimeDB schema。 - 不改变美术包背景图的出图风格与尺寸(16:9 / 1K)。 @@ -40,6 +40,7 @@ AGC 美术包背景阶段(结构化场景意图) 4. 受理响应与现役外部生成入口同形(operationId 异步受理信封),轮询继续走 `/api/external/v1/generations/{operation_id}`。 5. 入队后 `kind = scene`、`assetKind = scene`,队列类型、Worker、计费与持久化与站内场景路由一致;队列标题与任务摘要口径不变。 6. AGC 美术包背景阶段以 `stylePreset = custom` + `customStyle` 承载现有风格描述,`sceneContent` 承载 brief 衍生的画面内容,出图风格与比例不因迁移改变。 +7. 普通账号自动映射到 `/api/editor/scenes/generations`,该入口读取可选 `Idempotency-Key` 并传给现有队列;未提供时保留站内按请求 ID 入队的行为。场景组装仅保留 `generationInputs.source = ai-game-creator-client` 这一精确标记,其余配方字段仍由服务端重建。该标记让账号任务沿用 AGC 幂等命名空间和包含可下载 `result` 的队列结果,轮询走 `/api/runtime/external-generation/jobs/{operation_id}`。 ### 失败、重试与幂等 @@ -47,6 +48,7 @@ AGC 美术包背景阶段(结构化场景意图) 2. 缺少或非法幂等键、越权 scope 的拒绝语义与现役外部生成入口一致。 3. 同一幂等键 + 同一请求重放返回原任务,不新建任务、不重复扣费;同键不同请求返回 409。 4. Provider 失败、取消与 lease 耗尽沿用现有扣退费语义。 +5. 账号场景入口拒绝非法幂等键(400);同键重放复用现有队列幂等实现。来源标记不参与权限授予,任务归属仍来自已认证用户。 ### 权限、归属与数据边界 @@ -64,6 +66,7 @@ AGC 美术包背景阶段(结构化场景意图) | 条款 | 验收方式 | 证据 | | ---- | -------- | ---- | +| 账号入口幂等键校验与 AGC 下载结果 | 非法键路由测试、场景来源到结果序列化测试、共享队列幂等命名空间测试 | `cargo test --locked -p api-server scene`(18 项)、`editor_generation_queue::tests`(19 项)、`external`(158 项)通过;本地启动因 SpacetimeDB 连接拒绝未通过健康检查,真实 Provider 出图与账号同键重放尚未联调 | | 新路由受理/参数校验/鉴权/幂等重放 | api-server 契约测试与单测 | 待补 | | 与站内路由同一 Prompt 组装结果 | 共享实现的单测对照 | 待补 | | OpenAPI 与实现一致 | 契约测试 + `check:openapi` 类门禁 | 待补 | diff --git a/server-rs/crates/api-server/src/app.rs b/server-rs/crates/api-server/src/app.rs index 201f2327c..659686d1f 100644 --- a/server-rs/crates/api-server/src/app.rs +++ b/server-rs/crates/api-server/src/app.rs @@ -2749,6 +2749,44 @@ mod tests { } } + #[tokio::test] + async fn editor_scene_generation_rejects_invalid_idempotency_key_before_queueing() { + let state = AppState::new(AppConfig { + external_generation_mode: ExternalGenerationMode::Queue, + ..AppConfig::default() + }) + .expect("state should build"); + let seed_user = seed_phone_user_with_password(&state, "13800138232", TEST_PASSWORD).await; + let token = sign_test_user_token(&state, &seed_user, "sess_editor_scene_idempotency"); + state.fail_test_editor_generation_enqueue(); + let app = build_router(state.clone()); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/api/editor/scenes/generations") + .header("authorization", format!("Bearer {token}")) + .header("content-type", "application/json") + .header("idempotency-key", "contains space") + .body(Body::from( + serde_json::json!({ + "sceneContent": "雨夜小镇", + "stylePreset": "anime", + "generationInputs": { "source": "ai-game-creator-client" }, + }) + .to_string(), + )) + .expect("request should build"), + ) + .await + .expect("request should complete"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(state.test_editor_generation_enqueue_attempts(), 0); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert!(String::from_utf8_lossy(&body).contains("Idempotency-Key")); + } + #[tokio::test] async fn editor_scene_generation_rejects_inline_data_url_before_queueing() { let state = AppState::new(AppConfig { diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index b615a870b..75d8856f1 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -826,6 +826,19 @@ mod tests { editor_generation_idempotency_namespace(&game_creator), GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX ); + let scene = crate::editor_project::build_editor_scene_image_generation_payload( + serde_json::from_value(json!({ + "sceneContent": "雨夜小镇", + "stylePreset": "anime", + "generationInputs": { "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE }, + })) + .expect("scene request should deserialize"), + ) + .expect("scene payload should build"); + assert_eq!( + editor_generation_idempotency_namespace(&scene), + GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX, + ); assert_eq!( editor_generation_idempotency_namespace(&ordinary), EXTERNAL_API_GENERATION_DEDUPE_PREFIX diff --git a/server-rs/crates/api-server/src/editor_project.rs b/server-rs/crates/api-server/src/editor_project.rs index 9a6aad4bf..d67344797 100644 --- a/server-rs/crates/api-server/src/editor_project.rs +++ b/server-rs/crates/api-server/src/editor_project.rs @@ -2658,12 +2658,23 @@ fn build_editor_scene_generation_inputs( { fields.push(json!({ "id": "customStyle", "title": "自定义画风", "value": custom_style })); } - json!({ + let mut inputs = json!({ "version": 2, "action": "scene.generate", "fields": fields, "references": references, - }) + }); + // AGC 账号任务依赖来源标记选择幂等命名空间和可下载的队列结果;其余字段仍由服务端重建。 + if payload + .generation_inputs + .as_ref() + .and_then(|inputs| inputs.get("source")) + .and_then(Value::as_str) + == Some(GAME_CREATOR_CLIENT_GENERATION_SOURCE) + { + inputs["source"] = json!(GAME_CREATOR_CLIENT_GENERATION_SOURCE); + } + inputs } fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str { @@ -2739,9 +2750,11 @@ pub async fn generate_editor_scene( State(state): State, Extension(request_context): Extension, Extension(authenticated): Extension, + headers: HeaderMap, payload: Result, JsonRejection>, ) -> Result, AppError> { let Json(payload) = parse_editor_generation_json_payload(payload)?; + let idempotency_key = optional_editor_idempotency_key(&headers)?; let image_payload = build_editor_scene_image_generation_payload(payload)?; let caller = EditorGenerationCaller::from_authenticated(&authenticated); if !state.config.external_generation_mode.is_inline() { @@ -2750,7 +2763,7 @@ pub async fn generate_editor_scene( &request_context, &caller, image_payload, - None, + idempotency_key, ) .await?; return Ok(json_success_body( @@ -13316,6 +13329,57 @@ mod tests { ); } + #[test] + fn scene_generation_preserves_agc_downloadable_queue_result() { + let request = serde_json::from_value::(json!({ + "sceneContent": "雨夜小镇", + "stylePreset": "anime", + "referenceImageSrcs": ["art-spec-resource"], + "generationInputs": { + "source": GAME_CREATOR_CLIENT_GENERATION_SOURCE, + "action": "client-supplied-action", + "fields": [{ "id": "prompt", "value": "不可信配方" }], + "references": [{ "refId": "untrusted-resource" }], + }, + })) + .expect("scene request should deserialize"); + let mut image_payload = build_editor_scene_image_generation_payload(request) + .expect("scene payload should build"); + image_payload.generation_inputs = + sanitize_editor_queued_generation_inputs(image_payload.generation_inputs); + let inputs = image_payload.generation_inputs.as_ref().unwrap(); + assert_eq!(inputs["source"], GAME_CREATOR_CLIENT_GENERATION_SOURCE); + assert_eq!(inputs["action"], "scene.generate"); + assert_eq!(inputs["fields"][0]["value"], "雨夜小镇"); + assert_eq!(inputs["references"], json!([{ "id": "reference" }])); + + let mut job = atomic_editor_generation_job_fixture(); + job.request_payload_json = serde_json::to_string(&image_payload).unwrap(); + let context = EditorGenerationQueueResultContext::from_job(&job); + assert_eq!( + context.consumer, + EditorGenerationQueueConsumer::GameCreatorResourceEditor + ); + let result: Value = serde_json::from_str( + &serialize_atomic_editor_generation_job_result( + &context, + &json!({ + "ok": true, + "objectKey": "generated/scene.png", + "resource": { + "resourceId": "scene-resource", + "objectKey": "generated/scene.png", + "assetObjectId": "scene-object", + }, + }), + ) + .expect("AGC scene result should serialize"), + ) + .unwrap(); + assert_eq!(result["result"]["objectKey"], "generated/scene.png"); + assert_eq!(result["result"]["resource"]["resourceId"], "scene-resource"); + } + #[test] fn background_removal_options_preserve_queue_parameters_and_legacy_identity() { for (fields, mode, color) in [ -- 2.52.0