Compare commits

...

6 Commits

Author SHA1 Message Date
lhk229 fc439612b0 Merge branch 'master' into feat/external-scene-generation
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m38s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m12s
Project CI / Backend tests (pull_request) Successful in 3m57s
Project CI / Frontend tests (pull_request) Successful in 1m56s
Project CI / Native shell tests (pull_request) Successful in 5m59s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m2s
Project CI / Repository checks (pull_request) Successful in 1m57s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m21s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m46s
2026-09-25 01:25:44 +08:00
lhk229 6ed1fd26ed 修复 Linux 沙箱命令退出回收的偶发竞态
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m7s
Project CI / Backend tests (pull_request) Successful in 3m49s
Project CI / Frontend tests (pull_request) Successful in 1m53s
Project CI / Native shell tests (pull_request) Successful in 5m51s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m57s
Project CI / Repository checks (pull_request) Successful in 1m55s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m19s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m24s
Project CI / AI game creator shell Rust crates (push) Successful in 1m30s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m9s
Project CI / Backend tests (push) Successful in 4m49s
Project CI / Frontend tests (push) Successful in 2m9s
Project CI / Native shell tests (push) Successful in 6m54s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 9m56s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 10m14s
Project CI / Repository checks (push) Successful in 2m2s
Project CI / AI game creator shell web tests (push) Successful in 1m30s
统一正常退出、取消和超时的有界进程组退出确认,保留归属校验
提前记录启动身份,修补终态协议错误的清理及输出任务回收
补充确定性竞态回归并同步运行时规范和排障记忆
保持现有 CI 并行、分片和重试策略不变
2026-09-24 16:45:07 +00:00
lhk229 2370dc509b 修复账号场景生成的幂等与结果返回协议
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m33s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m3s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 2m2s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m10s
Project CI / Repository checks (pull_request) Successful in 1m55s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m26s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m0s
站内场景入口读取并透传可选幂等键,复用既有队列去重逻辑
场景配方保留 AGC 来源标记,恢复可下载的队列完成结果
补充非法幂等键、场景结果序列化和幂等命名空间回归测试
同步 OpenAPI、技术方案、实施计划及排障记录
2026-09-24 16:18:40 +00:00
lhk229 4912aa4df0 删除创作首页登录送泥点营销文案
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m34s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m14s
Project CI / Backend tests (pull_request) Successful in 3m51s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m40s
Project CI / Frontend tests (pull_request) Successful in 2m0s
Project CI / Native shell tests (pull_request) Successful in 5m52s
Project CI / Repository checks (pull_request) Successful in 2m8s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m50s
Project CI / AI game creator shell web tests (pull_request) Successful in 2m17s
删除 CreationLandingView hero 区“登录即送 100 泥点,可以免费制作 50 个素材”文案
删除 CreationLandingView.test.tsx 中对应的文案断言
清理 index.css 中已无调用方的 creation-landing__hero-benefit 样式
更正 decision-log 中已过期的“注册赠送固定为 100 泥点”决策记录,注明金额由线上钱包配置决定
2026-09-24 16:08:42 +00:00
lhk229 bb69e09b8d Merge branch 'master' into feat/external-scene-generation
Project CI / AI game creator shell Rust crates (pull_request) Successful in 1m31s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 1m56s
Project CI / Backend tests (pull_request) Successful in 3m53s
Project CI / Frontend tests (pull_request) Successful in 2m9s
Project CI / Native shell tests (pull_request) Successful in 5m58s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 9m6s
Project CI / Repository checks (pull_request) Successful in 1m54s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 9m58s
Project CI / AI game creator shell web tests (pull_request) Successful in 1m32s
2026-09-24 23:47:34 +08:00
lhk229 571cb00fe4 新增 external v1 游戏场景生成路由并迁移 AGC 美术包背景阶段
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
- 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 文档
2026-09-24 15:40:30 +00:00
23 changed files with 871 additions and 144 deletions
@@ -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` | | `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` | | `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/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/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/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` | | `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 | | 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` | | 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` | | 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` | | 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` | | 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` | | 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` | | 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 ```text
GET /api/external/v1/generations/{operationId} 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: 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. - 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 `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 `aspectRatio`: `1:1`, `2:3`, `3:2`, `9:16`, `16:9`.
- Image `imageSize`: `0.5K`, `1K`, `2K`. - Image `imageSize`: `0.5K`, `1K`, `2K`.
@@ -148,7 +148,7 @@ For the lower-level asset/resource creation endpoints, `generationInputs` is rep
## Art Spec and Image Request ## 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: 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:
@@ -2326,6 +2326,30 @@ fn direct_taonier_reference_matches_local_source(
.unwrap_or(false) .unwrap_or(false)
} }
fn direct_taonier_background_asset_identity(
root: &Path,
expected_reference_source: Option<&DirectTaonierArtAssetIdentity>,
) -> Option<DirectTaonierArtAssetIdentity> {
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 { fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
let Some(art_spec) = direct_taonier_art_asset_identity( let Some(art_spec) = direct_taonier_art_asset_identity(
root, root,
@@ -2337,14 +2361,7 @@ fn direct_taonier_art_base_is_valid(root: &Path) -> bool {
) else { ) else {
return false; return false;
}; };
let Some(background) = direct_taonier_art_asset_identity( let Some(background) = direct_taonier_background_asset_identity(root, Some(&art_spec)) else {
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(&art_spec),
) else {
return false; return false;
}; };
let _ = (background, art_spec); let _ = (background, art_spec);
@@ -3325,15 +3342,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
}) })
.flatten(); .flatten();
let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| { let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| {
direct_taonier_art_asset_identity( direct_taonier_background_asset_identity(root, Some(art_spec)).is_some()
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(art_spec),
)
.is_some()
}); });
let art_spec = match existing_art_spec { let art_spec = match existing_art_spec {
Some(identity) => identity, Some(identity) => identity,
@@ -3409,15 +3418,7 @@ pub(crate) async fn ensure_direct_taonier_art_package_at(
} }
}; };
if mode.regenerates_existing() if mode.regenerates_existing()
|| direct_taonier_art_asset_identity( || direct_taonier_background_asset_identity(root, Some(&art_spec)).is_none()
root,
DIRECT_CODEX_BACKGROUND_ASSET_PATH,
GameCreationAppAssetKind::Scene,
"/api/external/v1/editor/images/generations",
"spec",
Some(&art_spec),
)
.is_none()
{ {
emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图"); emit_direct_game_creator_progress(root, "art.background", "正在生成 16:9 游戏场景背景图");
let outcome = match generate_direct_taonier_art_asset_at( let outcome = match generate_direct_taonier_art_asset_at(
@@ -8294,8 +8295,8 @@ mod tests {
vec!["taonier-resource-icon-spec".to_string()], vec!["taonier-resource-icon-spec".to_string()],
), ),
GameCreationAppAssetKind::Scene => ( GameCreationAppAssetKind::Scene => (
"/api/external/v1/editor/images/generations", "/api/external/v1/editor/scenes/generations",
"spec", "scene",
vec!["taonier-resource-icon-spec".to_string()], vec!["taonier-resource-icon-spec".to_string()],
), ),
_ => ( _ => (
@@ -8679,6 +8680,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<u8> { fn tiny_visible_png() -> Vec<u8> {
let mut bytes = Vec::new(); let mut bytes = Vec::new();
let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel(
@@ -2835,14 +2835,15 @@ pub(in crate::agent) fn retained_platform_art_generation_runtime_state_matches_d
&& art_spec_asset_type == Some("icon-spec") && art_spec_asset_type == Some("icon-spec")
} }
GameCreationAppAssetKind::Scene => { GameCreationAppAssetKind::Scene => {
snapshot.endpoint == "/api/external/v1/editor/images/generations" snapshot.endpoint == "/api/external/v1/editor/scenes/generations"
&& snapshot.generation_kind == "spec" && snapshot.generation_kind == "scene"
&& platform_art_runtime_references_match_request_contract( && platform_art_runtime_references_match_request_contract(
&snapshot.reference_resource_ids, &snapshot.reference_resource_ids,
GameCreationAppAssetKind::Scene, GameCreationAppAssetKind::Scene,
) )
&& request_asset_kind.as_deref() == Some("game-background") && json_string_field(&request_body, "sceneContent")
&& art_spec_asset_type == Some("background") .is_some_and(|value| !value.trim().is_empty())
&& json_string_field(&request_body, "stylePreset").as_deref() == Some("custom")
} }
GameCreationAppAssetKind::IconSpritesheet => { GameCreationAppAssetKind::IconSpritesheet => {
snapshot.endpoint == "/api/external/v1/editor/icon-spritesheets/generations" 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::Image => "image",
GameCreationAppAssetKind::Character => "character", GameCreationAppAssetKind::Character => "character",
GameCreationAppAssetKind::PublicationMaterial => "publication-material", GameCreationAppAssetKind::PublicationMaterial => "publication-material",
GameCreationAppAssetKind::Scene => "scene",
_ => "spec", _ => "spec",
}; };
let is_canonical_art_spritesheet = 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 { } else {
( (
"/api/external/v1/editor/images/generations", "/api/external/v1/editor/images/generations",
@@ -13272,14 +13293,13 @@ mod canvas_generation_tests {
let background = write_retained_stage( let background = write_retained_stage(
root, root,
"run-background-canonical-and-user-references", "run-background-canonical-and-user-references",
"/api/external/v1/editor/images/generations", "/api/external/v1/editor/scenes/generations",
serde_json::json!({ serde_json::json!({
"prompt": "保留账本参考合同", "sceneContent": "保留账本参考合同",
"kind": "spec", "stylePreset": "custom",
"assetKind": "game-background", "customStyle": "原创横屏 Web 游戏场景背景",
"projectId": "test-canvas-project", "projectId": "test-canvas-project",
"assetFolderId": "test-asset-folder", "assetFolderId": "test-asset-folder",
"generationInputs": { "artSpec": { "assetType": "background" } },
"referenceImageSrcs": ["resource-icon-spec", "user-reference-1"], "referenceImageSrcs": ["resource-icon-spec", "user-reference-1"],
}), }),
); );
@@ -735,6 +735,30 @@ pub(super) fn platform_art_generation_runtime_request_snapshot(
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
(generation_kind, reference_resource_ids) (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::<Result<Vec<_>, _>>()?;
("scene".to_string(), reference_resource_ids)
}
"/api/external/v1/editor/icon-spritesheets/generations" => { "/api/external/v1/editor/icon-spritesheets/generations" => {
let reference_resource_id = json_string_field(&request_body, "referenceId") let reference_resource_id = json_string_field(&request_body, "referenceId")
.or_else(|| json_string_field(&request_body, "referenceImageSrc")) .or_else(|| json_string_field(&request_body, "referenceImageSrc"))
@@ -555,6 +555,7 @@ fn platform_art_generation_request_snapshot_is_valid(payload: &serde_json::Value
endpoint, endpoint,
Some( Some(
"/api/external/v1/editor/images/generations" "/api/external/v1/editor/images/generations"
| "/api/external/v1/editor/scenes/generations"
| "/api/external/v1/editor/icon-spritesheets/generations" | "/api/external/v1/editor/icon-spritesheets/generations"
) )
); );
@@ -15,6 +15,8 @@ const PROJECT_COMMAND_MAX_ARGUMENT_BYTES: usize = 8 * 1024;
const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1; const PROJECT_COMMAND_MIN_TIMEOUT_SECONDS: u64 = 1;
const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300; const PROJECT_COMMAND_MAX_TIMEOUT_SECONDS: u64 = 300;
const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024; const PROJECT_COMMAND_OUTPUT_MAX_BYTES: usize = 24 * 1024;
#[cfg(target_os = "linux")]
const PROJECT_COMMAND_CLEANUP_TIMEOUT: Duration = Duration::from_secs(5);
const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000; const PROJECT_COMMAND_FINGERPRINT_MAX_ENTRIES: usize = 20_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000; const PROJECT_COMMAND_FINGERPRINT_MAX_FILES: usize = 10_000;
const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024; const PROJECT_COMMAND_FINGERPRINT_MAX_BYTES: u64 = 512 * 1024 * 1024;
@@ -222,14 +224,30 @@ impl ProjectCommandTree {
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
let requested = self.request_owned_group_termination(); let requested = self.request_owned_group_termination();
let _ = child.start_kill(); let _ = child.start_kill();
let waited = tokio::time::timeout(Duration::from_secs(5), child.wait()).await; let waited = tokio::time::timeout_at(deadline, child.wait()).await;
requested?;
waited waited
.map_err(|_| "等待受控命令主进程退出超时")? .map_err(|_| "等待受控命令主进程退出超时")?
.map_err(|_| "受控命令主进程退出未确认")?; .map_err(|_| "受控命令主进程退出未确认")?;
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into()) #[cfg(target_os = "linux")]
{
let Self::Group { pid, .. } = self;
// leader 可能与取消同时退出;只要组已停止便无需补发信号。
if let Err(error) = wait_linux_project_command_group_exit(*pid, deadline).await {
return Err(match requested {
Ok(_) => error,
Err(request_error) => format!("{request_error};{error}"),
});
}
return Ok("主进程已回收,受控进程组已无活成员;完整子树状态未证明".into());
}
#[cfg(not(target_os = "linux"))]
{
requested?;
Ok("已请求终止受控进程组并回收主进程,完整子树状态未证明".into())
}
} }
} }
@@ -244,17 +262,39 @@ impl ProjectCommandTree {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
{ {
let Self::Group { pid, .. } = self; let Self::Group { pid, .. } = self;
// 容器 PID 1 可能不回收 bwrap 的孤儿僵尸;它们不再执行,也无法被信号终止。 // wait 已回收 leader,不能再用旧 PID 授权发信号。
// 仅在确认没有存活成员时免除清理,存活成员仍须通过 leader 身份核对。 // namespace 后代可能仍在退出,容器 PID 1 也可能保留孤儿僵尸。
if !linux_project_command_group_has_live_members(*pid)? { return wait_linux_project_command_group_exit(
return Ok(()); *pid,
} tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT,
)
.await;
} }
#[cfg(not(target_os = "linux"))]
self.request_owned_group_termination().map(|_| ()) self.request_owned_group_termination().map(|_| ())
} }
} }
} }
#[cfg(target_os = "linux")]
async fn wait_linux_project_command_group_exit(
group: u32,
deadline: tokio::time::Instant,
) -> Result<(), String> {
while linux_project_command_group_has_live_members(group)? {
if tokio::time::Instant::now() >= deadline {
return Err(
"受控进程组仍有存活成员,退出未确认;不得向身份未确认的进程组补发信号".into(),
);
}
tokio::time::sleep_until(
deadline.min(tokio::time::Instant::now() + Duration::from_millis(20)),
)
.await;
}
Ok(())
}
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> { fn linux_project_command_group_has_live_members(group: u32) -> Result<bool, String> {
let inspect = || -> std::io::Result<bool> { let inspect = || -> std::io::Result<bool> {
@@ -1842,6 +1882,17 @@ where
), ),
)); ));
} }
// 目标放行后可能立即退出,必须趁 ready gate 仍持有 launcher 时记录归属。
let tree = match ProjectCommandTree::attach(&child) {
Ok(tree) => tree,
Err(error) => {
let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new(
ProjectCommandErrorStage::Preflight,
project_command_launch_error_with_termination(error, termination),
));
}
};
if let Err(error) = durable_commit() { if let Err(error) = durable_commit() {
let termination = terminate_project_command_process_group(&mut child).await; let termination = terminate_project_command_process_group(&mut child).await;
return Err(ProjectCommandError::new( return Err(ProjectCommandError::new(
@@ -1860,12 +1911,7 @@ where
// cancelled future must not erase the launch-unknown decision window. // cancelled future must not erase the launch-unknown decision window.
let exec = gate.wait_target_exec(Duration::from_secs(3)); let exec = gate.wait_target_exec(Duration::from_secs(3));
match exec { match exec {
Ok(TargetExecState::Established) => { Ok(TargetExecState::Established) => Ok(EstablishedProjectCommand { tree, child, gate }),
let tree = ProjectCommandTree::attach(&child).map_err(|error| {
ProjectCommandError::new(ProjectCommandErrorStage::LaunchUnknown, error)
})?;
Ok(EstablishedProjectCommand { tree, child, gate })
}
Ok(TargetExecState::Failed { errno }) => { Ok(TargetExecState::Failed { errno }) => {
let termination = terminate_project_command_process_group_after_commit(&mut child); let termination = terminate_project_command_process_group_after_commit(&mut child);
Err(ProjectCommandError::new( Err(ProjectCommandError::new(
@@ -1977,7 +2023,11 @@ async fn terminate_project_command_process_group(
.ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?; .ok_or_else(|| "请求终止受控进程组失败:子进程缺少 pid".to_string())?;
let group_result = request_unix_project_command_process_group_termination(process_id); let group_result = request_unix_project_command_process_group_termination(process_id);
let child_kill_error = child.start_kill().err(); let child_kill_error = child.start_kill().err();
let wait_result = child.wait().await; let deadline = tokio::time::Instant::now() + PROJECT_COMMAND_CLEANUP_TIMEOUT;
let wait_result = tokio::time::timeout_at(deadline, child.wait())
.await
.map_err(|_| "等待受控命令主进程退出超时".to_string())?
.map_err(|error| error.to_string());
if let Err(error) = &group_result { if let Err(error) = &group_result {
let fallback = match (&child_kill_error, &wait_result) { let fallback = match (&child_kill_error, &wait_result) {
(_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(), (_, Ok(_)) => "主进程已回收,但无法确认其余组内进程".to_string(),
@@ -1989,6 +2039,7 @@ async fn terminate_project_command_process_group(
return Err(format!("{error};{fallback}")); return Err(format!("{error};{fallback}"));
} }
wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?; wait_result.map_err(|error| format!("请求终止受控进程组后等待主进程失败:{error}"))?;
wait_linux_project_command_group_exit(process_id, deadline).await?;
Ok(format!( Ok(format!(
"{}并完成主进程回收", "{}并完成主进程回收",
group_result.expect("group termination result checked") group_result.expect("group termination result checked")
@@ -2220,7 +2271,7 @@ where
let (exit_code, timed_out, termination_summary) = match wait { let (exit_code, timed_out, termination_summary) = match wait {
ProjectCommandWait::Exited(Ok(status)) => { ProjectCommandWait::Exited(Ok(status)) => {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let _terminal = wait_established_project_command_terminal(gate).await?; let terminal = wait_established_project_command_terminal(gate).await;
if let Err(error) = tree.after_main_exit(&mut child).await { if let Err(error) = tree.after_main_exit(&mut child).await {
stdout_task.abort(); stdout_task.abort();
stderr_task.abort(); stderr_task.abort();
@@ -2229,6 +2280,12 @@ where
format!("command.exec 主进程退出后进程树未确认回收,需要人工核对:{error}"), format!("command.exec 主进程退出后进程树未确认回收,需要人工核对:{error}"),
)); ));
} }
#[cfg(target_os = "linux")]
if let Err(error) = terminal {
stdout_task.abort();
stderr_task.abort();
return Err(error);
}
(status.code(), false, None) (status.code(), false, None)
} }
ProjectCommandWait::Exited(Err(error)) => { ProjectCommandWait::Exited(Err(error)) => {
@@ -2541,45 +2598,83 @@ mod tests {
return; return;
} }
assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0); assert_eq!(unsafe { libc::prctl(libc::PR_SET_CHILD_SUBREAPER, 1) }, 0);
let mut command = tokio::process::Command::new("/bin/sh"); struct DescendantGuard(i32);
command impl Drop for DescendantGuard {
.args(["-c", "sleep 60 & echo $!; read release"]) fn drop(&mut self) {
.stdin(Stdio::piped()) unsafe {
.stdout(Stdio::piped()); libc::kill(self.0, libc::SIGKILL);
command.as_std_mut().process_group(0); libc::waitpid(self.0, std::ptr::null_mut(), 0);
let mut child = command.spawn().unwrap(); }
let tree = ProjectCommandTree::attach(&child).unwrap(); }
let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap()); }
let mut line = String::new(); // 正常退出与取消/超时共用的 terminate 都覆盖 leader 已回收的窗口。
tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line) for terminate in [false, true] {
.await let mut command = tokio::process::Command::new("/bin/sh");
.unwrap(); command
let descendant: i32 = line.trim().parse().unwrap(); .args(["-c", "sleep 60 & echo $!; read release"])
drop(child.stdin.take()); .stdin(Stdio::piped())
child.wait().await.unwrap(); .stdout(Stdio::piped())
let live_result = tree.after_main_exit(&mut child).await; .kill_on_drop(true);
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0); command.as_std_mut().process_group(0);
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() }; let mut child = command.spawn().unwrap();
assert_eq!( let tree = ProjectCommandTree::attach(&child).unwrap();
unsafe { let mut output = tokio::io::BufReader::new(child.stdout.take().unwrap());
libc::waitid( let mut line = String::new();
libc::P_PID, tokio::io::AsyncBufReadExt::read_line(&mut output, &mut line)
descendant as u32, .await
&mut info, .unwrap();
libc::WEXITED | libc::WNOWAIT, let descendant: i32 = line.trim().parse().unwrap();
) let descendant_guard = DescendantGuard(descendant);
}, drop(child.stdin.take());
0 child.wait().await.unwrap();
); let ProjectCommandTree::Group { pid, .. } = &tree;
let zombie_result = tree.after_main_exit(&mut child).await; let error = tree
assert_eq!( .request_owned_group_termination()
unsafe { libc::waitpid(descendant, std::ptr::null_mut(), 0) }, .expect_err("存活成员缺少 leader 身份时必须拒绝发送信号");
descendant assert!(error.contains("leader 身份未确认"), "{error}");
); let error = wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
let error = live_result.expect_err("存活成员缺少 leader 身份时必须拒绝清理"); .await
assert!(error.contains("leader 身份未确认"), "{error}"); .expect_err("持续存活成员必须在预算用尽时失败");
zombie_result.expect("已回收 leader 的进程组只剩僵尸时不应要求人工核对"); assert!(error.contains("仍有存活成员"), "{error}");
tree.after_main_exit(&mut child).await.unwrap(); {
let cleanup = async {
if terminate {
tree.terminate(&mut child).await.map(|_| ())
} else {
tree.after_main_exit(&mut child).await
}
};
tokio::pin!(cleanup);
// 先 poll 生产清理,确认它确实等待,才让后代进入僵尸态。
tokio::select! {
biased;
result = &mut cleanup => panic!("后代仍存活时提前结束清理:{result:?}"),
_ = tokio::task::yield_now() => {}
}
assert_eq!(unsafe { libc::kill(descendant, libc::SIGKILL) }, 0);
let mut info = unsafe { std::mem::zeroed::<libc::siginfo_t>() };
assert_eq!(
unsafe {
libc::waitid(
libc::P_PID,
descendant as u32,
&mut info,
libc::WEXITED | libc::WNOWAIT,
)
},
0
);
cleanup.await.expect("leader 消失后,组成员停止应完成清理");
}
tree.after_main_exit(&mut child)
.await
.expect("僵尸不应阻止完成");
wait_linux_project_command_group_exit(*pid, tokio::time::Instant::now())
.await
.expect("无活成员时不消耗等待预算");
drop(descendant_guard);
tree.after_main_exit(&mut child).await.unwrap();
}
} }
#[test] #[test]
@@ -3426,6 +3521,9 @@ raise SystemExit(code)'
.expect("run timeout test"); .expect("run timeout test");
assert!(timed_out.timed_out); assert!(timed_out.timed_out);
assert_eq!(timed_out.status, "failed"); assert_eq!(timed_out.status, "failed");
#[cfg(target_os = "linux")]
assert!(timed_out.output.contains("主进程已回收"));
#[cfg(not(target_os = "linux"))]
assert!(timed_out.output.contains("请求终止受控进程组")); assert!(timed_out.output.contains("请求终止受控进程组"));
assert!(timed_out.output.contains("不等同完整 OS sandbox")); assert!(timed_out.output.contains("不等同完整 OS sandbox"));
+1
View File
@@ -23,6 +23,7 @@
- [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md) - [外部 OpenAPI 与 API Key 接入方案](./【后端架构】外部OpenAPI与APIKey接入方案-2026-06-19.md)
- [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。 - [外部 MCP 语义工具说明与参数设计](./technical/【技术方案】外部MCP语义工具说明与参数设计-2026-09-23.md):15 个新增语义工具与全部原工具并存,复用现有 External API;包含工具说明、action、参数、幂等和兼容合同。
- [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。 - [External v1 OpenAPI](./openapi/genarrative-external-v1.openapi.json):公开 HTTP 契约唯一机器可读来源。
- [External v1 游戏场景生成路由](./technical/【技术方案】ExternalV1游戏场景生成路由-2026-09-24.md):external v1 结构化场景生成专用路由与 AGC 美术包背景阶段迁移合同。
## AI 游戏创作与 Agent Runtime ## AI 游戏创作与 Agent Runtime
@@ -1052,7 +1052,60 @@
"$ref": "#/components/responses/UpstreamError" "$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": { "/api/external/v1/editor/images/edits": {
@@ -2965,6 +3018,69 @@
} }
} }
}, },
"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",
"description": "场景配方由服务端重建;仅保留 source 精确等于 ai-game-creator-client 的客户端来源标记,用于选择 AGC 队列结果与幂等命名空间。调用方 fields、action 和引用 provenance 不会覆盖服务端配方。"
},
"assetFolderId": {
"type": ["string", "null"]
},
"assetLabel": {
"type": ["string", "null"],
"description": "省略或纯空白时统一使用「游戏场景」。"
},
"canvasCompletion": {
"anyOf": [
{
"$ref": "#/components/schemas/EditorCanvasGenerationCompletion"
},
{
"type": "null"
}
]
}
}
},
"EditorImageGenerationRequest": { "EditorImageGenerationRequest": {
"type": "object", "type": "object",
"required": ["prompt"], "required": ["prompt"],
@@ -0,0 +1,43 @@
# 【实施计划】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);站内入口补齐现有 AGC 账号模式的幂等与结果协议。
## 实现顺序
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 检查。
8. 审查修复:站内场景 handler 读取并透传可选幂等键;共享组装只保留精确 AGC 来源标记。增加非法键路由测试与场景 payload 到队列可下载结果的回归测试,同步 OpenAPI 元数据说明。
## 验证命令
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 暴露重放异常,停止合并并记录。
@@ -0,0 +1,46 @@
# 【里程碑】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`:背景阶段请求构造、保留账本与身份常量迁移。
## 验收标准
- [x] 账号场景入口透传可选幂等键、入队前拒绝非法键;场景配方保留 AGC 来源标记且队列完成结果包含可下载资源(路由校验及组装到结果序列化的定向测试)。
- [ ] 新路由契约测试通过:路由矩阵与鉴权、参数 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、幂等重放、参数失败关闭。
@@ -2884,7 +2884,7 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在
## 2026-06-22 编辑器生成扣费与新用户赠送收口 ## 2026-06-22 编辑器生成扣费与新用户赠送收口
- 背景:画板多个生成按钮已经展示泥点消耗,但部分图片、图标、UI 提取、视频、角色动作或音频链路只校验 / 展示价格,没有统一进入钱包预扣;新用户注册送泥点也需要与当前生成价格匹配。 - 背景:画板多个生成按钮已经展示泥点消耗,但部分图片、图标、UI 提取、视频、角色动作或音频链路只校验 / 展示价格,没有统一进入钱包预扣;新用户注册送泥点也需要与当前生成价格匹配。
- 决策:编辑器所有外部生成入口不再从前端请求接收 `priceMudPoints`,后端按运行时模型定价配置计算价格后统一进入 `execute_billable_asset_operation_with_cost` 或等价音频发布扣费链路;角色动作和视频使用真实登录用户作为扣费 owner。新用户注册赠送固定为 `100` 泥点。 - 决策:编辑器所有外部生成入口不再从前端请求接收 `priceMudPoints`,后端按运行时模型定价配置计算价格后统一进入 `execute_billable_asset_operation_with_cost` 或等价音频发布扣费链路;角色动作和视频使用真实登录用户作为扣费 owner。~~新用户注册赠送固定为 `100` 泥点。~~(2026-09-24 更正:注册赠送金额不是固定值,由线上 `profile_wallet_config.initial_mud_points` 配置决定,后台通过 `/admin/api/profile/wallet-config` 与钱包配置页随时调整;代码内常量仅为未写入配置时的兜底默认,本地编译行为不代表线上实际赠送金额,线上数值以配置表当前值为准。契约说明见 [`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`](../../【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md)。)
- 影响范围:编辑器图片 / 图片修改 / 图标 spritesheet / UI 提取 / 视频 / 角色动作 / 音频生成 BFF,前端画板生成提交模型,外部 OpenAPI,`module-runtime` 钱包注册奖励。 - 影响范围:编辑器图片 / 图片修改 / 图标 spritesheet / UI 提取 / 视频 / 角色动作 / 音频生成 BFF,前端画板生成提交模型,外部 OpenAPI,`module-runtime` 钱包注册奖励。
- 验证方式:运行编辑器图片、图标、UI 提取、视频、角色动作、音频扣费结构性测试,前端生成提交和 API client 测试,`module-runtime` 注册奖励测试。 - 验证方式:运行编辑器图片、图标、UI 提取、视频、角色动作、音频扣费结构性测试,前端生成提交和 API client 测试,`module-runtime` 注册奖励测试。
- 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`。 - 关联文档:`docs/【编辑器】模型定价配置管理方案-2026-06-22.md`、`docs/【编辑器】生成类面板Lovart统一改造方案-2026-06-17.md`、`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.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 ## 同一祖先下的多个项目会各自弹一次 UAC
- **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。 - **现象**:AGC 启动页一次挂载出现多个叠在一起的 UAC 提权弹窗;用户点「否」后仍会被再问一次。
@@ -5994,6 +6000,12 @@ Cocos Creator 根目录由 `package.json.creator.version` 与普通 `assets/`
- **验证**:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合` 与 `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。 - **验证**:宿主 `the_opening_user_item_is_emitted_before_anything_that_can_fail_in_the_turn`、前端 `本轮用户条目没到时,失败说明按身份挂回自己那一轮,本地气泡不再自成假回合` 与 `收口早退不吞掉还没写进界面的失败说明(订阅重建只回放生命周期锚点)`。
- **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。 - **关联**:`apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs`、`.../agent/direct_runtime/user_input.rs`、`.../chat/conversation/{directThreadChat.ts,directTurnPresentation.ts}`、`docs/adr/【ADR】DirectProject命令接单化-2026-09-23.md`。
## Linux command.exec 的 leader 回收与后代退出存在时序差
- bwrap 主进程已经 `wait` 回收时,namespace 后代仍可能短暂处于退出过程;一次 `/proc` 扫描发现活成员后再读取 leader 身份,会把正常退出误报为需人工核对。容器 PID 1 未回收的 `Z / X` 成员也不能当作活进程。
- 正常退出与取消/超时共用有界的组退出确认,组内无活成员立即返回;缺失或变化的 leader 身份不能授权补发信号,持续活成员或读取失败仍报错。启动身份在 ready 后、commit 前记录;terminal 协议错误也不能跳过清理与 reader 取消。
- 回归使用独立 subreaper 夹具:先 poll 清理并确认仍在等待,再让同组后代退出,覆盖正常收尾和取消/超时的共同清理路径;保持现有 CI 分片与并行,不靠取消并行或失败重试消除竞态。
## 2026-09-24 DirectProject「接单窗口里看不到自己刚发的话」是设计,不是丢消息 ## 2026-09-24 DirectProject「接单窗口里看不到自己刚发的话」是设计,不是丢消息
- **现象**:按下发送后聊天区里不会立刻出现自己那句话;宿主还在接单 / 落盘的那段时间只能看到 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段不读秒——起点要等宿主的 `turn.started.at`),滚动也停在原地。订阅重建的窗口同理。容易被读成"消息丢了 / 没发出去"。 - **现象**:按下发送后聊天区里不会立刻出现自己那句话;宿主还在接单 / 落盘的那段时间只能看到 composer 忙态、状态行与「陶泥儿正在处理」卡片(卡片这一段不读秒——起点要等宿主的 `turn.started.at`),滚动也停在原地。订阅重建的窗口同理。容易被读成"消息丢了 / 没发出去"。
@@ -582,6 +582,8 @@ V1.11 把命令安全边界从“固定 program + argv 规则 + 隔离环境变
### V1.11.1 可信 launch 握手 ### V1.11.1 可信 launch 握手
Linux 一次性命令的退出确认按进程组最终状态判断:正常退出、取消与超时在同一有界清理预算内等待组内非 `Z / X` 成员消失,空组立即返回。bwrap leader 已回收而 namespace 后代尚在退出时,不凭一次瞬时扫描报错,也不向无法确认 leader 启动身份的进程组发送信号;持续存活、不可读或归属不明仍失败关闭。启动身份在放行目标前记录,terminal 协议错误也必须完成清理并停止输出 reader。回归用独立 subreaper 夹具控制后代退出时序,保留 CI 的现有并行与分片,不增加测试失败重试。
V1.11.1 必须把 `prepared -> child-created -> sandbox-ready -> commit-persisted -> exec-established -> running/exited` 做成 launcher 状态机,不能再把 bwrap 进程 spawn 或 `--json-status-fd` 的 `child-pid` 当作 sandbox-ready。实测 `child-pid` 会在 `--block-fd` 放行前出现,此时目标程序尚未执行;它只能证明 namespace child 已创建。关闭 block writer 也不能作为 abort,因为 bwrap 会把 EOF 当作可读并继续执行,失败关闭必须显式 kill + wait/reap。 V1.11.1 必须把 `prepared -> child-created -> sandbox-ready -> commit-persisted -> exec-established -> running/exited` 做成 launcher 状态机,不能再把 bwrap 进程 spawn 或 `--json-status-fd` 的 `child-pid` 当作 sandbox-ready。实测 `child-pid` 会在 `--block-fd` 放行前出现,此时目标程序尚未执行;它只能证明 namespace child 已创建。关闭 block writer 也不能作为 abort,因为 bwrap 会把 EOF 当作可读并继续执行,失败关闭必须显式 kill + wait/reap。
- Linux 最终 `COMMAND` 必须先进入受信任 trampoline,而不是直接进入用户目标。trampoline 通过与 PTY/transcript 分离的私有控制通道发送带随机 nonce 的 `SANDBOX_READY`,等待 Runtime 完成 revision / verification gate / process record 的 durable commit 后接收 `COMMIT_EXEC`,再用 exec-error pipe 启动目标并回报 `EXEC_ESTABLISHED` 或 `TARGET_EXEC_FAILED`。commit 前的 EOF、错 nonce、协议错误和持久化失败都必须杀死并回收整个 bwrap 树,目标零执行。 - Linux 最终 `COMMAND` 必须先进入受信任 trampoline,而不是直接进入用户目标。trampoline 通过与 PTY/transcript 分离的私有控制通道发送带随机 nonce 的 `SANDBOX_READY`,等待 Runtime 完成 revision / verification gate / process record 的 durable commit 后接收 `COMMIT_EXEC`,再用 exec-error pipe 启动目标并回报 `EXEC_ESTABLISHED` 或 `TARGET_EXEC_FAILED`。commit 前的 EOF、错 nonce、协议错误和持久化失败都必须杀死并回收整个 bwrap 树,目标零执行。
@@ -0,0 +1,78 @@
# 【技术方案】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 和计费规则;补齐 AGC 账号模式所需的可选幂等键与队列结果协议。
- 不放松通用 `/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 衍生的画面内容,出图风格与比例不因迁移改变。
7. 普通账号自动映射到 `/api/editor/scenes/generations`,该入口读取可选 `Idempotency-Key` 并传给现有队列;未提供时保留站内按请求 ID 入队的行为。场景组装仅保留 `generationInputs.source = ai-game-creator-client` 这一精确标记,其余配方字段仍由服务端重建。该标记让账号任务沿用 AGC 幂等命名空间和包含可下载 `result` 的队列结果,轮询走 `/api/runtime/external-generation/jobs/{operation_id}`。
### 失败、重试与幂等
1. 缺少 `sceneContent`、非法 `stylePreset`、自定义风格缺少 `customStyle` 等参数错误返回 400,与站内路由同语义。
2. 缺少或非法幂等键、越权 scope 的拒绝语义与现役外部生成入口一致。
3. 同一幂等键 + 同一请求重放返回原任务,不新建任务、不重复扣费;同键不同请求返回 409。
4. Provider 失败、取消与 lease 耗尽沿用现有扣退费语义。
5. 账号场景入口拒绝非法幂等键(400);同键重放复用现有队列幂等实现。来源标记不参与权限授予,任务归属仍来自已认证用户。
### 权限、归属与数据边界
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`)保持可读,不做数据迁移。
## 验收标准与证据
| 条款 | 验收方式 | 证据 |
| ---- | -------- | ---- |
| 账号入口幂等键校验与 AGC 下载结果 | 非法键路由测试、场景来源到结果序列化测试、共享队列幂等命名空间测试 | `cargo test --locked -p api-server scene`(18 项)、`editor_generation_queue::tests`(19 项)、`external`(158 项)通过;本地启动因 SpacetimeDB 连接拒绝未通过健康检查,真实 Provider 出图与账号同键重放尚未联调 |
| 新路由受理/参数校验/鉴权/幂等重放 | api-server 契约测试与单测 | 待补 |
| 与站内路由同一 Prompt 组装结果 | 共享实现的单测对照 | 待补 |
| OpenAPI 与实现一致 | 契约测试 + `check:openapi` 类门禁 | 待补 |
| 美术包背景端到端(reuse-or-create / regenerate) | 客户端定向测试 + 本地真实栈 smoke | 待补 |
| 中断恢复:保留账本匹配与身份对账 | 客户端定向测试 | 待补 |
## 未决问题与决策
- `stylePreset` 取舍:已决策——AGC 美术包背景固定 `custom` + `customStyle` 承载现有风格文案,不绑定预设风格(2026-09-24,与用户确认)。
+38
View File
@@ -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] #[tokio::test]
async fn editor_scene_generation_rejects_inline_data_url_before_queueing() { async fn editor_scene_generation_rejects_inline_data_url_before_queueing() {
let state = AppState::new(AppConfig { let state = AppState::new(AppConfig {
@@ -826,6 +826,19 @@ mod tests {
editor_generation_idempotency_namespace(&game_creator), editor_generation_idempotency_namespace(&game_creator),
GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX 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!( assert_eq!(
editor_generation_idempotency_namespace(&ordinary), editor_generation_idempotency_namespace(&ordinary),
EXTERNAL_API_GENERATION_DEDUPE_PREFIX EXTERNAL_API_GENERATION_DEDUPE_PREFIX
+124 -13
View File
@@ -2658,12 +2658,23 @@ fn build_editor_scene_generation_inputs(
{ {
fields.push(json!({ "id": "customStyle", "title": "自定义画风", "value": custom_style })); fields.push(json!({ "id": "customStyle", "title": "自定义画风", "value": custom_style }));
} }
json!({ let mut inputs = json!({
"version": 2, "version": 2,
"action": "scene.generate", "action": "scene.generate",
"fields": fields, "fields": fields,
"references": references, "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 { fn normalize_editor_scene_optional_text<'a>(value: Option<&'a str>, default: &'a str) -> &'a str {
@@ -2692,13 +2703,9 @@ fn normalize_editor_scene_asset_label(asset_label: Option<String>) -> String {
resolve_editor_generated_asset_label(asset_label, "游戏场景") resolve_editor_generated_asset_label(asset_label, "游戏场景")
} }
pub async fn generate_editor_scene( pub(crate) fn build_editor_scene_image_generation_payload(
State(state): State<AppState>, payload: EditorSceneGenerateRequest,
Extension(request_context): Extension<RequestContext>, ) -> Result<EditorImageGenerationRequest, AppError> {
Extension(authenticated): Extension<AuthenticatedAccessToken>,
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, AppError> {
let Json(payload) = parse_editor_generation_json_payload(payload)?;
let generation_options = normalize_editor_scene_generation_options( let generation_options = normalize_editor_scene_generation_options(
payload.model.as_deref(), payload.model.as_deref(),
payload.aspect_ratio.as_deref(), payload.aspect_ratio.as_deref(),
@@ -2717,8 +2724,7 @@ pub async fn generate_editor_scene(
})) }))
})?; })?;
let generation_inputs = build_editor_scene_generation_inputs(&payload, &generation_options); let generation_inputs = build_editor_scene_generation_inputs(&payload, &generation_options);
let caller = EditorGenerationCaller::from_authenticated(&authenticated); Ok(EditorImageGenerationRequest {
let image_payload = EditorImageGenerationRequest {
prompt, prompt,
size: None, size: None,
kind: Some("scene".to_string()), kind: Some("scene".to_string()),
@@ -2737,14 +2743,27 @@ pub async fn generate_editor_scene(
asset_label: Some(normalize_editor_scene_asset_label(payload.asset_label)), asset_label: Some(normalize_editor_scene_asset_label(payload.asset_label)),
source_resource_id: None, source_resource_id: None,
canvas_completion: payload.canvas_completion, canvas_completion: payload.canvas_completion,
}; })
}
pub async fn generate_editor_scene(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(authenticated): Extension<AuthenticatedAccessToken>,
headers: HeaderMap,
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
) -> Result<Json<Value>, 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() { if !state.config.external_generation_mode.is_inline() {
let queue_job = enqueue_editor_image_generation_for_owner( let queue_job = enqueue_editor_image_generation_for_owner(
&state, &state,
&request_context, &request_context,
&caller, &caller,
image_payload, image_payload,
None, idempotency_key,
) )
.await?; .await?;
return Ok(json_success_body( return Ok(json_success_body(
@@ -13269,6 +13288,98 @@ mod tests {
thread, 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 scene_generation_preserves_agc_downloadable_queue_result() {
let request = serde_json::from_value::<EditorSceneGenerateRequest>(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] #[test]
fn background_removal_options_preserve_queue_parameters_and_legacy_identity() { fn background_removal_options_preserve_queue_parameters_and_legacy_identity() {
for (fields, mode, color) in [ for (fields, mode, color) in [
@@ -7,7 +7,9 @@ use axum::{
use serde::de::DeserializeOwned; use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{Value, json}; use serde_json::{Value, json};
use shared_contracts::assets::EditorCanvasGenerationCompletionPayload; use shared_contracts::assets::{
EditorCanvasGenerationCompletionPayload, EditorSceneGenerateRequest,
};
use shared_contracts::external_generation::{ use shared_contracts::external_generation::{
ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse, ExternalEditorGenerationJobResponse, ExternalEditorGenerationSubmissionResponse,
ExternalGenerationJobStatus, ExternalGenerationJobStatus,
@@ -38,12 +40,13 @@ use crate::{
EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest, EditorCanvasViewportPayload, EditorGenerationCaller, EditorImageEditRequest,
EditorImageGenerationRequest, EditorProjectListQuery, EditorProjectListView, EditorImageGenerationRequest, EditorProjectListQuery, EditorProjectListView,
EditorProjectPayload, EditorProjectResourcePayload, EditorProjectSummaryListResponse, EditorProjectPayload, EditorProjectResourcePayload, EditorProjectSummaryListResponse,
EditorUiDesignAssetExtractionRequest, current_utc_micros, EditorUiDesignAssetExtractionRequest, build_editor_scene_image_generation_payload,
editor_asset_folder_payload_from_record, editor_asset_library_payload_from_record, current_utc_micros, editor_asset_folder_payload_from_record,
editor_asset_payload_from_record, editor_idempotent_create_id, editor_asset_library_payload_from_record, editor_asset_payload_from_record,
editor_project_payload_from_record, editor_project_resource_payload_from_record, editor_idempotent_create_id, editor_project_payload_from_record,
editor_project_summary_from_record, enqueue_editor_background_removal_for_owner, editor_project_resource_payload_from_record, editor_project_summary_from_record,
enqueue_editor_image_edit_for_owner, enqueue_editor_image_generation_for_owner, 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, enqueue_editor_ui_design_asset_extraction_for_owner,
ensure_generic_editor_image_generation_contract, map_editor_project_error, ensure_generic_editor_image_generation_contract, map_editor_project_error,
normalize_editor_persisted_media_src, normalize_optional_string, 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)) Ok(external_generation_accepted_response(&request_context, job))
} }
pub async fn generate_external_editor_scene(
State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>,
Extension(principal): Extension<ExternalApiPrincipal>,
headers: HeaderMap,
payload: Result<Json<EditorSceneGenerateRequest>, JsonRejection>,
) -> Result<Response, AppError> {
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( pub async fn edit_external_editor_image(
State(state): State<AppState>, State(state): State<AppState>,
Extension(request_context): Extension<RequestContext>, Extension(request_context): Extension<RequestContext>,
@@ -1792,6 +1818,61 @@ mod tests {
.await; .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] #[tokio::test]
async fn external_background_removal_rejects_invalid_parameters_before_queueing() { async fn external_background_removal_rejects_invalid_parameters_before_queueing() {
let state = AppState::new(crate::config::AppConfig::default()) 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] #[test]
fn exported_openapi_json_contains_external_editor_routes_and_security() { fn exported_openapi_json_contains_external_editor_routes_and_security() {
let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse"); let parsed: Value = serde_json::from_str(OPENAPI_JSON).expect("openapi json should parse");
@@ -19,12 +19,12 @@ use crate::{
delete_external_editor_project, edit_external_editor_image, delete_external_editor_project, edit_external_editor_image,
extract_external_editor_ui_design_assets, generate_external_editor_background_music, extract_external_editor_ui_design_assets, generate_external_editor_background_music,
generate_external_editor_character_animation, generate_external_editor_icon_spritesheet, generate_external_editor_character_animation, generate_external_editor_icon_spritesheet,
generate_external_editor_image, generate_external_editor_sound_effect, generate_external_editor_image, generate_external_editor_scene,
generate_external_editor_video, get_external_editor_asset_library, generate_external_editor_sound_effect, generate_external_editor_video,
get_external_editor_generation_job, get_external_editor_project, get_external_editor_asset_library, get_external_editor_generation_job,
list_external_editor_projects, load_recent_external_editor_project, openapi_json, get_external_editor_project, list_external_editor_projects,
remove_external_editor_image_background, rename_external_editor_project, load_recent_external_editor_project, openapi_json, remove_external_editor_image_background,
save_external_editor_canvas, update_external_editor_asset, rename_external_editor_project, save_external_editor_canvas, update_external_editor_asset,
update_external_editor_asset_folder, update_external_editor_asset_folder,
}, },
external_mcp, external_mcp,
@@ -111,6 +111,10 @@ pub fn router(state: AppState) -> Router<AppState> {
"/api/external/v1/editor/images/generations", "/api/external/v1/editor/images/generations",
post(generate_external_editor_image), post(generate_external_editor_image),
), ),
(
"/api/external/v1/editor/scenes/generations",
post(generate_external_editor_scene),
),
( (
"/api/external/v1/editor/images/edits", "/api/external/v1/editor/images/edits",
post(edit_external_editor_image), post(edit_external_editor_image),
@@ -257,6 +261,7 @@ mod route_contract_tests {
), ),
("/api/external/v1/generations/{operation_id}", &["GET"]), ("/api/external/v1/generations/{operation_id}", &["GET"]),
("/api/external/v1/editor/images/generations", &["POST"]), ("/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/edits", &["POST"]),
( (
"/api/external/v1/editor/images/background-removals", "/api/external/v1/editor/images/background-removals",
@@ -208,9 +208,6 @@ describe('CreationLandingView', () => {
expect(subtitle.textContent).toContain('美术 Agent'); expect(subtitle.textContent).toContain('美术 Agent');
expect(subtitle.textContent).toContain('无限画布'); expect(subtitle.textContent).toContain('无限画布');
expect(subtitle.textContent).toContain('角色、场景、UI 与宣发素材'); expect(subtitle.textContent).toContain('角色、场景、UI 与宣发素材');
expect(
screen.getByText('登录即送 100 泥点,可以免费制作 50 个素材'),
).toBeTruthy();
expect( expect(
screen.getByRole('heading', { screen.getByRole('heading', {
level: 2, level: 2,
@@ -1418,9 +1418,6 @@ export function CreationLandingView({
<br /> <br />
用美术 Agent 与无限画布,快速制作角色、场景、UI 与宣发素材。 用美术 Agent 与无限画布,快速制作角色、场景、UI 与宣发素材。
</p> </p>
<p className="creation-landing__hero-benefit">
登录即送 100 泥点,可以免费制作 50 个素材
</p>
</div> </div>
<div className="creation-landing__hero-actions"> <div className="creation-landing__hero-actions">
<PlatformActionButton <PlatformActionButton
+1 -15
View File
@@ -2945,8 +2945,7 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
gap: 0.42rem; gap: 0.42rem;
} }
.creation-landing__hero-subtitle, .creation-landing__hero-subtitle {
.creation-landing__hero-benefit {
margin: 0; margin: 0;
color: #8a7466; color: #8a7466;
text-indent: 0; text-indent: 0;
@@ -2959,14 +2958,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
line-height: 1.7; line-height: 1.7;
} }
.creation-landing__hero-benefit {
color: #9a6f56;
font-size: 16px;
font-weight: 680;
letter-spacing: 0.08em;
line-height: 1.55;
}
.creation-landing__hero-actions { .creation-landing__hero-actions {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -3934,11 +3925,6 @@ html[data-mobile-keyboard-open='true'] .platform-mobile-bottom-dock {
line-height: 1.62; line-height: 1.62;
} }
.creation-landing__hero-benefit {
font-size: 0.8rem;
letter-spacing: 0.04em;
}
.creation-landing__hero-actions { .creation-landing__hero-actions {
gap: 0.72rem; gap: 0.72rem;
} }