From dae59992b16a0cbf0f55b82b7ec25a47c66c27db Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Wed, 29 Jul 2026 11:54:06 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E5=BC=80=E5=8F=91=E6=80=81?= =?UTF-8?q?=E6=B8=B8=E6=88=8F=E8=81=8A=E5=A4=A9=E5=88=9B=E4=BD=9C=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增仅开发态游戏聊天入口与本地安全预览布局 将Supervisor进度、返工、测试、迭代和图片证据写入聊天 完善自动预览授权持久化、锁竞争重试与Runtime收尾恢复 规范视觉产物走规范图、UI设计和透明图集专用接口 补齐共享契约、回归测试、技术方案和项目记忆 --- .../genarrative-external-editor-api/SKILL.md | 26 + .../references/api-selection.md | 12 +- .../scripts/genarrative_external_api.py | 34 + apps/ai-game-creator-shell/package.json | 1 + .../src-tauri/Cargo.lock | 1 + .../src-tauri/Cargo.toml | 1 + .../src/agent/generation/canvas_generation.rs | 315 ++- .../src-tauri/src/agent/prompt.rs | 82 +- .../src-tauri/src/agent/runtime_actions.rs | 3 + .../src/agent/runtime_actions/action_audit.rs | 76 +- .../runtime_actions/autonomous_policy.rs | 60 +- .../agent/runtime_actions/project_gates.rs | 40 +- .../runtime_actions/provider_tool_plan.rs | 8 +- .../runtime_actions/response_stream_tests.rs | 95 + .../runtime_actions/tool_policy_snapshot.rs | 5 +- .../src-tauri/src/agent/runtime_driver.rs | 12 +- .../src/agent/runtime_driver/main_loop.rs | 31 +- .../agent/runtime_driver/main_loop_tests.rs | 42 +- .../agent/runtime_driver/provider_recovery.rs | 7 + .../src/agent/runtime_driver/recovery_scan.rs | 269 +++ .../src/agent/runtime_driver/task_start.rs | 35 +- .../src-tauri/src/agent/runtime_protocol.rs | 1 + .../autonomous_completion_contract_tests.rs | 83 +- .../agent/runtime_protocol/finalization.rs | 83 + .../agent/runtime_protocol/response_stream.rs | 27 + .../src/agent/runtime_protocol/steering.rs | 11 +- .../src-tauri/src/agent/runtime_tools.rs | 3 +- .../src/agent/runtime_tools/file_ops.rs | 11 +- .../src/agent/runtime_tools/media.rs | 61 +- .../src/agent/runtime_tools/policy.rs | 14 +- .../src-tauri/src/agent_native_tools.rs | 4 +- .../src-tauri/src/assets.rs | 12 + .../src-tauri/src/cli.rs | 8 +- .../src-tauri/src/collaboration.rs | 120 +- .../src-tauri/src/commands.rs | 3 + .../src-tauri/src/main.rs | 23 +- .../src-tauri/src/project/manifest.rs | 115 +- .../src/tests/collaboration/delegation.rs | 35 +- .../src/tests/collaboration/recovery.rs | 6 + .../tests/collaboration/static_deliveries.rs | 59 +- .../collaboration/supervisor_planning.rs | 180 +- .../src-tauri/src/tests/mod.rs | 135 +- .../src-tauri/src/tests/project.rs | 372 ++- .../src-tauri/src/tests/project_tools.rs | 65 + .../src-tauri/src/tests/provider.rs | 8 +- .../src-tauri/src/tests/response_stream.rs | 66 +- .../tests/runtime_actions/action_execution.rs | 81 +- .../planning_strategy/autonomous_build.rs | 18 +- .../src/tests/runtime_actions/support.rs | 14 +- .../src-tauri/src/tests/runtime_state.rs | 22 +- .../src-tauri/src/windows.rs | 98 + apps/ai-game-creator-shell/src/App.tsx | 705 +++++- .../src/features/agent-runtime/model.ts | 15 +- .../project-workspace/GameChatImageViewer.tsx | 192 ++ .../LocalGamePreviewFrame.tsx | 48 + .../SupervisorChatOnlyView.tsx | 1078 +++++++-- apps/ai-game-creator-shell/src/main.tsx | 23 +- apps/ai-game-creator-shell/src/styles.css | 491 +++- .../src/view/project-development/index.tsx | 27 +- .../appSurface/project-development.suite.ts | 2009 ++++++++++++++++- .../assert-planning-and-status-shortcuts.ts | 4 +- .../shared-memory/decision-log.md | 55 +- docs/project-memory/shared-memory/pitfalls.md | 13 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 28 +- package.json | 1 + .../src/contracts/gameCreationApp.test.ts | 17 +- .../shared/src/contracts/gameCreationApp.ts | 34 +- .../shared-contracts/src/game_creation_app.rs | 61 +- 68 files changed, 7146 insertions(+), 548 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/GameChatImageViewer.tsx create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx diff --git a/.codex/skills/genarrative-external-editor-api/SKILL.md b/.codex/skills/genarrative-external-editor-api/SKILL.md index ac684ddf7..9e9080df7 100644 --- a/.codex/skills/genarrative-external-editor-api/SKILL.md +++ b/.codex/skills/genarrative-external-editor-api/SKILL.md @@ -142,6 +142,20 @@ client.generate_image( ) ``` +For a transparent game/UI atlas, call the dedicated helper instead of ordinary image generation: + +```python +client.generate_icon_spritesheet( + "editor-resource-current-art-spec", + ["蛇头四方向", "直身与四种转角", "尾部四方向", "四类可区分食物"], + canvasSession=session, + assetLabel="贪吃蛇透明图集", + screenColor="auto", +) +``` + +Pass the registered visual-spec resource ID as `reference_image_src`; do not pass the UI prototype or a local path. + Use the helper directly from this skill path, or copy it into the caller's project. Do not change the fixed base URL or move the API Key into environment variables. Use this shared base: @@ -326,6 +340,18 @@ Character image generation (including character redraw through `kind: "character - `sliceWarning` is a separate condition used only when transparent spritesheet post-processing succeeded but automatic slicing failed. Keep `sliceWarning.reason` as the original diagnostic and continue using the complete transparent spritesheet; a UI may add context when displaying it, but must not rewrite the stored reason. - The service contract keeps `warning` and `sliceWarning` mutually exclusive. As defensive handling for a malformed response containing both, treat the general `warning` as authoritative and do not misclassify the source-preserved result as a slicing-only warning. +For reusable transparent game/UI sheets, do not substitute ordinary image generation merely because it can draw several objects in one image. Use icon spritesheet generation when a stable visual-spec reference and `iconDescriptions` exist; use UI extraction only for an existing annotated UI design. Pass `screenColor: "auto"` unless the art direction requires one of the supported solid chroma colors. A client must verify the returned full sheet really contains transparency before treating it as a transparent spritesheet. If a source-preserved `warning` is present, do not register the opaque provider source as the requested transparent deliverable. When only `sliceWarning` is present, the full transparent sheet remains usable, but no individual slices may be claimed. + +## AI Game Creator Canonical Visual DAG + +The AI game creator reuses its existing 16-task manifest; do not add a parallel task system or collapse the following artifacts into one ordinary generation request: + +1. `art-director` generates `assets/art-spec.png` with `POST /api/external/v1/editor/images/generations`, `kind: "spec"`, and registers it as `assetKind: "icon-spec"`. This is the real visual-spec image. The JSON value in `generationInputs.artSpec` is supporting structured context and does not replace this image. +2. `design-foundation` uses the registered External Editor resource ID for `assets/art-spec.png` in `referenceImageSrcs`, then generates the complete `assets/ui-prototype.png` through `POST /api/external/v1/editor/images/generations` with `kind: "ui-design"`. +3. `art-asset-plan` uses the same registered `assets/art-spec.png` resource ID as the required `referenceImageSrc` for `POST /api/external/v1/editor/icon-spritesheets/generations`, supplies concrete `iconDescriptions`, and registers the transparent full result as `assets/art-spritesheet.png`. + +Never use `assets/ui-prototype.png` as the icon spritesheet's visual-spec reference. `POST /api/external/v1/editor/ui-designs/assets/extractions` requires an existing UI design image with red-box annotations; it is not UI generation and is not part of this canonical DAG. + ## Guardrails - Do not invent endpoints outside the OpenAPI, especially internal worker or runtime task-list routes. diff --git a/.codex/skills/genarrative-external-editor-api/references/api-selection.md b/.codex/skills/genarrative-external-editor-api/references/api-selection.md index 77f7269e9..18b62f5e1 100644 --- a/.codex/skills/genarrative-external-editor-api/references/api-selection.md +++ b/.codex/skills/genarrative-external-editor-api/references/api-selection.md @@ -20,6 +20,14 @@ At the start of a new conversation, ask for a canvas name before the first gener Before generating art assets, normalize the user's request into a current art spec with `assetType`, `subject`, `style`, `palette`, `composition`, `format`, `constraints`, and `references`. Ask follow-up questions only for missing fields that block the selected endpoint. Reuse the current spec automatically when the user asks for another asset without changing style/spec requirements. Put the spec in `generationInputs.artSpec` and summarize it in the prompt when useful. +For the AI game creator's existing 16-task autonomous build, distinguish that JSON art spec from the required visual-spec image and keep this dependency chain: + +1. `art-director` -> `assets/art-spec.png` via `POST /api/external/v1/editor/images/generations`, with `kind=spec` and registered `assetKind=icon-spec`. +2. `design-foundation` -> `assets/ui-prototype.png` via the same image generation endpoint with `kind=ui-design`, using the registered art-spec resource ID in `referenceImageSrcs`. +3. `art-asset-plan` -> transparent `assets/art-spritesheet.png` via `POST /api/external/v1/editor/icon-spritesheets/generations`, using the registered art-spec resource ID as `referenceImageSrc` and providing `iconDescriptions`. + +Do not use the UI prototype as the spritesheet specification. UI extraction requires a stable source image with red-box annotations and is outside this canonical DAG. + ## Intent Routing Infer the endpoint from the user's description. Do not present this as a menu unless the request is genuinely ambiguous. @@ -69,8 +77,8 @@ Ask a follow-up only when two routes could both be correct and produce different | --- | --- | --- | --- | | Generate image/spec/character/UI/publication material | `POST /api/external/v1/editor/images/generations` | `prompt` | `kind`, `model`, `aspectRatio`, `imageSize`, `size`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | | Edit/redraw image | `POST /api/external/v1/editor/images/edits` | `prompt`, `sourceImageSrc` | `referenceImageSrcs`, `model`, `size`, `projectId`, `assetFolderId`, `assetLabel`, `sourceResourceId`, `targetLayerId`, `canvasCompletion` | -| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `canvasCompletion` | -| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | +| Generate icon spritesheet | `POST /api/external/v1/editor/icon-spritesheets/generations` | `referenceImageSrc`, `iconDescriptions` | `referenceImageSrcs`, `screenColor`, `model`, `aspectRatio`, `imageSize`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | +| Extract assets from UI design | `POST /api/external/v1/editor/ui-designs/assets/extractions` | `sourceImageSrc`, `aspectRatio`, `imageSize` | `screenColor`, `model`, `referenceImageSrcs`, `projectId`, `assetFolderId`, `spritesheetLabel`, `canvasCompletion` | | Generate character animation | `POST /api/external/v1/editor/character-animations/generations` | `sourceLayerId`, `sourceImageSrc`, `sourceWidth`, `sourceHeight`, `promptText`, `resolution`, `ratio`, `frameCount`, `durationSeconds`, `model` | `projectId`, `sourceResourceId`, `canvasCompletion`; then create a library asset from the first returned frame | | Generate video | `POST /api/external/v1/editor/videos/generations` | `prompt`, `model`, `aspectRatio`, `durationSeconds`, `resolution`, `mode`, `sound` | `referenceImageSrcs`, `referenceVideoSrcs`, `referenceAudioSrcs`, `webSearchEnabled`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion` | | Generate sound effect | `POST /api/external/v1/editor/audios/sound-effects/generations` | `prompt`, `duration` | `model`, `projectId`, `assetFolderId`, `assetLabel`, `canvasCompletion`, `generationInputs` | diff --git a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py index 6998db36c..23f53c526 100644 --- a/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py +++ b/.codex/skills/genarrative-external-editor-api/scripts/genarrative_external_api.py @@ -450,6 +450,29 @@ class GenarrativeExternalClient: timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, ) + def generate_icon_spritesheet( + self, + reference_image_src: str, + icon_descriptions: list[str], + **fields: Any, + ) -> Any: + descriptions = [item.strip() for item in icon_descriptions if item.strip()] + if not descriptions: + raise GenarrativeApiError("icon_descriptions must contain at least one non-empty item") + label = fields.get("assetLabel", "图标图集") + self._apply_canvas_session_fields(fields, label, 1024, 1024) + fields.setdefault("screenColor", "auto") + return self.request_json( + "POST", + "/api/external/v1/editor/icon-spritesheets/generations", + { + "referenceImageSrc": reference_image_src, + "iconDescriptions": descriptions, + **fields, + }, + timeout=GENERATION_REQUEST_TIMEOUT_SECONDS, + ) + def extract_ui_assets(self, source_image_src: str, image_size: str = "1K", **fields: Any) -> Any: fields.pop("aspectRatio", None) self._apply_canvas_session_fields(fields, fields.get("spritesheetLabel", "UI 素材拆分"), 1024, 1024, "spritesheetLabel") @@ -619,6 +642,17 @@ def _self_test() -> None: assert calls[0]["body"]["canvasCompletion"]["title"] == "角色呼吸动画" assert calls[1]["path"] == "/api/external/v1/editor/assets" assert result["asset"]["assetId"] == "editor-asset-demo" + calls.clear() + client.generate_icon_spritesheet( + "editor-resource-spec", + ["蛇头向上", "蛇身直线", "转角", "尾部", "四类食物"], + canvasSession=session, + assetLabel="贪吃蛇透明图集", + ) + assert calls[0]["path"] == "/api/external/v1/editor/icon-spritesheets/generations" + assert calls[0]["body"]["referenceImageSrc"] == "editor-resource-spec" + assert calls[0]["body"]["screenColor"] == "auto" + assert calls[0]["body"]["iconDescriptions"][0] == "蛇头向上" print("self-test ok") diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index c79223d53..e99bc18c3 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "npm --prefix ../.. exec tauri -- dev", + "game-chat": "npm --prefix ../.. exec tauri -- dev -- -- --game-chat", "dev-server": "node scripts/start-dev-server.mjs", "dev-stack": "node scripts/start-dev-stack.mjs", "build": "npm --prefix ../.. exec tauri -- build", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 3cf4feb64..de032a52b 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1476,6 +1476,7 @@ dependencies = [ "chromiumoxide", "futures", "http", + "image", "libc", "platform-agent", "platform-llm", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 47ef56504..e79109f5c 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -12,6 +12,7 @@ base64 = "0.22" chromiumoxide = "0.9.1" futures = "0.3" http = "1" +image = { version = "0.25", default-features = false, features = ["png"] } rmcp = { version = "2.2.0", default-features = false, features = ["client", "reqwest-native-tls", "transport-child-process", "transport-streamable-http-client-reqwest"] } serde = { version = "1", features = ["derive"] } serde_json = "1" 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 47d288234..d2dc1f727 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 @@ -405,9 +405,88 @@ pub(in crate::agent) struct PreparedPlatformArtAssetGeneration { generated_prompt: Option, model: Option, provider: Option, + slice_warning: Option, + generation_route: String, + generation_kind: String, + reference_resource_ids: Vec, extension: String, } +fn canonical_art_spec_reference_at( + root: &Path, + expected_canvas_project_id: &str, +) -> Result { + let manifest = read_manifest_for_project(root)?; + let source = manifest + .assets + .iter() + .find(|asset| { + asset.local_path == AGENT_RUNTIME_ART_SPEC_PATH + && asset.kind == "icon-spec" + && asset.media_type.starts_with("image/") + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + }) + .ok_or_else(|| { + "派生视觉资产需要先完成并登记 assets/art-spec.png;请等待 art-director 后重试" + .to_string() + })?; + let source_path = resolve_local_project_path(root, &source.local_path)?; + if !source_path.is_file() { + return Err( + "派生视觉资产的规范图 assets/art-spec.png 不存在;请等待 art-director 后重试" + .to_string(), + ); + } + if source.source.canvas_project_id.as_deref() != Some(expected_canvas_project_id) { + return Err( + "assets/art-spec.png 不属于当前 External Editor 画布项目,不能作为派生视觉资产参考源" + .to_string(), + ); + } + source + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .ok_or_else(|| { + "assets/art-spec.png 缺少 External Editor 项目资源 ID,不能伪造规范图引用".to_string() + }) +} + +fn canonical_art_spritesheet_icon_descriptions(prompt: &str) -> Vec { + let project_context = truncate_inline(prompt.trim(), 320); + [ + "当前玩法的玩家主体、朝向或动作状态与可组合部件", + "当前玩法的目标物、收集物、敌对实体或危险物", + "当前玩法需要的地块、障碍、资源物件与场景装饰", + "得分、受击、成长、失败、胜利与操作反馈特效", + ] + .into_iter() + .map(|category| format!("{category};遵循同一项目视觉规范:{project_context}")) + .collect() +} + +fn platform_art_spritesheet_has_transparent_pixels(download: &CanvasResourceDownload) -> bool { + image::load_from_memory(&download.bytes) + .ok() + .map(|image| image.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX)) + .unwrap_or(false) +} + +fn platform_art_generation_postprocess_failure(generated: &serde_json::Value) -> Option { + let warning = generated + .get("warning") + .filter(|warning| !warning.is_null())?; + let code = json_string_field(warning, "code").unwrap_or_else(|| "unknown".to_string()); + let reason = json_string_field(warning, "reason") + .unwrap_or_else(|| "透明背景后处理未生成可用衍生物".to_string()); + Some(format!( + "平台图片生成完成但透明后处理失败({code}):{reason};provider 源图已由服务端保留,不得登记为透明图集或自动重试" + )) +} + pub(in crate::agent) async fn generate_platform_art_asset_with_options_at( root: &Path, prompt: &str, @@ -444,34 +523,69 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( let canvas_context = prepare_external_canvas_generation_context(root, &client, &api_base_url, &api_key).await?; let generation_prompt = build_platform_art_asset_prompt(prompt, briefs, options); - let generation_kind = if options.asset_kind == "ui-prototype" { - "ui-design" + let generation_kind = match options.asset_kind.as_str() { + "ui-prototype" => "ui-design", + "art-spritesheet" => "icon-spritesheet", + _ => "spec", + }; + let is_canonical_art_spritesheet = options.asset_kind == "art-spritesheet"; + let canonical_reference = matches!( + options.asset_kind.as_str(), + "ui-prototype" | "art-spritesheet" + ) + .then(|| canonical_art_spec_reference_at(root, &canvas_context.project_id)) + .transpose()?; + let (endpoint, request_body) = if is_canonical_art_spritesheet { + let reference_image_src = canonical_reference + .as_deref() + .ok_or_else(|| "透明美术图集缺少规范图引用".to_string())?; + ( + "/api/external/v1/editor/icon-spritesheets/generations", + serde_json::json!({ + "referenceImageSrc": reference_image_src, + "iconDescriptions": canonical_art_spritesheet_icon_descriptions(&generation_prompt), + "screenColor": "auto", + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + ) } else { - "spec" + ( + "/api/external/v1/editor/images/generations", + serde_json::json!({ + "prompt": generation_prompt, + "kind": generation_kind, + "aspectRatio": options.aspect_ratio, + "imageSize": options.image_size, + "assetKind": options.asset_kind, + "assetLabel": options.asset_label, + "projectId": canvas_context.project_id, + "assetFolderId": canvas_context.asset_folder_id, + "generationInputs": { + "artSpec": platform_art_asset_art_spec(options), + }, + "referenceImageSrcs": canonical_reference.clone().into_iter().collect::>(), + "canvasCompletion": { + "title": options.asset_label, + "placeholder": external_canvas_placeholder(&options.aspect_ratio), + }, + }), + ) }; let response = client - .post(format!( - "{}/api/external/v1/editor/images/generations", - api_base_url - )) + .post(format!("{api_base_url}{endpoint}")) .bearer_auth(&api_key) - .json(&serde_json::json!({ - "prompt": generation_prompt, - "kind": generation_kind, - "aspectRatio": options.aspect_ratio, - "imageSize": options.image_size, - "assetKind": options.asset_kind, - "assetLabel": options.asset_label, - "projectId": canvas_context.project_id, - "assetFolderId": canvas_context.asset_folder_id, - "generationInputs": { - "artSpec": platform_art_asset_art_spec(options), - }, - "canvasCompletion": { - "title": options.asset_label, - "placeholder": external_canvas_placeholder(&options.aspect_ratio), - }, - })) + .json(&request_body) .send() .await .map_err(|error| format!("请求平台图片生成失败:{error}"))?; @@ -484,12 +598,39 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( .await .map_err(|error| format!("解析平台图片生成响应失败:{error}"))?; let generated = payload.get("data").unwrap_or(&payload); - let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, generated) - .await? - .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; + if let Some(error) = platform_art_generation_postprocess_failure(generated) { + return Err(error); + } let null = serde_json::Value::Null; - let resource = generated.get("resource").unwrap_or(&null); - let asset = generated.get("asset").unwrap_or(&null); + let resource = if is_canonical_art_spritesheet { + generated.get("spritesheetResource").unwrap_or(&null) + } else { + generated.get("resource").unwrap_or(&null) + }; + let asset = if is_canonical_art_spritesheet { + generated.get("spritesheetAsset").unwrap_or(&null) + } else { + generated.get("asset").unwrap_or(&null) + }; + let download_source = if resource.is_object() { + resource + } else { + generated + }; + let download = + resolve_canvas_resource_download(&client, &api_base_url, &api_key, download_source) + .await? + .ok_or_else(|| "平台图片生成响应缺少可下载图片".to_string())?; + if is_canonical_art_spritesheet && !platform_art_spritesheet_has_transparent_pixels(&download) { + return Err( + "External Editor 返回的 art-spritesheet 没有真实透明像素,已拒绝把不透明源图登记为正式图集" + .to_string(), + ); + } + let slice_warning = generated + .get("sliceWarning") + .filter(|warning| !warning.is_null()) + .and_then(|warning| json_string_field(warning, "reason")); let resource_id = json_string_field(resource, "resourceId"); let task_id = json_string_field(generated, "taskId").or_else(|| json_string_field(resource, "taskId")); @@ -507,8 +648,11 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( json_string_field(generated, "model").or_else(|| json_string_field(resource, "model")); let provider = json_string_field(generated, "provider") .or_else(|| json_string_field(resource, "provider")); - let source_hint = json_string_field(generated, "objectKey") - .or_else(|| json_string_field(generated, "imageSrc")); + let source_hint = json_string_field(resource, "objectKey") + .or_else(|| json_string_field(resource, "imageSrc")) + .or_else(|| json_string_field(generated, "objectKey")) + .or_else(|| json_string_field(generated, "imageSrc")) + .or_else(|| json_string_field(generated, "spritesheetImageSrc")); let extension = infer_file_extension(source_hint.as_deref(), &download.media_type).to_string(); Ok(PreparedPlatformArtAssetGeneration { requested_output_path, @@ -522,6 +666,10 @@ pub(in crate::agent) async fn request_platform_art_asset_with_options_at( generated_prompt, model, provider, + slice_warning, + generation_route: endpoint.to_string(), + generation_kind: generation_kind.to_string(), + reference_resource_ids: canonical_reference.into_iter().collect(), extension, }) } @@ -594,6 +742,10 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( generated_prompt, model, provider, + slice_warning, + generation_route, + generation_kind, + reference_resource_ids, extension, } = prepared; let file_stem = resource_id @@ -743,6 +895,9 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( task_id: task_id.clone(), prompt: generated_prompt.clone(), model: model.clone(), + generation_route: Some(generation_route.clone()), + generation_kind: Some(generation_kind.clone()), + reference_resource_ids: reference_resource_ids.clone(), }, ) { Ok(registered) => registered, @@ -776,6 +931,10 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( "provider": provider.clone(), "assetFolderId": canvas_context.asset_folder_id, "canvasName": canvas_context.canvas_name, + "sliceWarning": slice_warning.clone(), + "generationRoute": generation_route, + "generationKind": generation_kind, + "referenceResourceIds": reference_resource_ids, }), )?; Ok(GeneratedPlatformArtAsset { @@ -784,31 +943,44 @@ fn commit_prepared_platform_art_asset_with_before_replace_hook( asset_object_id, task_id, model, + slice_warning, }) } pub(crate) fn platform_art_asset_art_spec( options: &PlatformArtAssetGenerationOptions, ) -> serde_json::Value { + if options.asset_kind == "icon-spec" { + return serde_json::json!({ + "assetType": "icon-spec", + "subject": "游戏角色、场景、UI 图标与反馈特效的统一视觉规范", + "style": "原创、正交展示、轮廓清楚、材质与光照一致的视觉规范板", + "palette": "统一主色、辅色、强调色和状态色,保证桌面端与移动端可读性", + "composition": "严格 1:1 规范板;分区展示玩家主体、目标物、场景地块、UI 图标、状态反馈与材质色板", + "format": format!("{} {}", options.aspect_ratio, options.image_size), + "constraints": "这是后续完整 UI 与透明图集共同引用的真实规范图;元素不得相互遮挡,不得做成完整游戏截图、海报或无结构概念画;必须原创,不得复刻现有游戏角色、Logo、贴图、标志性轮廓或受保护视觉语言", + "references": [], + }); + } if options.asset_kind == "ui-prototype" { return serde_json::json!({ "assetType": "ui", - "subject": "完整桌面端游戏 UI 原型,包含 HUD、卡牌控件、战场区和操作控件", + "subject": "严格依据当前项目玩法合同生成的完整游戏 UI 原型,包含状态 HUD、主要可玩区域、关键实体、操作控件、失败与重开流程", "style": "正视角、清晰分区、可指导 HTML/CSS 实现的高保真 UI/UX mockup", "palette": "与原创游戏主题一致,文字与控件对比清楚", - "composition": "严格 16:9 单屏界面;顶部资源与波次 HUD,左侧或顶部单位卡槽,中部战场网格,右侧敌人入口,底部或角落放置开始、暂停、重开和操作提示", + "composition": "严格 16:9 单屏界面;按当前玩法组织状态 HUD、主要可玩区域、目标或收集物说明、开始/移动/暂停/重开控件,并体现移动端触控布局", "format": format!("{} {}", options.aspect_ratio, options.image_size), - "constraints": "必须明显展示资源数值、单位卡牌、冷却/费用、波次进度、开始或暂停或重开控件和操作反馈;不得只生成无 HUD 的场景插画、战斗概念图、地图或宣传图;玩法类型只描述功能,不授权复刻现有作品,必须重建原创标题、资源名、单位名、角色轮廓与场景语言;不得复刻、翻译或近似改写现有游戏角色、单位名、Logo、贴图、标志性布局或受保护视觉语言", + "constraints": "不得假设项目属于塔防或补入玩法合同中不存在的卡牌、波次、敌人入口等结构;必须清楚展示当前玩法实际需要的信息、实体、操作、失败与重开状态;不得只生成无 HUD 的场景插画、概念图、地图或宣传图;必须重建原创标题、资源名、角色轮廓与场景语言,不得复刻、翻译或近似改写现有游戏角色、Logo、贴图、标志性布局或受保护视觉语言", "references": [], }); } serde_json::json!({ "assetType": "art", "subject": format!("{};使用项目原创命名和原创阵营设计", options.asset_label), - "style": "清晰可切分的原创 Web 游戏素材图集;以非拟人生态构装体、晶体、菌丝、雾气或器物轮廓建立独立视觉身份,避免现有塔防游戏的可识别角色语言", - "composition": format!("{} 游戏素材图集,按单位、敌人、地块和特效分区,留出清楚切分间距", options.aspect_ratio), + "style": "清晰可切分的原创 Web 游戏素材图集;严格沿用当前规范图的轮廓、材质、色板与光照,不预设塔防或其他固定玩法", + "composition": format!("{} 游戏素材图集,按玩家主体及状态、目标或收集物、障碍或场景元素、反馈特效分区,留出清楚切分间距", options.aspect_ratio), "format": format!("{} {}", options.aspect_ratio, options.image_size), - "constraints": "必须是可见的真实图片产物,不得用纯文本计划代替;禁止带脸向日葵、嘴状豌豆炮管、草坪横排、僵尸人形、现有游戏 Logo、贴图、图标式角色排布、标志性轮廓或近似配色;植物或生态主题不得直接画成大众熟知塔防角色的变体", + "constraints": "必须是可见的真实透明图片产物,不得用纯文本计划代替;只生成当前项目玩法合同需要的实体和反馈,不得擅自加入单位卡牌、波次敌人或其他玩法;禁止现有游戏 Logo、贴图、标志性角色轮廓、图标排布或近似配色", "references": [], }) } @@ -818,9 +990,15 @@ pub(crate) fn build_platform_art_asset_prompt( briefs: &[AgentGroupBrief], options: &PlatformArtAssetGenerationOptions, ) -> String { + if options.asset_kind == "icon-spec" { + return format!( + "为这个 Web 小游戏生成一张 1:1 的统一视觉规范图,作为后续 UI 设计图和透明游戏图集的共同权威参考。规范板必须分区展示:玩家主体及其成长形态、核心目标或收集物、场景地块与障碍、HUD/操作图标、得分/受击/胜负反馈、主辅强调色与材质规则。所有元素使用一致的正交视角、轮廓、光照和原创视觉语言,留出清楚间距;不要生成完整游戏截图、海报、黑底图集或纯文字说明。玩法机制只用于理解功能,不授权复刻现有作品。\n\n项目视觉需求:{}", + truncate_prompt_context(prompt.trim()) + ); + } if options.asset_kind == "ui-prototype" { return format!( - "生成一张真正的游戏 UI/UX 原型图,不是场景概念图。画面必须是完整 16:9 桌面端单屏界面,明确可见:顶部资源数值与波次/状态 HUD;单位卡牌及费用、冷却状态;中部战场网格;右侧敌人来袭方向;开始、暂停、重开控件;基础操作提示和点击/资源不足等反馈。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画草地、角色和敌人的无 HUD 战斗画面,禁止做海报、地图或纯插画。玩法类型和机制词只用于理解功能,不代表允许复刻现有作品;生成前必须把输入中可识别的现有游戏标题、资源、角色或单位名转换为新的原创命名,并重建角色轮廓、配色、场景材质和界面视觉语言。不得在画面中渲染 Sunflower、Peashooter、向日葵、豌豆射手、僵尸等知名塔防元素,也不得使用现有游戏 Logo、贴图、标志性布局或受保护视觉语言。\n\n项目 UI 需求:{}", + "生成一张真正的游戏 UI/UX 原型图,不是场景概念图。必须严格从下方当前项目 UI 需求提取玩法,不得自行假设它属于塔防或加入需求中不存在的单位卡牌、费用、波次、敌人入口等结构。画面是完整 16:9 桌面端单屏界面,并同时明确移动端重排意图;清楚呈现当前玩法所需的分数/资源/生命/局内状态 HUD、主要可玩区域、玩家与目标/收集物/危险物、开始和主要操作、失败状态与重新开始、键盘和触控提示。使用正视角、清晰分区和可读占位文字,使前端开发可直接据此拆分 HTML/CSS。禁止只画无 HUD 场景、概念图、地图、海报或纯插画。玩法机制只用于理解功能,不授权复刻现有作品;必须采用项目已经确定的原创命名、角色轮廓、配色、场景材质和界面视觉语言,不得使用现有游戏 Logo、贴图、标志性布局或受保护视觉语言。\n\n当前项目 UI 需求:{}", truncate_prompt_context(prompt.trim()) ); } @@ -834,7 +1012,7 @@ pub(crate) fn build_platform_art_asset_prompt( .filter(|markdown| !markdown.is_empty()) .unwrap_or("需要一张可直接用于 Web 小游戏首版原型的核心美术素材。"); format!( - "为 Web 小游戏首版原型生成一张可切分的原创核心美术素材图集,适合放入本地 assets 并被 canvas 游戏直接引用。必须先从美术 brief 中提取项目自己的标题、阵营、资源和单位名,并据此创造非拟人、非标志性的新轮廓。即使用户提到植物塔防或相似机制,也禁止画带脸向日葵、嘴状豌豆炮管、草坪横排、僵尸人形、现有游戏 Logo、贴图、标志性角色轮廓、图标式排布或近似配色;不得把知名角色改色、换名或机械化后继续使用。优先采用晶体、菌丝、雾气、陶质器物、潮汐生态构装体等与项目设定一致的独立形态,按守卫、敌人、地块和特效分区并留出清楚切分间距。\n用户需求:{}\n美术资产 brief:{}", + "为 Web 小游戏首版原型生成一张可切分的原创透明核心美术素材图集,适合放入本地 assets 并被游戏直接引用。严格从用户需求和美术 brief 提取当前项目自己的标题、玩法实体、目标物、收集物、障碍、状态与反馈;不得自行假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌等内容。所有元素沿用当前规范图的轮廓、配色、材质和光照,分区排布并留出清楚切分间距。禁止现有游戏 Logo、贴图、标志性角色轮廓、图标式排布或近似配色,不得把知名角色改色、换名或机械化后继续使用。\n用户需求:{}\n美术资产 brief:{}", truncate_inline(prompt, 240), truncate_prompt_context(art_asset_brief) ) @@ -843,6 +1021,61 @@ pub(crate) fn build_platform_art_asset_prompt( #[cfg(test)] mod canvas_generation_tests { use super::*; + use image::{codecs::png::PngEncoder, ColorType, ImageEncoder}; + + fn rgba_test_png(alpha: u8) -> CanvasResourceDownload { + let mut bytes = Vec::new(); + PngEncoder::new(&mut bytes) + .write_image(&[12, 34, 56, alpha], 1, 1, ColorType::Rgba8.into()) + .expect("encode RGBA fixture"); + CanvasResourceDownload { + bytes, + media_type: "image/png".to_string(), + } + } + + #[test] + fn canonical_art_spritesheet_requires_real_transparent_pixels() { + assert!(platform_art_spritesheet_has_transparent_pixels( + &rgba_test_png(0) + )); + assert!(!platform_art_spritesheet_has_transparent_pixels( + &rgba_test_png(u8::MAX) + )); + } + + #[test] + fn derived_visual_asset_waits_for_registered_art_spec() { + let temporary = tempfile::tempdir().expect("create art dependency project"); + let root = temporary.path(); + init_local_game_project_at(root, "art-dependency", "美术依赖测试").expect("init project"); + + let error = canonical_art_spec_reference_at(root, "canvas-project") + .expect_err("missing art spec must block derived visual generation"); + + assert!(error.contains("先完成并登记 assets/art-spec.png")); + assert!(error.contains("等待 art-director")); + } + + #[test] + fn postprocess_warning_wins_over_slice_warning_and_preserves_reason() { + let payload = serde_json::json!({ + "warning": { + "code": "postprocess-failed-source-preserved", + "reason": "抠图服务暂不可用,已保留源图。" + }, + "sliceWarning": { + "code": "insufficient-connected-components", + "reason": "不应覆盖通用告警" + } + }); + let error = platform_art_generation_postprocess_failure(&payload) + .expect("general warning must be authoritative"); + assert!(error.contains("postprocess-failed-source-preserved")); + assert!(error.contains("抠图服务暂不可用,已保留源图。")); + assert!(error.contains("不得登记为透明图集或自动重试")); + assert!(!error.contains("不应覆盖通用告警")); + } fn replacement_options() -> PlatformArtAssetGenerationOptions { PlatformArtAssetGenerationOptions { @@ -878,6 +1111,10 @@ mod canvas_generation_tests { generated_prompt: Some("原创替换图集".to_string()), model: Some("test-image-model".to_string()), provider: Some("test-provider".to_string()), + slice_warning: None, + generation_route: "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + generation_kind: "icon-spritesheet".to_string(), + reference_resource_ids: vec!["art-spec-resource".to_string()], extension: "png".to_string(), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 09fef8c6c..40940b5fb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -489,7 +489,31 @@ fn game_creator_design_foundation_tool_plan_prompt( ); } format!( - "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、阵营、资源、单位名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致。不得沿用或近似改写 Sunflower、Peashooter、向日葵、豌豆射手、僵尸等知名塔防单位、角色、Logo、界面术语或受保护视觉语言。文本策划只是中间结果;最终必须调用 canvas.asset_generate 生成 16:9、2K 横屏界面原型图并登记到 assets/ui-prototype.png,assetKind=ui-prototype、assetLabel=游戏横屏界面原型图、replaceExisting=false。canvas.asset_generate 成功动作本身就是当前 revision 的验证。已有同路径画布资产时先用 asset.list 核对登记,再在当前 run 对且只对 assets/ui-prototype.png 调用 image.inspect;检查已通过时不得删除、重复生成或再次扣费。只有 ui-prototype.v1 的 resourceBar、unitCardTray、battlefieldGrid、enemyEntryDirection、waveStatus、primaryControls、implementationClarity、originalTheme 八项全部通过才可完成。纯场景图、概念图、地图、海报或只有角色与箭头的战斗画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可用明确修正未通过项且保持原创命名的 prompt、固定输出合同和 replaceExisting=true 原位替换旧候选;普通任务不得覆盖,任何任务都不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" + "{prompt}\n\n你负责玩法规格与界面原型交付。玩法类型和机制描述不代表用户授权复刻现有游戏;必须先为项目创造原创标题、实体、资源、目标名称与视觉语言,并在 memory/project.md、game/game_design.md 和图片提示中保持一致。不得沿用或近似改写知名游戏单位、角色、Logo、界面术语或受保护视觉语言。文本策划只是中间结果;最终必须先用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,再调用 canvas.asset_generate 生成 16:9、2K 横屏界面原型图并登记到 assets/ui-prototype.png,assetKind=ui-prototype、assetLabel=游戏横屏界面原型图、replaceExisting=false。图片 prompt 必须逐项继承当前任务和 game/game_design.md 的真实玩法、HUD、可玩区域、关键实体、主要操作、失败/重开与移动端触控要求;不得假设为塔防或补入合同中不存在的单位卡牌、费用、波次、敌人入口等结构。Runtime 固定把规范图资源作为 referenceImageSrcs 第一项,调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=ui-design);不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions,后者只用于从已有且带标注的 UI 设计图提取独立透明 UI 素材。缺少规范图时必须等待 art-director 依赖并如实阻塞,不得回退为无规范参考的普通生图。canvas.asset_generate 成功动作本身就是当前 revision 的验证。已有同路径画布资产时先核对登记,再在当前 run 对且只对 assets/ui-prototype.png 调用 image.inspect;检查已通过时不得重复生成或再次扣费。只有 ui-prototype.v2 的 informationHud、gameplaySurface、objectiveEntities、primaryControls、failureRestartFlow、responsiveLayout、implementationClarity、originalTheme 八项检查全部通过才可完成。纯场景图、概念图、地图、海报或只有角色而没有可玩界面的画面都不是 UI 原型。视觉检查未通过时不得提交最终回复;只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可使用固定输出合同和 replaceExisting=true 原位替换旧候选;不得先删除正式图片。图片生成未配置、待确认或失败时同样不得提交最终回复,也不得把计划写完当成 completed。{role_boundary}" + ) +} + +fn game_creator_art_director_tool_plan_prompt( + prompt: &str, + editor_api_key_is_configured: bool, +) -> String { + if !editor_api_key_is_configured { + return format!("{prompt}\n\n你负责确定原创视觉方向。当前未配置 External Editor API Key,只完成正式 director 文档,不调用 canvas.asset_generate,也不伪造 assets/art-spec.png。"); + } + format!("{prompt}\n\n你负责生成项目唯一的统一视觉规范图。视觉方向文档只是中间结果;最终必须调用 canvas.asset_generate,以固定合同 outputPath=assets/art-spec.png、aspectRatio=1:1、imageSize=1K、assetKind=icon-spec、assetLabel=游戏统一视觉规范图、replaceExisting=false 生成真实图片。Runtime 固定调用 External Editor v1 的 POST /api/external/v1/editor/images/generations(kind=spec),并把结果同时登记到同名画布、素材库和项目 manifest。规范图必须覆盖玩家主体、目标物、地块、UI 图标、状态反馈、色板与材质规则,作为后续 UI 和透明图集共同引用的权威资源;不得用 generationInputs.artSpec JSON、纯文本计划、完整游戏截图、海报或普通黑底图集冒充。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可设置 replaceExisting=true 原位替换。生成失败或缺少 resourceId 时不得提交最终回复,也不得把计划写完当成 completed。") +} + +fn game_creator_art_asset_plan_tool_plan_prompt( + prompt: &str, + editor_api_key_is_configured: bool, +) -> String { + if !editor_api_key_is_configured { + return format!( + "{prompt}\n\n你负责首版美术资产清单交付。当前未配置 External Editor API Key,因此本轮必须写入可解析的 assets/manifest.art.json,记录所需素材、用途、推荐规格和当前未生成状态;不调用 canvas.asset_generate,也不伪造 assets/art-spritesheet.png。完成清单并通过当前 revision 的验证后即可交付,不得编辑 game/index.html。" + ); + } + format!( + "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成并登记 assets/art-spritesheet.png,固定使用 1:1、1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材、replaceExisting=false,并写入可解析的 assets/manifest.art.json。调用前必须用 asset.list 确认 assets/art-spec.png 已登记为当前项目的 icon-spec 画布资源,并依据当前任务、game/game_design.md 与 manifest 逐项说明真实需要的玩家主体及朝向/状态、目标或收集物、障碍/场景元素和反馈特效,由 Runtime 形成 iconDescriptions;不得假设为塔防或加入合同中不存在的单位、敌人、波次、卡牌。Runtime 固定以规范图的权威 resourceId 作为 referenceImageSrc,调用 POST /api/external/v1/editor/icon-spritesheets/generations,并用 screenColor=auto 完成透明后处理;不得把 UI 原型、Data URL、Blob URL、本地路径或结构化 JSON 冒充规范图引用,不得回退普通生图或 UI extraction。缺少规范图时必须等待 art-director 依赖并如实阻塞。成功后回读 observation 与 asset.list,核对服务端返回的透明 spritesheet、真实 alpha、warning 和 sliceWarning。warning.code=postprocess-failed-source-preserved 时没有透明图集,不得登记、验收或自动重试;仅 sliceWarning 时可保留完整透明图集,但不得声称独立切片已生成。已有有效同路径资产时不得重复生成或扣费;只有带 repairOfDelegationId 的唯一返工轮可 replaceExisting=true 原位替换。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认、失败或透明证据不足时不得提交最终回复。" ) } @@ -503,26 +527,25 @@ pub(crate) fn game_creator_agent_runtime_tool_plan_system_prompt_for_agent( editor_api_key_is_configured(), ); } + if agent_id == "art-director" { + return game_creator_art_director_tool_plan_prompt(&prompt, editor_api_key_is_configured()); + } if agent_id == "art-asset-plan" { - if !editor_api_key_is_configured() { - return format!( - "{prompt}\n\n你负责首版美术资产清单交付。当前未配置 External Editor API Key,因此本轮必须写入可解析的 assets/manifest.art.json,记录所需素材、用途、推荐规格和当前未生成状态;不调用 canvas.asset_generate,也不伪造 assets/art-spritesheet.png。完成清单并通过当前 revision 的验证后即可交付,不得编辑 game/index.html。" - ); - } - return format!( - "{prompt}\n\n你负责首版美术素材实际生成。资产清单和美术计划只是中间结果;最终必须调用 canvas.asset_generate 生成核心素材图并登记到 assets/art-spritesheet.png,aspectRatio=1:1、imageSize=1K、assetKind=art-spritesheet、assetLabel=游戏首版核心美术素材;普通首轮 replaceExisting=false,并写入可解析的 assets/manifest.art.json,记录素材路径、用途、来源和当前首版范围。图片提示必须明确排除标志性现有塔防角色轮廓:不得出现带脸向日葵、嘴状豌豆炮管、草坪横排、僵尸人形、现有游戏图标式排布或近似配色;应围绕项目已确定的原创名称设计非拟人、非标志性的新轮廓。成功执行 canvas.asset_generate 后,该动作本身就是当前 revision 的验证;随后调用 asset.list 回读并核对 manifest 已登记该 image/* 资产,再核对美术清单与真实文件一致后回复。已有同路径有效画布资产时不得删除、重复生成或扣费,只补齐和校验清单;非 UI spritesheet 不需要调用 image.inspect 做主观返工。只有任务正文明确标识这是带 repairOfDelegationId 的唯一返工轮时,才可沿用固定输出合同并设置 replaceExisting=true 原位替换不合格图片;不得先删除固定正式产物,也不得改用其他 outputPath。不得运行 game.static_smoke 或 preview.validate,也不得编辑 game/index.html。图片生成未配置、待确认或失败时不得提交最终回复,也不得把计划写完当成 completed。" + return game_creator_art_asset_plan_tool_plan_prompt( + &prompt, + editor_api_key_is_configured(), ); } if agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { return prompt; } let visual_delivery_contract = if editor_api_key_is_configured() { - "design-foundation 是图片产物型任务,expectedArtifacts 必须包含 assets/ui-prototype.png;art-asset-plan 也是图片产物型任务,expectedArtifacts 必须同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。二者都不能用空 expectedArtifacts 或纯文本回执代替图片" + "art-director 是规范图产物型任务,expectedArtifacts 必须包含 assets/art-spec.png;design-foundation 是图片产物型任务,expectedArtifacts 必须包含 assets/ui-prototype.png;art-asset-plan 也是图片产物型任务,负责透明图集,expectedArtifacts 必须同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。三者都不能用空 expectedArtifacts 或纯文本回执代替图片" } else { - "当前未配置 External Editor API Key,design-foundation 必须交付 memory/project.md 与 game/game_design.md,art-asset-plan 必须交付 assets/manifest.art.json;不得要求调用 canvas.asset_generate,也不得伪造 assets/ui-prototype.png 或 assets/art-spritesheet.png" + "当前未配置 External Editor API Key,art-director 只交付视觉方向文档,design-foundation 必须交付 memory/project.md 与 game/game_design.md,art-asset-plan 必须交付 assets/manifest.art.json;不得要求调用 canvas.asset_generate,也不得伪造 assets/art-spec.png、assets/ui-prototype.png 或 assets/art-spritesheet.png" }; let prompt = format!( - "{prompt}\n\n你当前是项目唯一面向用户的 Project Supervisor,并拥有最终回复权。每一轮都必须把用户原始目标视为最高层业务目标,专业 Agent 回执只能补充证据,不能把回执内容改写成新目标。总控不能替代已有专业角色完成其领域交付:只要仓库目标同时包含两个以上互不依赖的专业方向,就必须自行查看静态角色目录,选择最匹配的不同专业 Agent,并在同一个 native planning 批次用带 acceptanceCriteria 和 expectedArtifacts 的 agent.delegate 发起委派,让这些方向并行;用户不需要点名 Agent、指定数量或提醒并行。{visual_delivery_contract}。只有没有匹配专业角色、纯协调工作或一两步轻量读取时才由总控直接处理。互不重叠的临时并行检查通过 agent.spawn_isolated 分派;当同一目标同时需要边界清晰的专业委派和互不重叠的临时检查时,必须把两类协作放进同一个 native planning 批次一次性提交,不能拆成先后轮次。提交首个协作批次前,先分别完整枚举当前目标中已经生效的长期专业交付和临时隔离检查;两类都非空时,遗漏任一类的批次都不得提交。仓库合同明确把临时检查分为先行和后续独立阶段时,首批只提交当前已经生效的检查;先行组 ready 后优先创建刚生效的后续组,所有必要组创建前不得调用 agent.run_status 认领先行组,全部 ready 后用一次 agent.run_status 收齐。已有委派未收束时不要重复委派。需要等待专业 Agent 时返回空 response,让 Runtime 的 delegate/all-join 完成屏障保持同一父 run;取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。" + "{prompt}\n\n你当前是项目唯一面向用户的 Project Supervisor,并拥有最终回复权。每一轮都必须把用户原始目标视为最高层业务目标,专业 Agent 回执只能补充证据,不能把回执内容改写成新目标。总控不能替代已有专业角色完成其领域交付:只要仓库目标同时包含两个以上互不依赖的专业方向,就必须自行查看静态角色目录,选择最匹配的不同专业 Agent,并在同一个 native planning 批次用带 acceptanceCriteria 和 expectedArtifacts 的 agent.delegate 发起委派,让这些方向并行;用户不需要点名 Agent、指定数量或提醒并行。{visual_delivery_contract}。视觉产物始终按 owner 隔离:art-director 只声明 assets/art-spec.png,design-foundation 只声明 assets/ui-prototype.png,art-asset-plan 只声明 assets/art-spritesheet.png;不得把 UI 与图集合并交给 art-director。旧派生图需要原位替换时,先在同一批次分别交给 design-foundation 与 art-asset-plan 建立精确原合同并取得 needs-repair,认领后再在同一批次分别发起各自唯一、完全继承原合同的 repair,两个 repair 共同构成一个显式视觉返工阶段。只有没有匹配专业角色、纯协调工作或一两步轻量读取时才由总控直接处理。互不重叠的临时并行检查通过 agent.spawn_isolated 分派;当同一目标同时需要边界清晰的专业委派和互不重叠的临时检查时,必须把两类协作放进同一个 native planning 批次一次性提交,不能拆成先后轮次。提交首个协作批次前,先分别完整枚举当前目标中已经生效的长期专业交付和临时隔离检查;两类都非空时,遗漏任一类的批次都不得提交。仓库合同明确把临时检查分为先行和后续独立阶段时,首批只提交当前已经生效的检查;先行组 ready 后优先创建刚生效的后续组,所有必要组创建前不得调用 agent.run_status 认领先行组,全部 ready 后用一次 agent.run_status 收齐。已有委派未收束时不要重复委派。需要等待专业 Agent 时返回空 response,让 Runtime 的 delegate/all-join 完成屏障保持同一父 run;取得 readyDelegateReceipts 或 readyIsolatedJoins 后直接整合结果。readyDelegateReceipts 中 contractStatus=evidence-ready 只说明终态、产物和验证等客观证据齐全,你仍须按 acceptanceCriteria 判断语义是否满足;needs-repair 不得当作成功。客观或语义不满足时可以发起一次新 agent.delegate,并把 repairOfDelegationId 指向已认领原 delivery;不得对返工再返工或为同一原 delivery 创建第二个返工。专业结果冲突且无法依据用户目标裁决时,合并问题后用一次 user.input_request 询问用户。只有实现路径、产品取舍或缺失事实会实质改变结果时才调用 user.input_request;项目内可读取事实、权限确认和工具失败不得伪装成用户问题。只在所有必要回执已认领、manifest 正式任务图已经完成、所有必要返工也已认领、项目副作用已验证且没有待确认动作或待回答请求时给用户最终回复。不要向用户暴露内部 task/event、工具计划、动态 child ID 或调试状态。" ); let prompt = format!( "{prompt}\n\n当 collaboration policy 的 minIsolatedGroupsBeforeClaim 大于 0 时,首次 agent.run_status 认领前必须已经建立且 ready 的 isolated group 数量达到该值;不足时 Runtime 会在写 claim 或改 delivery 前失败关闭。已有 durable claim 的恢复不受此门禁影响。只读任务的 writeScopes 也必须填写且不能留空,只能覆盖其 expectedArtifacts 所在的最小目录/**,不能扩大到 sibling 或共同父目录。" @@ -672,9 +695,44 @@ mod tests { let with_canvas = game_creator_design_foundation_tool_plan_prompt("shared runtime contract", true); - assert!(with_canvas.contains("必须调用 canvas.asset_generate")); + assert!(with_canvas.contains("再调用 canvas.asset_generate")); + assert!(with_canvas.contains("assets/art-spec.png")); + assert!(with_canvas.contains("referenceImageSrcs 第一项")); assert!(with_canvas.contains("assets/ui-prototype.png")); assert!(with_canvas.contains("调用 image.inspect")); + assert!(with_canvas.contains("ui-prototype.v2")); + assert!(with_canvas.contains("informationHud")); + assert!(with_canvas.contains("failureRestartFlow")); + assert!(with_canvas.contains("不得假设为塔防")); + assert!(with_canvas + .contains("POST /api/external/v1/editor/images/generations(kind=ui-design)")); + assert!(with_canvas + .contains("不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions")); + } + + #[test] + fn agent_prompt_art_asset_plan_uses_fixed_transparent_spritesheet_route_and_warnings() { + let without_canvas = + game_creator_art_asset_plan_tool_plan_prompt("shared runtime contract", false); + assert!(without_canvas.contains("不调用 canvas.asset_generate")); + assert!(without_canvas.contains("不伪造 assets/art-spritesheet.png")); + + let with_canvas = + game_creator_art_asset_plan_tool_plan_prompt("shared runtime contract", true); + assert!(with_canvas.contains("用 asset.list 确认 assets/art-spec.png 已登记")); + assert!(with_canvas.contains("由 Runtime 形成 iconDescriptions")); + assert!(with_canvas.contains("玩家主体及朝向/状态")); + assert!(with_canvas.contains("不得假设为塔防")); + assert!(with_canvas.contains("权威 resourceId 作为 referenceImageSrc")); + assert!(with_canvas.contains("POST /api/external/v1/editor/icon-spritesheets/generations")); + assert!(with_canvas.contains("不得回退普通生图或 UI extraction")); + assert!(with_canvas.contains("screenColor=auto")); + assert!(with_canvas.contains("缺少规范图时必须等待 art-director")); + assert!(with_canvas.contains("不得回退普通生图")); + assert!(with_canvas.contains("真实 alpha")); + assert!(with_canvas.contains("warning.code=postprocess-failed-source-preserved")); + assert!(with_canvas.contains("不得登记、验收或自动重试")); + assert!(with_canvas.contains("仅 sliceWarning")); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index 45b158597..fbb15dd19 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -44,6 +44,8 @@ pub(in crate::agent) use run_status_observation::*; pub(in crate::agent) use structured_plan::*; pub(in crate::agent) use tool_plan_protocol::*; +#[cfg(test)] +pub(crate) use action_audit::agent_runtime_action_receipt_safe_detail_for_owner_for_test; pub(crate) use action_audit::{ agent_runtime_git_commit_safe_detail_value, agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, agent_runtime_tool_action_input_summary, @@ -86,6 +88,7 @@ pub(crate) use pending_confirmation_ledger::{ write_game_creator_agent_runtime_tool_confirmation, }; pub(crate) use project_gates::{ + acquire_game_creator_agent_provider_plan_project_write_lock_with_wait, acquire_game_creator_agent_runtime_project_write_lock_with_wait, advance_agent_runtime_project_revision_locked, agent_runtime_observation_advances_project_revision, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs index 274bd3686..cd1e96dd7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/action_audit.rs @@ -179,6 +179,21 @@ pub(in crate::agent) fn agent_runtime_action_receipt_safe_detail_for_owner( ) } +#[cfg(test)] +pub(crate) fn agent_runtime_action_receipt_safe_detail_for_owner_for_test( + root: &Path, + receipt_agent_id: &str, + receipt_run_id: &str, + observation: &AgentRuntimeToolObservation, +) -> Option { + agent_runtime_action_receipt_safe_detail_for_owner( + root, + receipt_agent_id, + receipt_run_id, + observation, + ) +} + fn agent_runtime_action_receipt_safe_detail_with_owner( root: &Path, receipt_owner: Option<(&str, &str)>, @@ -279,27 +294,58 @@ fn agent_runtime_action_receipt_safe_detail_with_owner( let validation_profile = detail .get("validationProfile") .and_then(serde_json::Value::as_str) - .filter(|value| *value == AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE); + .filter(|value| { + matches!( + *value, + AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE + | AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE + ) + }); let (passed, checks, issues) = if validation_profile.is_some() { if inspection_kind != Some(AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND) { return None; } let passed = detail.get("passed")?.as_bool()?; - let assessment = AgentRuntimeUiPrototypeAssessment { - checks: serde_json::from_value(detail.get("checks")?.clone()).ok()?, - issues: serde_json::from_value(detail.get("issues")?.clone()).ok()?, - summary: detail.get("conclusion")?.as_str()?.to_string(), + if validation_profile == Some(AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE) { + let assessment = AgentRuntimeUiPrototypeAssessment { + checks: serde_json::from_value(detail.get("checks")?.clone()).ok()?, + issues: serde_json::from_value(detail.get("issues")?.clone()).ok()?, + summary: detail.get("conclusion")?.as_str()?.to_string(), + } + .validate() + .ok()?; + if passed != assessment.passed() || passed != (observation.status == "ok") { + return None; + } + ( + Some(passed), + Some(serde_json::to_value(&assessment.checks).ok()?), + Some(serde_json::to_value(&assessment.issues).ok()?), + ) + } else { + let checks = serde_json::from_value::( + detail.get("checks")?.clone(), + ) + .ok()?; + let issues = + serde_json::from_value::>(detail.get("issues")?.clone()).ok()?; + if issues.len() > 8 + || issues.iter().any(|issue| { + issue.trim().is_empty() + || issue.chars().count() > 160 + || sanitize_agent_runtime_text(issue, 160) != *issue + }) + || passed != (checks.all_passed() && issues.is_empty()) + || passed != (observation.status == "ok") + { + return None; + } + ( + Some(passed), + Some(serde_json::to_value(&checks).ok()?), + Some(serde_json::to_value(&issues).ok()?), + ) } - .validate() - .ok()?; - if passed != assessment.passed() || passed != (observation.status == "ok") { - return None; - } - ( - Some(passed), - Some(serde_json::to_value(&assessment.checks).ok()?), - Some(serde_json::to_value(&assessment.issues).ok()?), - ) } else { if inspection_kind.is_some() || !matches!(detail.get("passed"), None | Some(serde_json::Value::Null)) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs index feb008e4d..eaee84273 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/autonomous_policy.rs @@ -264,6 +264,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ ) -> Result<(), String> { let mut code_prototype = None; let mut quality_review = None; + let mut art_director = None; let mut art_asset_plan = None; for action in &plan.actions { let Some(input) = autonomous_initial_delegate_input(action)? else { @@ -285,6 +286,7 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ let slot = match target_agent_id { "code-prototype" => &mut code_prototype, AGENT_RUNTIME_QUALITY_REVIEW_AGENT_ID => &mut quality_review, + "art-director" => &mut art_director, "art-asset-plan" => &mut art_asset_plan, _ => continue, }; @@ -348,6 +350,31 @@ pub(in crate::agent) fn validate_agent_runtime_autonomous_initial_collaboration_ "首批 quality-review 的 expectedArtifacts 必须为 []", )); } + if let Some(art_director) = art_director { + let art_task = autonomous_initial_delegate_task(art_director, "art-director")?; + let art_criteria = agent_runtime_tool_input_string_list( + &serde_json::Value::Object(art_director.clone()), + &["acceptanceCriteria", "acceptance_criteria", "criteria"], + ); + if std::iter::once(art_task.as_str()) + .chain(art_criteria.iter().map(String::as_str)) + .any(agent_runtime_task_explicitly_requires_read_only_delivery) + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 art-director 必须是非只读规范图生成任务", + )); + } + let art_artifacts = + autonomous_initial_delegate_expected_artifacts(art_director, "art-director")?; + if !art_artifacts + .iter() + .any(|path| path == "assets/art-spec.png") + { + return Err(autonomous_initial_collaboration_contract_error( + "首批 art-director 的 expectedArtifacts 必须包含 assets/art-spec.png", + )); + } + } if let Some(art_asset_plan) = art_asset_plan { let art_task = autonomous_initial_delegate_task(art_asset_plan, "art-asset-plan")?; let art_criteria = agent_runtime_tool_input_string_list( @@ -1495,10 +1522,38 @@ mod tests { .expect("resume first manifest task fixture"); for (local_path, kind) in [ + ("assets/art-spec.png", "icon-spec"), ("assets/ui-prototype.png", "ui-prototype"), ("assets/art-spritesheet.png", "art-spritesheet"), ] { - std::fs::write(root.join(local_path), b"visual fixture").expect("write visual fixture"); + let (generation_route, generation_kind, reference_resource_ids) = match kind { + "icon-spec" => ( + "/api/external/v1/editor/images/generations", + "spec", + Vec::new(), + ), + "ui-prototype" => ( + "/api/external/v1/editor/images/generations", + "ui-design", + vec!["resource-icon-spec".to_string()], + ), + _ => ( + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + vec!["resource-icon-spec".to_string()], + ), + }; + let bytes = if kind == "art-spritesheet" { + use image::ImageEncoder; + let mut bytes = Vec::new(); + image::codecs::png::PngEncoder::new(&mut bytes) + .write_image(&[12, 34, 56, 0], 1, 1, image::ColorType::Rgba8.into()) + .expect("encode transparent visual fixture"); + bytes + } else { + b"\x89PNG\r\n\x1a\nfixture".to_vec() + }; + std::fs::write(root.join(local_path), bytes).expect("write visual fixture"); register_local_asset_at( &root, local_path, @@ -1513,6 +1568,9 @@ mod tests { task_id: Some(format!("task-{kind}")), prompt: Some("测试视觉资产".to_string()), model: Some("gpt-image-2".to_string()), + generation_route: Some(generation_route.to_string()), + generation_kind: Some(generation_kind.to_string()), + reference_resource_ids, }, ) .expect("register visual fixture"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 41f021bd6..18fc74e29 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -857,13 +857,13 @@ pub(in crate::agent) fn ui_prototype_visual_inspection_blocker_detail_at_locked( return Ok(None); } Ok(Some(format!( - "expectedPath={expected_path} · resourceBar={} · unitCardTray={} · battlefieldGrid={} · enemyEntryDirection={} · waveStatus={} · primaryControls={} · implementationClarity={} · originalTheme={} · issues={}", - assessment.checks.resource_bar, - assessment.checks.unit_card_tray, - assessment.checks.battlefield_grid, - assessment.checks.enemy_entry_direction, - assessment.checks.wave_status, + "expectedPath={expected_path} · informationHud={} · gameplaySurface={} · objectiveEntities={} · primaryControls={} · failureRestartFlow={} · responsiveLayout={} · implementationClarity={} · originalTheme={} · issues={}", + assessment.checks.information_hud, + assessment.checks.gameplay_surface, + assessment.checks.objective_entities, assessment.checks.primary_controls, + assessment.checks.failure_restart_flow, + assessment.checks.responsive_layout, assessment.checks.implementation_clarity, assessment.checks.original_theme, assessment.issues.join(";"), @@ -879,6 +879,7 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( return None; } let (expected_path, expected_kind, label) = match agent_id { + "art-director" => (AGENT_RUNTIME_ART_SPEC_PATH, "icon-spec", "统一视觉规范图"), "design-foundation" => ("assets/ui-prototype.png", "ui-prototype", "策划界面原型图"), "art-asset-plan" => ( "assets/art-spritesheet.png", @@ -898,23 +899,15 @@ pub(in crate::agent) fn visual_asset_completion_blocker_at_locked( }); } }; - let registered = manifest.assets.iter().any(|asset| { - asset.local_path == expected_path - && asset.kind == expected_kind - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, &asset.local_path) - .ok() - .is_some_and(|path| path.is_file()) - }); - if !registered { + if let Err(error) = validate_manifest_required_visual_asset(root, &manifest, agent_id) { return Some(AgentRuntimeToolObservation { tool: "runtime.visual_asset".to_string(), status: "blocked".to_string(), - summary: format!("{label}尚未生成并登记,不能完成任务"), + summary: format!("{label}尚未按正式视觉流程生成并登记,不能完成任务"), detail: Some(format!( - "expectedPath={expected_path} · expectedKind={expected_kind} · editorApiKeyConfigured={}", - editor_api_key_is_configured() + "expectedPath={expected_path} · expectedKind={expected_kind} · editorApiKeyConfigured={} · reason={}", + editor_api_key_is_configured(), + redact_agent_runtime_project_paths(root, &error, 300), )), }); } @@ -1318,7 +1311,7 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( root: &Path, command_id: &str, ) -> Result { - const MAX_ATTEMPTS: usize = 200; + const MAX_ATTEMPTS: usize = 2_000; for attempt in 0..MAX_ATTEMPTS { match acquire_project_write_lock(root, command_id) { Err(error) @@ -1332,3 +1325,10 @@ pub(crate) fn acquire_game_creator_agent_runtime_project_write_lock_with_wait( } unreachable!("project write lock retry loop always returns") } + +pub(crate) fn acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( + root: &Path, + command_id: &str, +) -> Result { + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, command_id) +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 4c3285ec7..73557a6ed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -53,7 +53,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at agent_runtime_run_profile_identity_at(root, agent_id, run_id, None, None)?; let mcp_catalog = read_game_creator_mcp_catalog_at(root).await?; let mut built_request = { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.build.tool_plan", )?; @@ -103,7 +103,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at } } built_request = { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.rebuild.tool_plan", )?; @@ -139,7 +139,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at .map(|sidecar| context_compaction_result(sidecar, true)); } let provider_snapshot = { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + let _lock = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( root, "runtime.provider_request.capture.tool_plan", )?; @@ -733,7 +733,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD { format!( - "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。所有静态委派都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" + "上一条输出不符合工具计划协议:{protocol_error}\n本次修复的原生工具目录只保留首批协作工具。必须在同一响应一次性建立完整首批合同:code-prototype 必须是非只读实现任务且 expectedArtifacts 包含 game/index.html;quality-review 的 task 必须显式声明只读、不得修改项目,且 expectedArtifacts 必须为 [];如当前 policy 的 requiredStaticAgentIds 包含 art-director,必须加入非只读规范图委派且 expectedArtifacts 包含 assets/art-spec.png;如包含 design-foundation,必须加入非只读设计委派且 expectedArtifacts 包含 memory/project.md、game/game_design.md 与 assets/ui-prototype.png;如包含 art-asset-plan,还必须加入非只读美术生成委派且 expectedArtifacts 同时包含 assets/manifest.art.json 与 assets/art-spritesheet.png。所有静态委派都使用 agent.delegate,repairOfDelegationId=null、runId=null;如当前 policy 还要求 isolated,再在同批补齐 agent.spawn_isolated。不得更新计划、读取、搜索、查询状态、修改项目或返回最终回复。不要解释,不要 markdown,不要代码围栏。" ) } else { format!( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index bc17d823c..ccba17a4f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -768,6 +768,101 @@ fn response_stream_committed_checkpoint_recovers_by_idempotent_cleanup() { .is_none()); } +#[test] +fn completed_orphan_finalization_cleanup_keeps_newer_terminal_run() { + let (project, state, response_revision, snapshot) = + response_stream_fixture("response-stream-completed-orphan-run"); + let root = project.path(); + let response = "旧 run 已完成,只残留幂等清理 journal。"; + write_game_creator_agent_runtime_response_stream_ready_at( + root, + &snapshot, + response_revision, + response, + Some("stop"), + ) + .expect("write orphan ready stream"); + let outcome = finish_game_creator_agent_background_runtime_turn_with_checkpoint_at( + root, + state.clone(), + response, + response_revision, + &[], + |checkpoint| { + if checkpoint == AgentRuntimeFinalizationCheckpoint::ResponseStreamCommitted { + Err("injected-orphan-after-response-stream-committed".to_string()) + } else { + Ok(()) + } + }, + ) + .expect("leave completed orphan finalization"); + assert!(matches!( + outcome, + AgentBackgroundFinalizationOutcome::Pending(_) + )); + assert!(read_game_creator_agent_runtime_finalization_journal( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read retained orphan journal") + .is_some()); + let mut stale_stream = + read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id) + .expect("read committed orphan stream") + .expect("committed orphan stream exists"); + stale_stream.status = AGENT_RUNTIME_RESPONSE_STREAM_STATUS_READY.to_string(); + stale_stream.request_slot = "final-reply-stale-slot".to_string(); + stale_stream.response_revision = response_revision.saturating_sub(1); + stale_stream.accumulated_text = "上一版已过期回复".to_string(); + write_game_creator_agent_runtime_response_stream_at(root, &stale_stream) + .expect("replace committed stream with stale ready residue"); + + let newer_run_id = "response-stream-newer-cancelled-run"; + start_game_creator_agent_runtime_task_at( + root, + &state.agent_id, + "更新后的下一轮任务", + newer_run_id, + "agent-background-task", + "等待取消", + vec!["保留新 run 身份".to_string()], + ) + .expect("start newer run"); + cancel_game_creator_agent_runtime_task_at(root, &state.agent_id, newer_run_id) + .expect("cancel newer run"); + + assert_eq!( + cleanup_game_creator_agent_runtime_completed_finalizations_at(root) + .expect("clean completed orphan finalization"), + 1 + ); + assert!(read_game_creator_agent_runtime_finalization_journal( + root, + &state.agent_id, + &state.run_id, + ) + .expect("read cleaned orphan journal") + .is_none()); + let repaired_stream = + read_game_creator_agent_runtime_response_stream_at(root, &state.agent_id, &state.run_id) + .expect("read repaired orphan stream") + .expect("repaired orphan stream exists"); + assert_eq!( + repaired_stream.status, + AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + ); + assert_eq!(repaired_stream.accumulated_text, response); + assert_eq!(repaired_stream.response_revision, response_revision); + let current = read_game_creator_agent_runtime_at(root, &state.agent_id) + .expect("read newer runtime after orphan cleanup") + .state; + assert_eq!(current.run_id, newer_run_id); + assert_eq!(current.status, "cancelled"); + assert_eq!(current.phase, "cancelled"); +} + #[tokio::test] async fn provider_handoff_identity_drift_closes_lifecycle_without_leaking_response() { let (project, state, _response_revision, snapshot) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 0665b3d17..16792e4c0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -47,7 +47,10 @@ pub(crate) fn agent_runtime_executable_tools() -> Vec<&'static str> { } fn autonomous_game_build_agent_can_generate_canvas_asset(agent_id: &str) -> bool { - matches!(agent_id.trim(), "design-foundation" | "art-asset-plan") + matches!( + agent_id.trim(), + "art-director" | "design-foundation" | "art-asset-plan" + ) } pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs index 99919b715..84c6bb5f4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver.rs @@ -253,17 +253,19 @@ pub(crate) use pending_execution::{ pub(crate) use pending_recovery::resume_game_creator_agent_pending_tool_action_at; #[cfg(test)] pub(crate) use pending_recovery::resume_game_creator_agent_provider_action_batch_for_test_at; +pub(crate) use provider_recovery::{ + autonomous_manifest_parent_wake_error_is_transient, + schedule_waiting_autonomous_manifest_parent_wake_after_lane_release, + schedule_waiting_static_delegate_parent_wake_after_lane_release, + static_delegate_parent_wake_error_is_transient, +}; #[cfg(test)] pub(crate) use provider_recovery::{ ensure_waiting_provider_retry_records_for_test, probe_static_delegate_parent_wake_singleflight_coalescing, }; -pub(crate) use provider_recovery::{ - schedule_waiting_autonomous_manifest_parent_wake_after_lane_release, - schedule_waiting_static_delegate_parent_wake_after_lane_release, - static_delegate_parent_wake_error_is_transient, -}; pub(crate) use recovery_scan::{ + cleanup_game_creator_agent_runtime_completed_finalizations_at, resume_game_creator_agent_background_tasks_at, resume_game_creator_agent_pending_action_for_agent_at, wake_pending_game_creator_agent_background_tasks_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index d933eb655..0f40ca3d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -1,5 +1,31 @@ use super::*; +pub(in crate::agent) fn autonomous_registered_derived_visuals_need_repair_at(root: &Path) -> bool { + let Ok(manifest) = read_manifest_for_project(root) else { + return false; + }; + [ + ( + "design-foundation", + "assets/ui-prototype.png", + "ui-prototype", + ), + ( + "art-asset-plan", + "assets/art-spritesheet.png", + "art-spritesheet", + ), + ] + .into_iter() + .any(|(task_id, local_path, kind)| { + manifest + .assets + .iter() + .any(|asset| asset.local_path == local_path && asset.kind == kind) + && validate_manifest_required_visual_asset(root, &manifest, task_id).is_err() + }) +} + pub(super) fn game_creator_agent_background_final_reply_fallback( plan_response: &str, run_profile: &str, @@ -225,6 +251,7 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c } let autonomous_manifest_can_wait = agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && runtime.run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD + && !autonomous_registered_derived_visuals_need_repair_at(&root) && !game_creator_agent_runtime_provider_action_batch_exists( &root, &agent_id, @@ -1089,8 +1116,8 @@ pub(in crate::agent) async fn run_game_creator_agent_background_task_pass_with_c if agent_id == "design-foundation" { runtime.current_action = "等待可验收的 UI 原型图".to_string(); runtime.waiting_on = - "图片生成、manifest 登记与 ui-prototype.v1 结构化视觉检查".to_string(); - runtime.next_step = "缺图时调用 canvas.asset_generate;已有候选时对 assets/ui-prototype.png 调用 image.inspect;未通过则经 file.delete 权限流程删除后重新生成".to_string(); + "图片生成、manifest 登记与 ui-prototype.v2 结构化视觉检查".to_string(); + runtime.next_step = "缺图时调用 canvas.asset_generate;已有候选时对 assets/ui-prototype.png 调用 image.inspect;未通过时如实返回 needs-repair,由 Supervisor 认领后发起唯一 repair 原位替换,禁止先删除正式图片".to_string(); } else { runtime.current_action = "等待实际图片产物".to_string(); runtime.waiting_on = "图片生成确认、配置与本地 manifest 登记".to_string(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs index aaaf9ef4f..6de000683 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs @@ -17,11 +17,34 @@ fn register_autonomous_recovery_visual_fixture(root: &Path, local_path: &str, ki task_id: Some(format!("task-{kind}")), prompt: None, model: None, + generation_route: Some("/api/external/v1/editor/images/generations".to_string()), + generation_kind: Some("spec".to_string()), + reference_resource_ids: Vec::new(), }, ) .expect("register autonomous recovery visual fixture"); } +#[test] +fn autonomous_parent_keeps_planning_before_scheduling_registered_legacy_derived_visuals() { + let root = std::env::temp_dir().join(format!( + "genarrative-agent-main-loop-legacy-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos() + )); + init_local_game_project_at(&root, "legacy-derived-visuals", "旧派生视觉返工门禁") + .expect("project init"); + assert!(!autonomous_registered_derived_visuals_need_repair_at(&root)); + + register_autonomous_recovery_visual_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + assert!(autonomous_registered_derived_visuals_need_repair_at(&root)); + + fs::remove_dir_all(root).ok(); +} + fn prepare_autonomous_completion_evidence(root: &Path, state: &AgentRuntimeState) -> u64 { let contract = read_autonomous_completion_contract(root, &state.agent_id, &state.run_id) .expect("read autonomous completion contract") @@ -324,7 +347,7 @@ fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() { async fn missing_completed_visual_asset_fails_same_child_without_retry() { const PARENT_RUN_ID: &str = "autonomous-visual-recovery-parent"; const PARENT_TASK: &str = "生成完整小游戏并恢复丢失的正式视觉产物"; - const CHILD_ID: &str = "design-foundation"; + const CHILD_ID: &str = "art-director"; let _config_guard = crate::tests::write_test_local_config( r#"{"editorApi":{"apiKey":"visual-recovery-test-key"}}"#.to_string(), ); @@ -358,7 +381,7 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() { "# 游戏设计\n\n恢复丢失图片后才能完成。\n", ) .expect("write game design fixture"); - register_autonomous_recovery_visual_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + register_autonomous_recovery_visual_fixture(&root, "assets/art-spec.png", "icon-spec"); update_manifest_task_status_at( &root, "design-director", @@ -400,7 +423,7 @@ async fn missing_completed_visual_asset_fails_same_child_without_retry() { update_manifest_task_status_at(&root, CHILD_ID, GameCreationAppTaskStatus::Completed) .expect("project initial visual completion fixture"); - fs::remove_file(root.join("assets/ui-prototype.png")).expect("remove registered visual file"); + fs::remove_file(root.join("assets/art-spec.png")).expect("remove registered visual file"); let downgraded = read_manifest_for_project(&root).expect("refresh missing visual manifest"); assert_eq!( downgraded @@ -584,6 +607,19 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac } assert_eq!(stream.status, "committed"); assert_eq!(stream.accumulated_text, fallback); + for _ in 0..500 { + if read_game_creator_agent_runtime_finalization_journal( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + RUN_ID, + ) + .expect("poll finalization residue") + .is_none() + { + break; + } + std::thread::sleep(Duration::from_millis(20)); + } assert!(read_game_creator_agent_runtime_finalization_journal( &root, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs index 48f335691..4bdb4c9dc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/provider_recovery.rs @@ -1,5 +1,9 @@ use super::*; +pub(crate) fn autonomous_manifest_parent_wake_error_is_transient(error: &str) -> bool { + error.starts_with("项目正在被其他写操作占用:") +} + pub(in crate::agent) fn schedule_waiting_provider_retry_wake_after_lane_release( root: PathBuf, agent_id: String, @@ -127,6 +131,9 @@ async fn drive_waiting_autonomous_manifest_parent_wake_pass( return; } }, + Err(error) if autonomous_manifest_parent_wake_error_is_transient(&error) => { + continue; + } Err(error) => { let _ = mark_autonomous_manifest_parent_wake_needs_reconciliation_at( root, agent_id, run_id, &error, 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 d5c7aff2b..5ae051a2e 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 @@ -149,6 +149,272 @@ pub(in crate::agent) fn remove_game_creator_agent_runtime_provider_recovery_at( provider_retry::remove_at(root, agent_id, run_id) } +pub(crate) fn cleanup_game_creator_agent_runtime_completed_finalizations_at( + root: &Path, +) -> Result { + let journals = list_game_creator_agent_runtime_finalization_journals(root)?; + let mut removed = 0_usize; + for journal in journals { + if journal.status != AGENT_RUNTIME_FINALIZATION_STATUS_RUNTIME_COMPLETED { + continue; + } + let task = read_latest_game_creator_agent_runtime_task_by_run_id( + root, + &journal.agent_id, + &journal.run_id, + )? + .ok_or_else(|| { + format!( + "已完成 finalization 缺少所属任务:agent={} runId={}", + journal.agent_id, journal.run_id + ) + })?; + if task.status != "completed" + || task.phase != "completed" + || task.agent_id != journal.agent_id + || task.task_id != journal.task_id + || task.session_id != journal.session_id + || task.run_id != journal.run_id + || task.source != journal.source + || task.parent_agent_id != journal.parent_agent_id + || task.parent_run_id != journal.parent_run_id + || task.delegation_id != journal.delegation_id + || task.goal_id != journal.goal_id + || task.goal_revision != journal.goal_revision + { + return Err(format!( + "已完成 finalization 与所属终态任务身份冲突:agent={} runId={}", + journal.agent_id, journal.run_id + )); + } + if !game_creator_agent_runtime_finalization_assistant_exists(root, &journal)? { + return Err(format!( + "已完成 finalization 的 assistant 消息缺失:agent={} runId={}", + journal.agent_id, journal.run_id + )); + } + let (records, scan_truncated) = + read_agent_db_records_bounded(root, AGENT_RUNTIME_ACTION_HISTORY_MAX_DB_BYTES)?; + let finalization_records = records + .iter() + .filter(|record| { + record + .get("finalizationId") + .and_then(serde_json::Value::as_str) + == Some(journal.finalization_id.as_str()) + }) + .collect::>(); + let lifecycle_records = finalization_records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(AGENT_RUNTIME_FINALIZATION_LIFECYCLE_RECORD_TYPE) + }) + .copied() + .collect::>(); + let lifecycle_stages = lifecycle_records + .iter() + .filter_map(|record| record.get("stage").and_then(serde_json::Value::as_str)) + .collect::>(); + let exact_record_count = |record_type: &str| { + finalization_records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some(record_type) + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(journal.agent_id.as_str()) + && record.get("taskId").and_then(serde_json::Value::as_str) + == Some(journal.task_id.as_str()) + && record.get("sessionId").and_then(serde_json::Value::as_str) + == Some(journal.session_id.as_str()) + && record.get("runId").and_then(serde_json::Value::as_str) + == Some(journal.run_id.as_str()) + && record.get("messageId").and_then(serde_json::Value::as_str) + == Some(journal.message_id.as_str()) + }) + .count() + }; + let conversation_message_count = finalization_records + .iter() + .filter(|record| { + record.get("recordType").and_then(serde_json::Value::as_str) + == Some("conversation.message") + && record.get("agentId").and_then(serde_json::Value::as_str) + == Some(journal.agent_id.as_str()) + && record.get("sessionId").and_then(serde_json::Value::as_str) + == Some(journal.session_id.as_str()) + && record.get("messageId").and_then(serde_json::Value::as_str) + == Some(journal.message_id.as_str()) + && record.get("role").and_then(serde_json::Value::as_str) == Some("assistant") + }) + .count(); + let lifecycle_identity_matches = lifecycle_records.iter().all(|record| { + record.get("agentId").and_then(serde_json::Value::as_str) + == Some(journal.agent_id.as_str()) + && record.get("taskId").and_then(serde_json::Value::as_str) + == Some(journal.task_id.as_str()) + && record.get("sessionId").and_then(serde_json::Value::as_str) + == Some(journal.session_id.as_str()) + && record.get("runId").and_then(serde_json::Value::as_str) + == Some(journal.run_id.as_str()) + && record.get("messageId").and_then(serde_json::Value::as_str) + == Some(journal.message_id.as_str()) + && record.get("source").and_then(serde_json::Value::as_str) + == Some(journal.source.as_str()) + }); + if scan_truncated + || finalization_records.len() != 7 + || !lifecycle_identity_matches + || lifecycle_stages + != [ + "prepared", + "assistant-persisted", + "runtime-completed", + "goal-completed", + ] + || conversation_message_count != 1 + || exact_record_count("agent.runtime.completed") != 1 + || exact_record_count("agent.runtime.background_task.completed") != 1 + { + return Err(format!( + "已完成 finalization 缺少完整唯一七槽审计:agent={} runId={} scanTruncated={scan_truncated}", + journal.agent_id, journal.run_id + )); + } + let mut stream = read_game_creator_agent_runtime_response_stream_at( + root, + &journal.agent_id, + &journal.run_id, + )? + .ok_or_else(|| { + format!( + "已完成 finalization 的 committed 回复流缺失:agent={} runId={}", + journal.agent_id, journal.run_id + ) + })?; + let stream_base_identity_conflicts = stream.agent_id != journal.agent_id + || stream.task_id != journal.task_id + || stream.session_id != journal.session_id + || stream.run_id != journal.run_id + || stream.request_kind != "final-reply"; + if stream_base_identity_conflicts { + return Err(format!( + "已完成 finalization 与 committed 回复流身份冲突:agent={} runId={}", + journal.agent_id, journal.run_id + )); + } + let stream_matches = |stream: &AgentRuntimeResponseStream| { + stream.status == AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED + && stream.request_slot == journal.response_request_slot + && stream.applied_steer_cursor == journal.response_steer_cursor + && stream.response_revision == journal.response_revision + && stream.accumulated_text == journal.response + }; + if !stream_matches(&stream) { + if stream.status == AGENT_RUNTIME_RESPONSE_STREAM_STATUS_COMMITTED { + return Err(format!( + "已完成 finalization 与 committed 回复流身份冲突:agent={} runId={}", + journal.agent_id, journal.run_id + )); + } + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.finalization.completed-stream-repair", + )?; + remove_game_creator_agent_runtime_response_stream_at( + root, + &journal.agent_id, + &journal.run_id, + )?; + let mut state = agent_runtime_state_from_task_record(&task); + state.applied_steer_cursor = journal.response_steer_cursor; + mark_game_creator_agent_runtime_response_stream_committed_at(root, &state, &journal)?; + stream = read_game_creator_agent_runtime_response_stream_at( + root, + &journal.agent_id, + &journal.run_id, + )? + .ok_or_else(|| "已完成 finalization 的回复流修复后缺失".to_string())?; + if !stream_matches(&stream) { + return Err(format!( + "已完成 finalization 的回复流修复后仍冲突:agent={} runId={}", + journal.agent_id, journal.run_id + )); + } + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_completed_stream_repaired", + "agentId": journal.agent_id, + "taskId": journal.task_id, + "sessionId": journal.session_id, + "runId": journal.run_id, + "source": journal.source, + }), + ); + } + let _project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "runtime.finalization.completed-cleanup", + )?; + remove_game_creator_agent_runtime_finalization_recovery_sidecars( + root, + &journal.agent_id, + &journal.run_id, + )?; + let _ = append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.finalization_completed_cleaned", + "agentId": journal.agent_id, + "taskId": journal.task_id, + "sessionId": journal.session_id, + "runId": journal.run_id, + "source": journal.source, + }), + ); + removed = removed.saturating_add(1); + } + Ok(removed) +} + +fn current_game_creator_agent_runtime_finalization_exists_at( + root: &Path, + agent_ids: &std::collections::BTreeSet, +) -> Result { + for agent_id in agent_ids { + let (state, _) = + read_game_creator_agent_runtime_state_for_finalization_resume(root, agent_id)?; + if state.run_id.trim().is_empty() { + continue; + } + let path = game_creator_agent_runtime_finalization_path(root, agent_id, &state.run_id); + match fs::symlink_metadata(&path) { + Ok(_) => return Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取当前 Agent Runtime finalization 元数据失败:{}: {error}", + path.display() + )); + } + } + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + match fs::symlink_metadata(&backup_path) { + Ok(_) => return Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取当前 Agent Runtime finalization 恢复副本元数据失败:{}: {error}", + backup_path.display() + )); + } + } + } + Ok(false) +} + pub(crate) fn resume_game_creator_agent_background_tasks_at( root: &Path, ) -> Result, String> { @@ -165,6 +431,9 @@ pub(in crate::agent) fn resume_game_creator_agent_background_tasks_unredacted_at return read_game_creator_agent_runtimes_at(root); } let agent_ids = collect_game_creator_agent_runtime_agent_ids(root)?; + if !current_game_creator_agent_runtime_finalization_exists_at(root, &agent_ids)? { + cleanup_game_creator_agent_runtime_completed_finalizations_at(root)?; + } for ledger in tool_plan_handoff::list_at(root)? { let agent_id = ledger.agent_id(); let run_id = ledger.run_id(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 8f8fb1844..80fccae90 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -559,6 +559,7 @@ fn validate_autonomous_game_build_ready_task_parent_at( root: &Path, parent_agent_id: &str, parent_run_id: &str, + require_static_delegate_barrier: bool, ) -> Result { let parent_agent_id = normalize_game_creator_runtime_agent_id(parent_agent_id)?; if parent_agent_id != GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID { @@ -606,12 +607,14 @@ fn validate_autonomous_game_build_ready_task_parent_at( &agent_runtime_state_from_task_record(¤t_root), )? .ok_or_else(|| "autonomous ready-task scheduler 父 Run 缺少完成合同".to_string())?; - let barrier = static_delegate_completion_barrier_at(root, &parent_agent_id, parent_run_id)?; - if !barrier.is_clear() { - return Err(format!( - "autonomous ready-task scheduler 必须等待静态委派完成屏障收束:{}", - barrier.detail() - )); + if require_static_delegate_barrier { + let barrier = static_delegate_completion_barrier_at(root, &parent_agent_id, parent_run_id)?; + if !barrier.is_clear() { + return Err(format!( + "autonomous ready-task scheduler 必须等待静态委派完成屏障收束:{}", + barrier.detail() + )); + } } Ok(binding) } @@ -880,6 +883,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( root, parent_agent_id, parent_run_id, + true, )?; let manifest = read_manifest_for_project(root)?; let active_count = manifest @@ -923,10 +927,12 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( } for (task, transition_to_running) in candidates { + let runtime_lock = try_acquire_game_creator_agent_runtime_task_lock(root, &task.id)?; let parent_binding = match validate_autonomous_game_build_ready_task_parent_at( root, parent_agent_id, parent_run_id, + true, ) { Ok(binding) => binding, Err(error) => { @@ -940,6 +946,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( root, parent_agent_id, parent_run_id, + true, ) { let cancelled = AgentRuntimeTaskRecord { status: "cancelled".to_string(), @@ -965,7 +972,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( if game_creator_agent_runtime_terminal_status(&record).is_some() { terminal_records.push(record.clone()); } - scheduled.push((task, result, record, needs_notification)); + scheduled.push((task, result, record, needs_notification, runtime_lock)); } Err(error) => { update_manifest_task_status_at( @@ -995,7 +1002,7 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( } let mut results = Vec::new(); - for (task, result, record, needs_notification) in scheduled { + for (task, result, record, needs_notification, runtime_lock) in scheduled { append_agent_db_record( root, serde_json::json!({ @@ -1013,6 +1020,15 @@ pub(crate) fn schedule_autonomous_game_build_ready_tasks_at( "recovered": needs_notification, }), )?; + if game_creator_agent_runtime_terminal_status(&record).is_none() { + if let Some(runtime_lock) = runtime_lock { + spawn_next_game_creator_agent_background_task_drain_with_lock( + root, + &record.agent_id, + runtime_lock, + ); + } + } if needs_notification && game_creator_agent_runtime_terminal_status(&record).is_none() && notify_external_agent_runner_after_background_task_enqueue( @@ -1101,6 +1117,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke root, &parent_agent_id, &parent_run_id, + false, )?; let manifest = read_manifest_for_project(root)?; let Some(manifest_task) = manifest.tasks.iter().find(|task| task.id == state.agent_id) else { @@ -1119,7 +1136,7 @@ pub(in crate::agent) fn project_autonomous_manifest_ready_task_terminal_at_locke if status == GameCreationAppTaskStatus::Completed && matches!( manifest_task.id.as_str(), - "design-foundation" | "art-asset-plan" + "art-director" | "design-foundation" | "art-asset-plan" ) && !manifest_has_required_visual_asset(root, &manifest, &manifest_task.id) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs index bb39cab08..78dae9847 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol.rs @@ -82,6 +82,7 @@ pub(crate) use run_configuration::{ read_game_creator_agent_runtime_run_profile_binding, }; pub(crate) use steering::{ + acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait, consume_game_creator_agent_runtime_steers, game_creator_agent_runtime_accepts_steer, game_creator_agent_runtime_steer_ledger_path, interrupt_game_creator_agent_runtime_provider_request_at, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs index 3856088ad..079aa8ca4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/autonomous_completion_contract_tests.rs @@ -57,8 +57,35 @@ fn autonomous_fixture_with_setup( } fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str) { - fs::write(root.join(local_path), b"\x89PNG\r\n\x1a\nfixture") - .expect("write autonomous visual fixture"); + let bytes = if kind == "art-spritesheet" { + use image::ImageEncoder; + let mut bytes = Vec::new(); + image::codecs::png::PngEncoder::new(&mut bytes) + .write_image(&[12, 34, 56, 0], 1, 1, image::ColorType::Rgba8.into()) + .expect("encode transparent autonomous visual fixture"); + bytes + } else { + b"\x89PNG\r\n\x1a\nfixture".to_vec() + }; + fs::write(root.join(local_path), bytes).expect("write autonomous visual fixture"); + let (generation_route, generation_kind, reference_resource_ids) = match kind { + "icon-spec" => ( + "/api/external/v1/editor/images/generations", + "spec", + Vec::new(), + ), + "ui-prototype" => ( + "/api/external/v1/editor/images/generations", + "ui-design", + vec!["resource-icon-spec".to_string()], + ), + "art-spritesheet" => ( + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + vec!["resource-icon-spec".to_string()], + ), + _ => ("", "", Vec::new()), + }; register_local_asset_at( root, local_path, @@ -73,6 +100,9 @@ fn register_autonomous_visual_fixture(root: &Path, local_path: &str, kind: &str) task_id: Some(format!("task-{kind}")), prompt: None, model: None, + generation_route: (!generation_route.is_empty()).then(|| generation_route.to_string()), + generation_kind: (!generation_kind.is_empty()).then(|| generation_kind.to_string()), + reference_resource_ids, }, ) .expect("register autonomous visual fixture"); @@ -100,6 +130,7 @@ fn prepare_completed_autonomous_manifest_fixture(root: &Path) { .expect("write audio manifest fixture"); fs::write(root.join("exports/README.md"), "# 发布说明\n\n可试玩。\n") .expect("write publish fixture"); + register_autonomous_visual_fixture(root, "assets/art-spec.png", "icon-spec"); register_autonomous_visual_fixture(root, "assets/ui-prototype.png", "ui-prototype"); register_autonomous_visual_fixture(root, "assets/art-spritesheet.png", "art-spritesheet"); for task in new_game_creation_app_seed_tasks() { @@ -1010,6 +1041,54 @@ fn autonomous_scheduler_child_terminal_projects_with_existing_project_lock() { ); } +#[test] +fn autonomous_scheduler_child_terminal_projects_while_static_delegate_is_still_running() { + let (_temporary, root, parent_state, _contract) = autonomous_fixture( + "做一个完整小游戏", + "autonomous-scheduler-static-overlap-parent", + ); + update_manifest_task_status_at(&root, "design-director", GameCreationAppTaskStatus::Running) + .expect("mark scheduler child running"); + let record = queue_autonomous_manifest_child_fixture(&root, &parent_state, "design-director"); + let waiting_delivery = new_static_delegate_delivery( + &parent_state.agent_id, + &parent_state.session_id, + &parent_state.run_id, + "overlapping-static-action", + "overlapping-static-delegation", + "quality-review", + "overlapping-quality-session", + "overlapping-quality-run", + ); + create_or_read_static_delegate_delivery_at(&root, &waiting_delivery) + .expect("create overlapping static delivery"); + assert!(static_delegate_completion_barrier_at( + &root, + &parent_state.agent_id, + &parent_state.run_id, + ) + .expect("read overlapping static barrier") + .has_waiting()); + + let mut state = agent_runtime_state_from_task_record(&record); + state.status = "completed".to_string(); + state.phase = "completed".to_string(); + assert!( + project_autonomous_manifest_ready_task_terminal_at(&root, &state) + .expect("project scheduler child despite independent static delivery") + ); + + assert_eq!( + read_manifest_for_project(&root) + .expect("read scheduler child manifest") + .tasks + .iter() + .find(|task| task.id == "design-director") + .map(|task| &task.status), + Some(&GameCreationAppTaskStatus::Completed) + ); +} + #[tokio::test] async fn autonomous_scheduler_recovers_running_without_journal_and_journal_without_running() { let (_temporary, root, parent_state, _contract) = diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs index 62eef0b60..e4bf34b09 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs @@ -377,6 +377,89 @@ pub(crate) fn read_game_creator_agent_runtime_finalization_journal( Ok(Some(journal)) } +pub(in crate::agent) fn list_game_creator_agent_runtime_finalization_journals( + root: &Path, +) -> Result, String> { + let directory = root.join(".agent/runtime/finalizations"); + let agent_entries = match fs::read_dir(&directory) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(error) => { + return Err(format!( + "读取 Agent Runtime finalization 目录失败:{}: {error}", + directory.display() + )); + } + }; + let mut identities = BTreeSet::<(String, String)>::new(); + for agent_entry in agent_entries { + let agent_entry = agent_entry.map_err(|error| { + format!( + "读取 Agent Runtime finalization Agent 目录项失败:{}: {error}", + directory.display() + ) + })?; + let agent_type = agent_entry.file_type().map_err(|error| { + format!( + "读取 Agent Runtime finalization Agent 类型失败:{}: {error}", + agent_entry.path().display() + ) + })?; + if agent_type.is_symlink() || !agent_type.is_dir() { + return Err("Agent Runtime finalization Agent 项必须是普通目录".to_string()); + } + let agent_id = agent_entry + .file_name() + .to_str() + .ok_or_else(|| "Agent Runtime finalization Agent 目录名必须是 UTF-8".to_string())? + .to_string(); + for journal_entry in fs::read_dir(agent_entry.path()).map_err(|error| { + format!( + "读取 Agent Runtime finalization Agent 目录失败:{}: {error}", + agent_entry.path().display() + ) + })? { + let journal_entry = journal_entry.map_err(|error| { + format!( + "读取 Agent Runtime finalization 目录项失败:{}: {error}", + agent_entry.path().display() + ) + })?; + let journal_type = journal_entry.file_type().map_err(|error| { + format!( + "读取 Agent Runtime finalization 类型失败:{}: {error}", + journal_entry.path().display() + ) + })?; + if journal_type.is_symlink() || !journal_type.is_file() { + return Err("Agent Runtime finalization 项必须是普通文件".to_string()); + } + let file_name = journal_entry + .file_name() + .to_str() + .ok_or_else(|| "Agent Runtime finalization 文件名必须是 UTF-8".to_string())? + .to_string(); + let run_id = file_name + .strip_suffix(".json") + .or_else(|| { + file_name + .strip_prefix('.') + .and_then(|value| value.strip_suffix(".json.previous")) + }) + .ok_or_else(|| "Agent Runtime finalization 目录含未知文件".to_string())?; + identities.insert((agent_id.clone(), run_id.to_string())); + } + } + let mut journals = Vec::with_capacity(identities.len()); + for (agent_id, run_id) in identities { + let journal = + read_game_creator_agent_runtime_finalization_journal(root, &agent_id, &run_id)? + .ok_or_else(|| "Agent Runtime finalization 列举期间消失".to_string())?; + journals.push(journal); + } + Ok(journals) +} + pub(in crate::agent) fn write_game_creator_agent_runtime_finalization_journal( root: &Path, journal: &AgentRuntimeFinalizationJournal, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs index db5fa0e79..209a344da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/response_stream.rs @@ -129,6 +129,33 @@ pub(crate) fn read_game_creator_agent_runtime_response_stream_at( Ok(stream) } +pub(in crate::agent) fn remove_game_creator_agent_runtime_response_stream_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result<(), String> { + let path = game_creator_agent_runtime_response_stream_path(root, agent_id, run_id); + let backup_path = agent_runtime_json_sidecar_backup_path(&path); + match fs::symlink_metadata(&path) { + Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => { + Err("Agent Runtime 回复流必须是普通文件".to_string()) + } + Ok(_) => remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 回复流") + .and_then(|_| { + fs::remove_file(&path).map_err(|error| { + format!("删除 Agent Runtime 回复流失败:{}: {error}", path.display()) + }) + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + remove_agent_runtime_json_sidecar_backup(&backup_path, "Agent Runtime 回复流") + } + Err(error) => Err(format!( + "读取 Agent Runtime 回复流元数据失败:{}: {error}", + path.display() + )), + } +} + pub(in crate::agent) fn write_game_creator_agent_runtime_response_stream_at( root: &Path, stream: &AgentRuntimeResponseStream, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs index c47237829..428dcd809 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/steering.rs @@ -923,10 +923,7 @@ pub(crate) fn consume_game_creator_agent_runtime_steers( next_loop_index: usize, context_tracker: &AgentRuntimeContextWindowTracker, ) -> Result { - let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( - root, - "runtime.steer.consume", - )?; + let _lock = acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait(root)?; let mut snapshot = read_game_creator_agent_runtime_steer_ledger(root, &runtime.agent_id, &runtime.run_id)?; if snapshot.closed_cursor.is_some() { @@ -1063,6 +1060,12 @@ pub(crate) fn consume_game_creator_agent_runtime_steers( Ok(true) } +pub(crate) fn acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait( + root: &Path, +) -> Result { + acquire_game_creator_agent_runtime_project_write_lock_with_wait(root, "runtime.steer.consume") +} + pub(crate) fn render_game_creator_agent_runtime_steers_for_prompt( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs index 108f7b9fd..5266c63f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools.rs @@ -58,7 +58,8 @@ pub(crate) use isolated_joins::{ pub(crate) use media::observe_agent_runtime_platform_art_asset_generation_after_dispatch_for_test; #[allow(unused_imports)] pub(crate) use media::{ - AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, AGENT_RUNTIME_UI_PROTOTYPE_PATH, + AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, + AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, }; pub(crate) use policy::{ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs index 5cdb241f7..07ce9f72c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/file_ops.rs @@ -336,7 +336,16 @@ pub(in crate::agent) fn observe_agent_runtime_file_delete( }; } }; - if manifest_has_required_visual_asset(root, &manifest, agent_id) { + let registered_fixed_asset_exists = manifest.assets.iter().any(|asset| { + asset.local_path == "assets/art-spritesheet.png" + && asset.kind == "art-spritesheet" + && asset.media_type.starts_with("image/") + && asset.source.kind == GameCreationAppAssetSourceKind::Canvas + && resolve_local_project_path(root, &asset.local_path) + .ok() + .is_some_and(|path| path.is_file()) + }); + if registered_fixed_asset_exists { return AgentRuntimeToolObservation { tool: "file.delete".to_string(), status: "blocked".to_string(), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs index e47871ba8..c5c017088 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/media.rs @@ -10,11 +10,39 @@ pub(in crate::agent) struct AgentRuntimeImageInspectInput { pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND: &str = "ui-prototype"; pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_PATH: &str = "assets/ui-prototype.png"; -pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE: &str = "ui-prototype.v1"; +pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE: &str = "ui-prototype.v1"; +pub(crate) const AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE: &str = "ui-prototype.v2"; +pub(crate) const AGENT_RUNTIME_ART_SPEC_PATH: &str = "assets/art-spec.png"; #[derive(Clone, Debug, Deserialize, Serialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub(in crate::agent) struct AgentRuntimeUiPrototypeChecks { + pub(in crate::agent) information_hud: bool, + pub(in crate::agent) gameplay_surface: bool, + pub(in crate::agent) objective_entities: bool, + pub(in crate::agent) primary_controls: bool, + pub(in crate::agent) failure_restart_flow: bool, + pub(in crate::agent) responsive_layout: bool, + pub(in crate::agent) implementation_clarity: bool, + pub(in crate::agent) original_theme: bool, +} + +impl AgentRuntimeUiPrototypeChecks { + fn all_passed(&self) -> bool { + self.information_hud + && self.gameplay_surface + && self.objective_entities + && self.primary_controls + && self.failure_restart_flow + && self.responsive_layout + && self.implementation_clarity + && self.original_theme + } +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(in crate::agent) struct AgentRuntimeUiPrototypeLegacyChecks { pub(in crate::agent) resource_bar: bool, pub(in crate::agent) unit_card_tray: bool, pub(in crate::agent) battlefield_grid: bool, @@ -25,8 +53,8 @@ pub(in crate::agent) struct AgentRuntimeUiPrototypeChecks { pub(in crate::agent) original_theme: bool, } -impl AgentRuntimeUiPrototypeChecks { - fn all_passed(&self) -> bool { +impl AgentRuntimeUiPrototypeLegacyChecks { + pub(in crate::agent) fn all_passed(&self) -> bool { self.resource_bar && self.unit_card_tray && self.battlefield_grid @@ -279,7 +307,7 @@ pub(in crate::agent) async fn observe_agent_runtime_image_inspect( .join("\n"); let question = sanitize_agent_runtime_text(&question, MAX_QUESTION_CHARS); let inspection_focus = if ui_prototype_inspection { - "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、战斗概念图、地图、海报或仅有角色和箭头的插画必须判定失败。逐项检查:resourceBar=资源数值栏;unitCardTray=单位卡槽及费用/冷却;battlefieldGrid=明确战场网格;enemyEntryDirection=敌人入口/来袭方向;waveStatus=波次或局内状态;primaryControls=开始/暂停/重开等主要控件;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSS;originalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,不要 markdown 或解释,字段必须严格为:{\"checks\":{\"resourceBar\":true,\"unitCardTray\":true,\"battlefieldGrid\":true,\"enemyEntryDirection\":true,\"waveStatus\":true,\"primaryControls\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。".to_string() + "请只依据真实可见像素判断这是否是可供前端直接实现的完整游戏 UI 原型,不能依据文件名、生成提示词或图片内自述放行。纯场景图、概念图、地图、海报或只展示角色而没有可玩界面的插画必须判定失败;不要假设游戏一定属于塔防或任何固定玩法。逐项检查:informationHud=清楚显示当前玩法需要的分数、资源、生命、关卡或局内状态;gameplaySurface=主要可玩区域及空间规则清楚;objectiveEntities=玩家主体、目标/收集/危险物、谜题或文本选项、轨道等当前玩法等价关键要素可辨;primaryControls=当前玩法需要的开始、移动、暂停或操作控件清楚;failureRestartFlow=存在可识别的结束态表现意图或明确重开入口;responsiveLayout=能从可见布局、触控目标和可重排分组判断移动适配意图,实际双视口另由浏览器验证;implementationClarity=分区、层级和文字清楚到可指导 HTML/CSS;originalTheme=原创主题且未复刻现有游戏角色、Logo、贴图或受保护视觉语言。请只返回一个 JSON object,不要 markdown 或解释,字段必须严格为:{\"checks\":{\"informationHud\":true,\"gameplaySurface\":true,\"objectiveEntities\":true,\"primaryControls\":true,\"failureRestartFlow\":true,\"responsiveLayout\":true,\"implementationClarity\":true,\"originalTheme\":true},\"issues\":[\"未通过项及原因;全部通过时必须为空数组\"],\"summary\":\"500 字以内中文结论\"}。只有八项 checks 全为 true 且 issues 为空才通过。".to_string() } else if question.trim().is_empty() { "请检查布局、遮挡、裁切、视觉层级、素材一致性,以及桌面与移动视口是否可用。".to_string() } else { @@ -480,6 +508,14 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio }; } let canonical_options = match agent_id { + "art-director" => Some(PlatformArtAssetGenerationOptions { + output_path: Some(AGENT_RUNTIME_ART_SPEC_PATH.to_string()), + aspect_ratio: "1:1".to_string(), + image_size: "1K".to_string(), + asset_kind: "icon-spec".to_string(), + asset_label: "游戏统一视觉规范图".to_string(), + replace_existing: false, + }), "design-foundation" => Some(PlatformArtAssetGenerationOptions { output_path: Some("assets/ui-prototype.png".to_string()), aspect_ratio: "16:9".to_string(), @@ -617,7 +653,7 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio } if !matches!( options.asset_kind.as_str(), - "game-art" | "ui-prototype" | "art-spritesheet" + "game-art" | "icon-spec" | "ui-prototype" | "art-spritesheet" ) { return AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), @@ -767,18 +803,27 @@ pub(in crate::agent) async fn observe_agent_runtime_platform_art_asset_generatio "model": generated.model.clone(), }), ); + let slice_warning_summary = generated + .slice_warning + .as_deref() + .map(|reason| format!(";透明图集可用,但自动切片未完成:{reason}")) + .unwrap_or_default(); AgentRuntimeToolObservation { tool: "canvas.asset_generate".to_string(), status: "ok".to_string(), - summary: format!("已生成美术素材:{}", generated.asset.local_path), + summary: format!( + "已生成美术素材:{}{slice_warning_summary}", + generated.asset.local_path + ), detail: Some(format!( - "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, verifiedRevision={mutation_revision}", + "assetId={}, localPath={}, resourceId={}, assetObjectId={}, taskId={}, model={}, sliceWarning={}, verifiedRevision={mutation_revision}", generated.asset.id, generated.asset.local_path, generated.resource_id.as_deref().unwrap_or(""), generated.asset_object_id.as_deref().unwrap_or(""), generated.task_id.as_deref().unwrap_or(""), - generated.model.as_deref().unwrap_or("") + generated.model.as_deref().unwrap_or(""), + generated.slice_warning.as_deref().unwrap_or("") )), } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs index 9bf7aa3d3..55acb9da4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/policy.rs @@ -1,7 +1,10 @@ use super::*; fn autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id: &str) -> bool { - matches!(agent_id.trim(), "design-foundation" | "art-asset-plan") + matches!( + agent_id.trim(), + "art-director" | "design-foundation" | "art-asset-plan" + ) } fn autonomous_design_foundation_command_is_allowed(command_id: &str) -> bool { @@ -73,7 +76,7 @@ pub(crate) fn game_creator_agent_runtime_tool_policy_rule_for_run( && !autonomous_game_build_agent_can_execute_canvas_asset_generate(agent_id) { return Some(AgentRuntimeToolPolicyBlock::Denied(format!( - "自主构建模式只允许 design-foundation 或 art-asset-plan 执行:{command_id}" + "自主构建模式只允许 art-director、design-foundation 或 art-asset-plan 执行:{command_id}" ))); } if run_profile == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD @@ -394,13 +397,14 @@ mod tests { assert!(matches!( supervisor_block, Some(AgentRuntimeToolPolicyBlock::Denied(reason)) - if reason.contains("只允许 design-foundation 或 art-asset-plan") + if reason.contains("只允许 art-director、design-foundation 或 art-asset-plan") )); let mut bindings = BTreeMap::new(); for agent_id in [ "code-prototype", "quality-review", + "art-director", "design-foundation", "art-asset-plan", ] { @@ -422,10 +426,10 @@ mod tests { assert!(matches!( blocked, Some(AgentRuntimeToolPolicyBlock::Denied(reason)) - if reason.contains("只允许 design-foundation 或 art-asset-plan") + if reason.contains("只允许 art-director、design-foundation 或 art-asset-plan") )); } - for agent_id in ["design-foundation", "art-asset-plan"] { + for agent_id in ["art-director", "design-foundation", "art-asset-plan"] { let binding = bindings.get(agent_id).expect("visual binding"); assert!(game_creator_agent_runtime_tool_policy_rule_for_run( &root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index f69997290..022cb0516 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -845,7 +845,7 @@ fn runtime_tool_description(tool: &str) -> &'static str { "preview.validate" => "用真实浏览器验证桌面和移动预览并保存证据。", "image.inspect" => "让视觉模型检查一至两张项目内图片。", "canvas.asset_generate" => { - "通过已配置平台生成图片,写入确定的项目 assets 路径并登记素材;只有唯一返工委派可显式替换已登记正式图片。" + "通过已配置的 External Editor API 生成图片并登记到画布、素材库和项目 assets;art-director 先生成 icon-spec 规范图,ui-prototype 与透明 art-spritesheet 都固定复用该规范图;只有唯一返工委派可显式替换已登记正式图片。" } "blackboard.write" => "向项目级共享黑板追加稳定结论。", "agent.message" => "向一个目标 Agent 写入定向上下文消息。", @@ -1068,7 +1068,7 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "outputPath": { "type": ["string", "null"], "maxLength": 240 }, "aspectRatio": { "type": ["string", "null"], "enum": ["1:1", "2:3", "3:2", "9:16", "16:9", null] }, "imageSize": { "type": ["string", "null"], "enum": ["0.5K", "1K", "2K", null] }, - "assetKind": { "type": ["string", "null"], "enum": ["game-art", "ui-prototype", "art-spritesheet", null] }, + "assetKind": { "type": ["string", "null"], "enum": ["game-art", "icon-spec", "ui-prototype", "art-spritesheet", null] }, "assetLabel": { "type": ["string", "null"], "maxLength": 80 }, "replaceExisting": { "type": "boolean" } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/assets.rs b/apps/ai-game-creator-shell/src-tauri/src/assets.rs index fad456437..1c7402307 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -37,6 +37,9 @@ pub(crate) fn upload_local_asset_at( task_id: None, prompt: None, model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) } @@ -97,6 +100,9 @@ pub(crate) fn import_canvas_asset_at( task_id, prompt, model, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) } @@ -197,6 +203,9 @@ pub(crate) fn import_canvas_export_at( task_id, prompt: Some(layer.title.trim().to_string()).filter(|value| !value.is_empty()), model, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, )?); } @@ -319,6 +328,9 @@ pub(crate) async fn sync_canvas_project_assets_at( prompt: json_string_field(resource, "actualPrompt") .or_else(|| json_string_field(resource, "prompt")), model: json_string_field(resource, "model"), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, )?); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/cli.rs b/apps/ai-game-creator-shell/src-tauri/src/cli.rs index 76c1b95a3..9d60ee983 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/cli.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/cli.rs @@ -156,7 +156,10 @@ impl CliCommand { pub(crate) fn requires_started_external_agent_runner(&self) -> bool { self.requires_external_agent_runner() - && !matches!(self, Self::AgentCancel { .. } | Self::RunnerShutdownIfIdle) + && !matches!( + self, + Self::AgentCancel { .. } | Self::AgentResume { .. } | Self::RunnerShutdownIfIdle + ) } fn project_path_mut(&mut self) -> Option<(&mut PathBuf, bool)> { @@ -1262,9 +1265,12 @@ pub(crate) fn run_cli_command(command: CliCommand) -> Result<(), String> { } CliCommand::AgentResume { project_path } => { let project_path = canonicalize_cli_path(&project_path, "本地项目路径", false)?; + let completed_finalizations_cleaned = + cleanup_game_creator_agent_runtime_completed_finalizations_at(&project_path)?; require_external_agent_runner_for_cli_runtime_write(&project_path)?; let runtimes = resume_game_creator_agent_background_tasks_at(&project_path)?; println!("agent.resume.accepted"); + println!("completedFinalizationsCleaned={completed_finalizations_cleaned}"); println!( "runtimesJson={}", serialize_agent_runtime_cli_payload(&runtimes)? diff --git a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs index e7d1c2781..82ad1f376 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/collaboration.rs @@ -68,22 +68,24 @@ impl Default for SupervisorCollaborationPolicy { } fn autonomous_game_build_has_canonical_art_asset(root: &Path) -> bool { - const ART_PATH: &str = "assets/art-spritesheet.png"; read_existing_manifest_for_project(root) .ok() .is_some_and(|manifest| { - manifest.assets.iter().any(|asset| { - asset.local_path == ART_PATH - && asset.kind == "art-spritesheet" - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, ART_PATH) - .ok() - .and_then(|path| fs::read(path).ok()) - .is_some_and(|bytes| { - bytes.len() > 8 && bytes.starts_with(b"\x89PNG\r\n\x1a\n") - }) - }) + manifest_has_required_visual_asset(root, &manifest, "art-asset-plan") + }) +} + +fn autonomous_game_build_has_canonical_art_spec(root: &Path) -> bool { + read_existing_manifest_for_project(root) + .ok() + .is_some_and(|manifest| manifest_has_required_visual_asset(root, &manifest, "art-director")) +} + +fn autonomous_game_build_has_canonical_ui_prototype(root: &Path) -> bool { + read_existing_manifest_for_project(root) + .ok() + .is_some_and(|manifest| { + manifest_has_required_visual_asset(root, &manifest, "design-foundation") }) } @@ -94,8 +96,14 @@ fn autonomous_game_build_supervisor_collaboration_policy( .into_iter() .map(str::to_string) .collect::>(); - if editor_api_key_is_configured() && !autonomous_game_build_has_canonical_art_asset(root) { - required_static_agent_ids.push("art-asset-plan".to_string()); + if editor_api_key_is_configured() { + if !autonomous_game_build_has_canonical_art_spec(root) { + required_static_agent_ids.push("art-director".to_string()); + } else if !autonomous_game_build_has_canonical_ui_prototype(root) { + required_static_agent_ids.push("design-foundation".to_string()); + } else if !autonomous_game_build_has_canonical_art_asset(root) { + required_static_agent_ids.push("art-asset-plan".to_string()); + } } SupervisorCollaborationPolicy { required_initial_wave: SupervisorInitialCollaborationWave::Static, @@ -109,16 +117,38 @@ fn apply_autonomous_game_build_required_static_agents( root: &Path, mut policy: SupervisorCollaborationPolicy, ) -> Result { - if editor_api_key_is_configured() - && !autonomous_game_build_has_canonical_art_asset(root) - && !policy - .required_static_agent_ids - .iter() - .any(|existing| existing == "art-asset-plan") - { - policy - .required_static_agent_ids - .push("art-asset-plan".to_string()); + if editor_api_key_is_configured() { + if !autonomous_game_build_has_canonical_art_spec(root) + && !policy + .required_static_agent_ids + .iter() + .any(|existing| existing == "art-director") + { + policy + .required_static_agent_ids + .push("art-director".to_string()); + } else if autonomous_game_build_has_canonical_art_spec(root) + && !autonomous_game_build_has_canonical_ui_prototype(root) + && !policy + .required_static_agent_ids + .iter() + .any(|existing| existing == "design-foundation") + { + policy + .required_static_agent_ids + .push("design-foundation".to_string()); + } else if autonomous_game_build_has_canonical_art_spec(root) + && autonomous_game_build_has_canonical_ui_prototype(root) + && !autonomous_game_build_has_canonical_art_asset(root) + && !policy + .required_static_agent_ids + .iter() + .any(|existing| existing == "art-asset-plan") + { + policy + .required_static_agent_ids + .push("art-asset-plan".to_string()); + } } policy.min_static_delegates = policy .min_static_delegates @@ -1222,6 +1252,26 @@ fn summarize_supervisor_collaboration_actions( "Project Supervisor agent.delegate 只能指向静态专业 Agent".to_string() ); } + let expected_artifacts = input + .get("expectedArtifacts") + .or_else(|| input.get("expected_artifacts")) + .and_then(Value::as_array) + .into_iter() + .flatten(); + for artifact in expected_artifacts.filter_map(Value::as_str) { + let expected_owner = match artifact.trim() { + "assets/art-spec.png" => Some("art-director"), + "assets/ui-prototype.png" => Some("design-foundation"), + "assets/art-spritesheet.png" => Some("art-asset-plan"), + _ => None, + }; + if expected_owner.is_some_and(|owner| owner != agent_id) { + return Err(format!( + "Project Supervisor 视觉委派不能跨 owner 声明固定产物:{artifact} 必须交给 {}", + expected_owner.expect("checked visual artifact owner") + )); + } + } let repair = input .get("repairOfDelegationId") .or_else(|| input.get("repair_of_delegation_id")) @@ -1802,4 +1852,24 @@ mod tests { .expect_err("dynamic child must not count as static delegate"); assert!(error.contains("静态专业 Agent")); } + + #[test] + fn supervisor_collaboration_policy_rejects_cross_owner_visual_artifacts() { + let mut action = delegate("art-director", None); + action.input["expectedArtifacts"] = serde_json::json!([ + "assets/art-spec.png", + "assets/ui-prototype.png", + "assets/art-spritesheet.png" + ]); + let error = preflight_supervisor_collaboration_plan( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &[action], + &SupervisorCollaborationPolicy::default(), + &SupervisorCollaborationState::default(), + ) + .expect_err("cross-owner visual artifacts must fail before dispatch"); + assert!(error.contains("不能跨 owner")); + assert!(error.contains("assets/ui-prototype.png")); + assert!(error.contains("design-foundation")); + } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 85497b903..614254bed 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -922,6 +922,9 @@ pub(crate) fn register_local_asset( task_id: trim_optional_string(task_id), prompt: trim_optional_string(prompt), model: trim_optional_string(model), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 150a3008a..5b06bb10d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -869,6 +869,7 @@ struct GeneratedPlatformArtAsset { asset_object_id: Option, task_id: Option, model: Option, + slice_warning: Option, } #[derive(Debug, Eq, PartialEq, Serialize)] @@ -1610,6 +1611,20 @@ fn main() { Err(_) => std::process::exit(125), } } + let game_chat_launch = match parse_game_chat_launch_args(&args) { + Ok(options) => options, + Err(error) => { + eprintln!("{error}"); + std::process::exit(1); + } + }; + #[cfg(not(debug_assertions))] + if game_chat_launch.is_some() { + eprintln!("--game-chat 仅在开发构建中可用"); + std::process::exit(1); + } + #[cfg(test)] + let _ = &game_chat_launch; let runtime_config_dir = match take_cli_runtime_config_dir(&mut args) { Ok(config_dir) => config_dir, Err(error) => { @@ -1679,7 +1694,7 @@ fn main() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_clipboard_manager::init()) .manage(game_creator_preview_registry()) - .setup(|app| { + .setup(move |app| { configure_game_creator_runtime_config_dir(app.handle())?; let config_dir = game_creator_runtime_config_dir().ok_or_else(|| { std::io::Error::new( @@ -1701,7 +1716,11 @@ fn main() { })?; set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); #[cfg(all(debug_assertions, not(test)))] - open_developer_window(app.handle())?; + if let Some(options) = game_chat_launch.as_ref() { + navigate_client_to_game_chat(app.handle(), options)?; + } else { + open_developer_window(app.handle())?; + } Ok(()) }) .invoke_handler(tauri::generate_handler![ diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 27dd50c66..ae54a9ba5 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -208,7 +208,7 @@ pub(crate) fn ensure_manifest_seed_tasks(root: &Path, manifest: &mut GameCreatio && visual_assets_required && matches!( seed_task.id.as_str(), - "design-foundation" | "art-asset-plan" + "art-director" | "design-foundation" | "art-asset-plan" ) && !visual_asset_ready { @@ -229,20 +229,113 @@ pub(crate) fn manifest_has_required_visual_asset( manifest: &GameCreationAppManifest, task_id: &str, ) -> bool { + validate_manifest_required_visual_asset(root, manifest, task_id).is_ok() +} + +pub(crate) fn validate_manifest_required_visual_asset( + root: &Path, + manifest: &GameCreationAppManifest, + task_id: &str, +) -> Result<(), String> { let (expected_path, expected_kind) = match task_id { + "art-director" => ("assets/art-spec.png", "icon-spec"), "design-foundation" => ("assets/ui-prototype.png", "ui-prototype"), "art-asset-plan" => ("assets/art-spritesheet.png", "art-spritesheet"), - _ => return true, + _ => return Ok(()), }; - manifest.assets.iter().any(|asset| { - asset.local_path == expected_path - && asset.kind == expected_kind - && asset.media_type.starts_with("image/") - && asset.source.kind == GameCreationAppAssetSourceKind::Canvas - && resolve_local_project_path(root, &asset.local_path) - .ok() - .is_some_and(|path| path.is_file()) - }) + let asset = manifest + .assets + .iter() + .find(|asset| asset.local_path == expected_path && asset.kind == expected_kind) + .ok_or_else(|| format!("缺少规范视觉资产:{expected_path} ({expected_kind})"))?; + if !asset.media_type.starts_with("image/") + || asset.source.kind != GameCreationAppAssetSourceKind::Canvas + { + return Err(format!("规范视觉资产文件或来源无效:{expected_path}")); + } + let bytes = resolve_local_project_path(root, &asset.local_path) + .ok() + .and_then(|path| fs::read(path).ok()) + .filter(|bytes| bytes.starts_with(b"\x89PNG\r\n\x1a\n")) + .ok_or_else(|| format!("规范视觉资产不是有效登记的 PNG 文件:{expected_path}"))?; + if task_id == "art-asset-plan" + && !image::load_from_memory(&bytes) + .ok() + .is_some_and(|image| image.to_rgba8().pixels().any(|pixel| pixel[3] < u8::MAX)) + { + return Err("首版美术素材图没有真实透明像素".to_string()); + } + let canvas_project_id = asset + .source + .canvas_project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("规范视觉资产缺少 canvasProjectId:{expected_path}"))?; + asset + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| format!("规范视觉资产缺少 resourceId:{expected_path}"))?; + + let expected_route = match task_id { + "art-asset-plan" => "/api/external/v1/editor/icon-spritesheets/generations", + _ => "/api/external/v1/editor/images/generations", + }; + let expected_generation_kind = match task_id { + "art-director" => "spec", + "design-foundation" => "ui-design", + "art-asset-plan" => "icon-spritesheet", + _ => unreachable!("non-visual tasks returned above"), + }; + if asset.source.generation_route.as_deref() != Some(expected_route) + || asset.source.generation_kind.as_deref() != Some(expected_generation_kind) + { + return Err(format!( + "规范视觉资产缺少匹配的持久生成来源,按 legacy 资产处理:{expected_path}" + )); + } + + if task_id == "art-director" { + if !asset.source.reference_resource_ids.is_empty() { + return Err("统一视觉规范图不得声明派生资源引用".to_string()); + } + return Ok(()); + } + + validate_manifest_required_visual_asset(root, manifest, "art-director")?; + let art_spec = manifest + .assets + .iter() + .find(|asset| asset.local_path == "assets/art-spec.png" && asset.kind == "icon-spec") + .ok_or_else(|| "缺少当前统一视觉规范图".to_string())?; + let art_spec_project_id = art_spec + .source + .canvas_project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "统一视觉规范图缺少 canvasProjectId".to_string())?; + let art_spec_resource_id = art_spec + .source + .resource_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "统一视觉规范图缺少 resourceId".to_string())?; + if canvas_project_id != art_spec_project_id { + return Err(format!( + "派生视觉资产与当前统一视觉规范图不属于同一画布项目:{expected_path}" + )); + } + if asset.source.reference_resource_ids.as_slice() != [art_spec_resource_id] { + return Err(format!( + "派生视觉资产未精确引用当前统一视觉规范图:{expected_path}" + )); + } + Ok(()) } pub(crate) fn set_task_status( diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs index 67c225b94..94cdd8b83 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/delegation.rs @@ -1223,6 +1223,7 @@ fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "UI 原型语义完成门禁测试") .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); register_canvas_visual_asset_fixture(&root, AGENT_RUNTIME_UI_PROTOTYPE_PATH, "ui-prototype"); let run_id = "design-ui-semantic-gate-run"; let state = start_game_creator_agent_runtime_task_at( @@ -1256,6 +1257,21 @@ fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() .as_deref() .is_some_and(|detail| detail.contains("requiredInspection=image.inspect"))); + append_ui_prototype_inspection_fixture_with_profile(&root, run_id, true, "ui-prototype.v1"); + let legacy_v1 = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "历史 v1 审计不能放行当前 run。", + revision, + &[], + ) + .expect("legacy v1 remains recoverable but not authoritative"); + assert!(matches!( + legacy_v1, + AgentBackgroundFinalizationOutcome::Stale(ref blocker) + if blocker.tool == "runtime.visual_asset" + )); + append_ui_prototype_inspection_fixture(&root, run_id, false); let scene_blocked = finish_game_creator_agent_background_runtime_turn_at( &root, @@ -1274,7 +1290,7 @@ fn design_foundation_rejects_scene_image_stale_run_and_stale_sha_visual_proofs() assert!(scene_blocker .detail .as_deref() - .is_some_and(|detail| detail.contains("resourceBar=false"))); + .is_some_and(|detail| detail.contains("informationHud=false"))); append_ui_prototype_inspection_fixture(&root, "another-design-run", true); let wrong_run = finish_game_creator_agent_background_runtime_turn_at( @@ -1592,6 +1608,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r ); init_local_game_project_at(&root, "project-canvas-repair-atomic", "月光厨房") .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -1751,7 +1768,7 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r let captured = request_receiver .recv_timeout(Duration::from_secs(15)) .expect("canvas request before generation response"); - if captured.starts_with("POST /api/external/v1/editor/images/generations ") { + if captured.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") { saw_generation_request = true; break; } @@ -1784,11 +1801,17 @@ async fn canvas_replacement_rejects_parent_run_that_terminates_during_external_r .expect("read revision after rejected commit"), revision_before ); + let leftover_replacement_files = fs::read_dir(root.join("assets")) + .expect("read assets directory") + .filter_map(Result::ok) + .filter(|entry| { + let name = entry.file_name(); + let name = name.to_string_lossy(); + name.contains(".replacement.") || name.contains(".previous.") + }) + .count(); assert_eq!( - fs::read_dir(root.join("assets")) - .expect("read assets directory") - .count(), - 1, + leftover_replacement_files, 0, "authorization failure must not leave replacement files" ); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs index 08e261524..f0580e9e2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/recovery.rs @@ -87,6 +87,12 @@ fn project_supervisor_parent_wake_singleflight_coalesces_late_signal() { assert!(static_delegate_parent_wake_error_is_transient( "runtime-wake-retryable: target run is still waiting" )); + assert!(autonomous_manifest_parent_wake_error_is_transient( + "项目正在被其他写操作占用:$PROJECT_ROOT/.agent/project.lock" + )); + assert!(!autonomous_manifest_parent_wake_error_is_transient( + "manifest JSON 已损坏" + )); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 7ea41b081..d04162240 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -52,14 +52,22 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { "assetKind=ui-prototype", "asset.list", "image.inspect", - "ui-prototype.v1", - "resourceBar", - "原创标题、阵营、资源、单位名称与视觉语言", - "Peashooter", + "ui-prototype.v2", + "informationHud", + "gameplaySurface", + "objectiveEntities", + "failureRestartFlow", + "responsiveLayout", + "不得假设为塔防", + "原创标题、实体、资源、目标名称与视觉语言", + "assets/art-spec.png", + "icon-spec", + "referenceImageSrcs 第一项", + "POST /api/external/v1/editor/images/generations(kind=ui-design)", + "不得误用 POST /api/external/v1/editor/ui-designs/assets/extractions", "canvas.asset_generate 成功动作本身就是当前 revision 的验证", - "已有同路径画布资产时先用 asset.list", - "检查已通过时不得删除、重复生成或再次扣费", - "file.delete", + "已有同路径画布资产时先核对登记", + "检查已通过时不得重复生成或再次扣费", "纯场景图", "不得把计划写完当成 completed", ] { @@ -69,23 +77,46 @@ fn visual_specialist_prompts_require_real_registered_image_deliveries() { ); } + let director_prompt = + game_creator_agent_runtime_tool_plan_system_prompt_for_agent("art-director"); + for expected in [ + "assets/art-spec.png", + "assetKind=icon-spec", + "POST /api/external/v1/editor/images/generations(kind=spec)", + "后续 UI 和透明图集共同引用", + "不得用 generationInputs.artSpec JSON", + "缺少 resourceId 时不得提交最终回复", + ] { + assert!( + director_prompt.contains(expected), + "art director prompt missing {expected}" + ); + } + let art_prompt = game_creator_agent_runtime_tool_plan_system_prompt_for_agent("art-asset-plan"); for expected in [ "资产清单和美术计划只是中间结果", "canvas.asset_generate", "assets/manifest.art.json", "assets/art-spritesheet.png", - "aspectRatio=1:1", - "imageSize=1K", + "固定使用 1:1、1K", "assetKind=art-spritesheet", - "该动作本身就是当前 revision 的验证", - "随后调用 asset.list", - "不得删除、重复生成或扣费", - "不需要调用 image.inspect 做主观返工", + "用 asset.list 确认 assets/art-spec.png 已登记", + "由 Runtime 形成 iconDescriptions", + "权威 resourceId 作为 referenceImageSrc", + "POST /api/external/v1/editor/icon-spritesheets/generations", + "screenColor=auto", + "不得回退普通生图", + "回读 observation 与 asset.list", + "真实 alpha", + "warning.code=postprocess-failed-source-preserved", + "不得登记、验收或自动重试", + "仅 sliceWarning", + "已有有效同路径资产时不得重复生成或扣费", "asset.list", "不得运行 game.static_smoke 或 preview.validate", "不得编辑 game/index.html", - "不得把计划写完当成 completed", + "透明证据不足时不得提交最终回复", ] { assert!( art_prompt.contains(expected), diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs index f8365d780..4e340a113 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/supervisor_planning.rs @@ -649,18 +649,18 @@ fn valid_autonomous_initial_responsibility_actions_for_test() -> Vec AgentRuntimeToolAction { AgentRuntimeToolAction { tool: "agent.delegate".to_string(), - reason: Some("委派美术 Agent 生成首版精灵图".to_string()), + reason: Some("委派美术总监生成统一视觉规范图".to_string()), input: serde_json::json!({ - "agentId": "art-asset-plan", - "task": "生成首版游戏美术资源并写入项目资产目录。", + "agentId": "art-director", + "task": "生成项目统一视觉规范图并写入项目资产目录。", "acceptanceCriteria": [ - "使用画布生成接口产出可直接用于游戏的精灵图", - "生成结果必须登记为项目本地资产" + "使用画布生成接口产出后续 UI 与图集共用的规范图", + "生成结果必须登记为项目本地 icon-spec 资产" ], "expectedArtifacts": expected_artifacts, "repairOfDelegationId": null, @@ -670,8 +670,7 @@ fn autonomous_art_responsibility_action_for_test( } #[tokio::test] -async fn supervisor_autonomous_initial_art_responsibility_requires_canonical_spritesheet_artifact() -{ +async fn supervisor_autonomous_initial_art_director_requires_canonical_art_spec_artifact() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -714,7 +713,7 @@ async fn supervisor_autonomous_initial_art_responsibility_requires_canonical_spr ) .expect("start autonomous Supervisor runtime"); let mut actions = valid_autonomous_initial_responsibility_actions_for_test(); - actions.push(autonomous_art_responsibility_action_for_test(&[ + actions.push(autonomous_art_director_responsibility_action_for_test(&[ "assets/art-preview.png", ])); let plan = supervisor_collaboration_plan_for_test(actions); @@ -731,15 +730,15 @@ async fn supervisor_autonomous_initial_art_responsibility_requires_canonical_spr &"e".repeat(64), ) .await - .expect("preflight art responsibility without canonical spritesheet"); + .expect("preflight art director responsibility without canonical art spec"); let AgentRuntimeProviderActionBatchPreparation::Blocked(observation) = preparation else { - panic!("art responsibility without canonical spritesheet must be blocked"); + panic!("art director responsibility without canonical art spec must be blocked"); }; assert_eq!(observation.tool, "runtime.collaboration_policy"); assert_eq!(observation.status, "blocked"); let detail = observation.detail.expect("art responsibility block detail"); - assert!(detail.contains("art-asset-plan")); - assert!(detail.contains("assets/art-spritesheet.png")); + assert!(detail.contains("art-director")); + assert!(detail.contains("assets/art-spec.png")); fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); @@ -1676,8 +1675,8 @@ fn supervisor_autonomous_game_build_without_project_policy_requires_builder_and_ } #[test] -fn supervisor_autonomous_game_build_with_editor_api_key_and_missing_art_asset_requires_art_delegate( -) { +fn supervisor_autonomous_game_build_with_editor_api_key_requires_visual_agents_in_dependency_order() +{ let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1730,18 +1729,97 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_missing_art_asset_re .iter() .map(String::as_str) .collect::>(), - BTreeSet::from(["code-prototype", "quality-review", "art-asset-plan"]) + BTreeSet::from(["code-prototype", "quality-review", "art-director"]) ); assert!(!root .join(SUPERVISOR_COLLABORATION_POLICY_RELATIVE_PATH) .exists()); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + let design_run_id = "supervisor-autonomous-design-after-art-spec-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + design_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile after art spec delivery"); + let design_resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + design_run_id, + ) + .expect("resolve autonomous collaboration policy after art spec delivery"); + assert_eq!(design_resolution.policy.min_static_delegates, 3); + assert_eq!( + design_resolution + .policy + .required_static_agent_ids + .iter() + .map(String::as_str) + .collect::>(), + BTreeSet::from(["code-prototype", "quality-review", "design-foundation"]) + ); + + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + let art_run_id = "supervisor-autonomous-art-after-ui-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + art_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile after UI delivery"); + let art_resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + art_run_id, + ) + .expect("resolve autonomous collaboration policy after UI delivery"); + assert_eq!(art_resolution.policy.min_static_delegates, 3); + assert_eq!( + art_resolution + .policy + .required_static_agent_ids + .iter() + .map(String::as_str) + .collect::>(), + BTreeSet::from(["code-prototype", "quality-review", "art-asset-plan"]) + ); + + register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + let complete_run_id = "supervisor-autonomous-after-all-visual-assets-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + complete_run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile after all visual deliveries"); + let complete_resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + complete_run_id, + ) + .expect("resolve autonomous collaboration policy after all visual deliveries"); + assert_eq!(complete_resolution.policy.min_static_delegates, 2); + assert_eq!( + complete_resolution.policy.required_static_agent_ids, + vec!["code-prototype".to_string(), "quality-review".to_string()] + ); + fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); } #[test] -fn supervisor_autonomous_game_build_augments_existing_project_policy_with_required_art_delegate() { +fn supervisor_autonomous_game_build_augments_existing_project_policy_with_required_art_director() { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1792,7 +1870,7 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir .iter() .map(String::as_str) .collect::>(), - BTreeSet::from(["art-asset-plan"]) + BTreeSet::from(["art-director"]) ); fs::remove_dir_all(root).ok(); @@ -1800,8 +1878,8 @@ fn supervisor_autonomous_game_build_augments_existing_project_policy_with_requir } #[test] -fn supervisor_autonomous_game_build_with_editor_api_key_and_existing_art_asset_skips_art_delegate() -{ +fn supervisor_autonomous_game_build_with_editor_api_key_and_all_visual_assets_skips_visual_delegate( +) { let root = unique_project_path(); let config_dir = unique_project_path(); fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); @@ -1823,6 +1901,8 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_existing_art_asset_s "自主构建已有美术资源协作策略测试", ) .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); let run_id = "supervisor-autonomous-existing-art-collaboration-run"; bind_game_creator_agent_runtime_run_profile_at( @@ -1884,3 +1964,63 @@ fn supervisor_autonomous_game_build_with_editor_api_key_and_existing_art_asset_s fs::remove_dir_all(root).ok(); fs::remove_dir_all(config_dir).ok(); } + +#[test] +fn supervisor_autonomous_legacy_visual_assets_do_not_skip_the_visual_delegate() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + fs::create_dir_all(&config_dir).expect("create isolated runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": "https://editor.example.test", + "apiKey": "editor-runtime-key" + } + }) + .to_string(), + ) + .expect("write runtime config with editor API key"); + let _config_guard = use_test_runtime_config_dir(config_dir.clone()); + init_local_game_project_at(&root, "legacy-visual-policy", "旧视觉来源协作策略") + .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + let manifest_path = root.join(".agent/manifest.json"); + let mut manifest = read_manifest_for_project(&root).expect("read visual manifest"); + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.generation_route = None; + art_spec.source.generation_kind = None; + write_manifest(&manifest_path, &manifest).expect("persist legacy source fixture"); + + let run_id = "supervisor-autonomous-legacy-visual-policy-run"; + bind_game_creator_agent_runtime_run_profile_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, + Some(AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD), + None, + ) + .expect("bind autonomous supervisor profile"); + let resolution = resolve_supervisor_collaboration_policy_for_run_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + run_id, + ) + .expect("resolve legacy visual collaboration policy"); + assert_eq!(resolution.policy.min_static_delegates, 3); + assert!(resolution + .policy + .required_static_agent_ids + .iter() + .any(|agent_id| agent_id == "art-director")); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs index 449645312..c0a8ff1d3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/mod.rs @@ -19,6 +19,12 @@ fn valid_test_png_bytes() -> Vec { .expect("valid 1x1 test png") } +fn transparent_test_png_bytes() -> Vec { + base64::engine::general_purpose::STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR4nGP4z8DAAAAEAQEARwbK3gAAAABJRU5ErkJggg==") + .expect("valid transparent 1x1 test png") +} + pub(crate) struct TestConfigGuard { _lock: StdMutexGuard<'static, ()>, path: PathBuf, @@ -2351,6 +2357,7 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( listener.local_addr().expect("mock canvas api addr") ); let signed_url = format!("{base_url}/signed/hero.png"); + let spritesheet_signed_url = format!("{base_url}/signed/spritesheet.png"); let projects_body = serde_json::json!({ "data": { "projects": [ @@ -2432,6 +2439,44 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( } }) .to_string(); + let icon_spritesheet_body = serde_json::json!({ + "spritesheetImageSrc": "/generated/canvas/spritesheet.png", + "spritesheetWidth": 64, + "spritesheetHeight": 64, + "iconImageSrcs": [], + "sliceWarning": { + "code": "insufficient-connected-components", + "reason": "测试图集保持整图,未生成独立切片。" + }, + "prompt": "原创游戏素材图集", + "actualPrompt": "纯色抠像背景的原创游戏素材图集", + "model": "gpt-image-2", + "provider": "VectorEngine", + "taskId": "task-1", + "priceMudPoints": 3, + "spritesheetResource": { + "resourceId": "resource-1", + "projectId": "canvas-project-1", + "imageSrc": "/generated/canvas/spritesheet.png", + "objectKey": "generated/canvas/spritesheet.png", + "assetObjectId": "asset-object-1", + "width": 64, + "height": 64, + "sourceType": "generated", + "prompt": "原创游戏素材图集", + "actualPrompt": "纯色抠像背景的原创游戏素材图集", + "model": "gpt-image-2", + "provider": "VectorEngine", + "taskId": "task-1", + "assetKind": "icon-spritesheet" + }, + "spritesheetAsset": { + "assetId": "asset-1", + "assetObjectId": "asset-object-1", + "assetKind": "icon-spritesheet" + } + }) + .to_string(); let read_body = serde_json::json!({ "read": { "provider": "aliyun-oss", @@ -2444,6 +2489,18 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( } }) .to_string(); + let spritesheet_read_body = serde_json::json!({ + "read": { + "provider": "aliyun-oss", + "bucket": "mock", + "endpoint": "mock", + "host": "mock", + "objectKey": "generated/canvas/spritesheet.png", + "expiresAt": "2026-06-25T00:10:00Z", + "signedUrl": spritesheet_signed_url + } + }) + .to_string(); std::thread::spawn(move || { let mut generation_response_gate = generation_response_gate; for _ in 0..expected_requests { @@ -2474,13 +2531,35 @@ fn spawn_mock_external_canvas_api_server_with_capture_and_generation_gate( .expect("release mock canvas generation response"); } ("application/json", generation_body.as_bytes().to_vec()) + } else if request + .starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") + { + assert!(normalized_request.contains("authorization: bearer ")); + if let Some(gate) = generation_response_gate.take() { + gate.recv_timeout(Duration::from_secs(5)) + .expect("release mock canvas generation response"); + } + ( + "application/json", + icon_spritesheet_body.as_bytes().to_vec(), + ) } else if request.starts_with( "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fhero.png ", ) { assert!(normalized_request.contains("authorization: bearer ")); ("application/json", read_body.as_bytes().to_vec()) + } else if request.starts_with( + "GET /api/external/v1/assets/read-url?objectKey=generated%2Fcanvas%2Fspritesheet.png ", + ) { + assert!(normalized_request.contains("authorization: bearer ")); + ( + "application/json", + spritesheet_read_body.as_bytes().to_vec(), + ) } else if request.starts_with("GET /signed/hero.png ") { ("image/png", valid_test_png_bytes()) + } else if request.starts_with("GET /signed/spritesheet.png ") { + ("image/png", transparent_test_png_bytes()) } else { ("text/plain", b"not found".to_vec()) }; @@ -2590,7 +2669,30 @@ fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &st let absolute_path = root.join(local_path); fs::create_dir_all(absolute_path.parent().expect("visual asset parent")) .expect("create visual asset fixture directory"); - fs::write(&absolute_path, valid_test_png_bytes()).expect("write visual asset fixture"); + let bytes = if kind == "art-spritesheet" { + transparent_test_png_bytes() + } else { + valid_test_png_bytes() + }; + fs::write(&absolute_path, bytes).expect("write visual asset fixture"); + let (generation_route, generation_kind, reference_resource_ids) = match kind { + "icon-spec" => ( + "/api/external/v1/editor/images/generations", + "spec", + Vec::new(), + ), + "ui-prototype" => ( + "/api/external/v1/editor/images/generations", + "ui-design", + vec!["resource-icon-spec".to_string()], + ), + "art-spritesheet" => ( + "/api/external/v1/editor/icon-spritesheets/generations", + "icon-spritesheet", + vec!["resource-icon-spec".to_string()], + ), + _ => ("", "", Vec::new()), + }; register_local_asset_at( root, local_path, @@ -2605,6 +2707,9 @@ fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &st task_id: Some(format!("task-{kind}")), prompt: Some("测试视觉资产".to_string()), model: Some("gpt-image-2".to_string()), + generation_route: (!generation_route.is_empty()).then(|| generation_route.to_string()), + generation_kind: (!generation_kind.is_empty()).then(|| generation_kind.to_string()), + reference_resource_ids, }, ) .expect("register canvas visual asset fixture"); @@ -2612,12 +2717,12 @@ fn register_canvas_visual_asset_fixture(root: &Path, local_path: &str, kind: &st fn ui_prototype_checks_fixture(passed: bool) -> serde_json::Value { serde_json::json!({ - "resourceBar": passed, - "unitCardTray": passed, - "battlefieldGrid": true, - "enemyEntryDirection": true, - "waveStatus": passed, + "informationHud": passed, + "gameplaySurface": true, + "objectiveEntities": true, "primaryControls": passed, + "failureRestartFlow": passed, + "responsiveLayout": passed, "implementationClarity": passed, "originalTheme": true, }) @@ -2629,7 +2734,7 @@ fn ui_prototype_assessment_fixture(passed: bool) -> String { "issues": if passed { Vec::::new() } else { - vec!["只有战场场景和来袭箭头,缺少资源栏、单位卡槽、波次状态与主要控件".to_string()] + vec!["只有场景和角色,缺少状态 HUD、主要操作、失败重开与移动端布局".to_string()] }, "summary": if passed { "八项 UI 原型检查全部通过。" @@ -2641,6 +2746,20 @@ fn ui_prototype_assessment_fixture(passed: bool) -> String { } fn append_ui_prototype_inspection_fixture(root: &Path, run_id: &str, passed: bool) { + append_ui_prototype_inspection_fixture_with_profile( + root, + run_id, + passed, + AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, + ); +} + +fn append_ui_prototype_inspection_fixture_with_profile( + root: &Path, + run_id: &str, + passed: bool, + validation_profile: &str, +) { let image_bytes = fs::read(root.join(AGENT_RUNTIME_UI_PROTOTYPE_PATH)).expect("read UI prototype fixture"); let image_sha256 = format!("{:x}", Sha256::digest(&image_bytes)); @@ -2663,7 +2782,7 @@ fn append_ui_prototype_inspection_fixture(root: &Path, run_id: &str, passed: boo "responseId": "resp_ui_prototype_fixture", "conclusionChars": 20, "inspectionKind": AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, - "validationProfile": AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, + "validationProfile": validation_profile, "passed": passed, "checks": ui_prototype_checks_fixture(passed), "issues": issues, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index f57e0a42b..af06a337c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -317,6 +317,88 @@ fn platform_art_asset_output_path_rejects_escape_overwrite_and_symlink() { fs::remove_dir_all(root).ok(); } +#[test] +fn canonical_visual_completion_requires_persisted_route_kind_and_current_spec_reference() { + let root = unique_project_path(); + init_local_game_project_at(&root, "visual-provenance", "视觉来源门禁") + .expect("init visual provenance project"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); + let mut manifest = read_manifest_for_project(&root).expect("read visual manifest"); + for task_id in ["art-director", "design-foundation", "art-asset-plan"] { + validate_manifest_required_visual_asset(&root, &manifest, task_id) + .unwrap_or_else(|error| panic!("{task_id} provenance should pass: {error}")); + } + + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec!["stale-art-spec-resource".to_string()]; + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "design-foundation") + .expect_err("stale UI reference must fail") + .contains("未精确引用当前统一视觉规范图") + ); + + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.reference_resource_ids = vec!["resource-icon-spec".to_string()]; + ui.source.generation_route = + Some("/api/external/v1/editor/icon-spritesheets/generations".to_string()); + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "design-foundation") + .expect_err("wrong UI route must fail") + .contains("legacy") + ); + + let ui = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/ui-prototype.png") + .expect("ui prototype asset"); + ui.source.generation_route = Some("/api/external/v1/editor/images/generations".to_string()); + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.generation_kind = Some("spec".to_string()); + assert!( + validate_manifest_required_visual_asset(&root, &manifest, "art-asset-plan") + .expect_err("wrong spritesheet kind must fail") + .contains("legacy") + ); + + let spritesheet = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spritesheet.png") + .expect("art spritesheet asset"); + spritesheet.source.generation_kind = Some("icon-spritesheet".to_string()); + let art_spec = manifest + .assets + .iter_mut() + .find(|asset| asset.local_path == "assets/art-spec.png") + .expect("art spec asset"); + art_spec.source.resource_id = Some("resource-icon-spec-v2".to_string()); + for task_id in ["design-foundation", "art-asset-plan"] { + assert!( + validate_manifest_required_visual_asset(&root, &manifest, task_id) + .unwrap_err() + .contains("未精确引用当前统一视觉规范图"), + "{task_id} must become stale after art-spec resource changes" + ); + } + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn background_agent_runtime_can_start_local_preview() { let root = unique_project_path(); @@ -549,6 +631,7 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let (canvas_sender, canvas_receiver) = mpsc::channel(); let canvas_base_url = spawn_mock_external_canvas_generation_api_server(Some(canvas_sender)); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -640,6 +723,9 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { .observations .iter() .any(|item| item.contains("canvas.asset_generate:ok · 已生成美术素材:"))); + assert!(runtime.observations.iter().any(|item| { + item.contains("透明图集可用,但自动切片未完成:测试图集保持整图,未生成独立切片。") + })); let revision = read_game_creator_agent_runtime_project_revision(&root) .expect("read generated art revision") .revision; @@ -663,19 +749,36 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { let manifest: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) .expect("manifest json"); - let asset = &manifest["assets"][0]; + let asset = manifest["assets"] + .as_array() + .and_then(|assets| { + assets + .iter() + .find(|asset| asset["localPath"].as_str() == Some("assets/art-spritesheet.png")) + }) + .expect("generated art spritesheet asset"); assert_eq!(asset["source"]["kind"], "canvas"); assert_eq!(asset["source"]["resourceId"], "resource-1"); + assert_eq!( + asset["source"]["generationRoute"], + "/api/external/v1/editor/icon-spritesheets/generations" + ); + assert_eq!(asset["source"]["generationKind"], "icon-spritesheet"); + assert_eq!( + asset["source"]["referenceResourceIds"], + serde_json::json!(["resource-icon-spec"]) + ); assert_eq!(asset["kind"], "art-spritesheet"); assert_eq!(asset["localPath"], "assets/art-spritesheet.png"); assert_eq!( fs::read(root.join(asset["localPath"].as_str().unwrap())).unwrap(), - valid_test_png_bytes() + transparent_test_png_bytes() ); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"recordType\":\"canvas.asset_generate\"")); assert!(agent_db.contains("\"recordType\":\"agent.runtime.canvas.asset_generate\"")); assert!(agent_db.contains("\"agentId\":\"art-asset-plan\"")); + assert!(agent_db.contains("测试图集保持整图,未生成独立切片。")); assert!(!agent_db.contains("editor-runtime-key")); let canvas_requests = (0..5) .map(|_| { @@ -686,13 +789,18 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { .collect::>(); let generation_request = canvas_requests .iter() - .find(|request| request.starts_with("POST /api/external/v1/editor/images/generations ")) + .find(|request| { + request.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") + }) .expect("canvas generation request"); for expected in [ + r#""referenceImageSrc":"resource-icon-spec""#, + r#""iconDescriptions":"#, + r#""screenColor":"auto""#, r#""projectId":"canvas-project-1""#, r#""assetFolderId":"folder-1""#, r#""canvasCompletion":"#, - r#""assetKind":"art-spritesheet""#, + r#""assetLabel":"游戏首版核心美术素材""#, r#""aspectRatio":"1:1""#, ] { assert!( @@ -705,6 +813,94 @@ async fn background_agent_runtime_can_generate_platform_art_asset() { fs::remove_dir_all(config_dir).ok(); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn canonical_art_spec_and_ui_requests_use_the_shared_reference_chain() { + async fn capture_generation_request( + root: &Path, + config_dir: &Path, + options: PlatformArtAssetGenerationOptions, + ) -> String { + let (request_sender, request_receiver) = mpsc::channel(); + let canvas_base_url = + spawn_mock_external_canvas_generation_api_server(Some(request_sender)); + fs::create_dir_all(config_dir).expect("create runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": canvas_base_url, + "apiKey": "editor-runtime-key" + } + }) + .to_string(), + ) + .expect("write runtime config"); + let _config_guard = use_test_runtime_config_dir(config_dir.to_path_buf()); + request_platform_art_asset_with_options_for_test(root, "原创贪吃蛇视觉", &options) + .await + .expect("prepare canonical visual request"); + (0..5) + .map(|_| { + request_receiver + .recv_timeout(Duration::from_secs(2)) + .expect("canvas api request") + }) + .find(|request| request.starts_with("POST ")) + .expect("generation request") + } + + let root = unique_project_path(); + let spec_config_dir = unique_project_path(); + init_local_game_project_at(&root, "project-visual-chain", "月光厨房").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: Vec::new(), + confirm_commands: Vec::new(), + agent_policies: BTreeMap::new(), + }, + ) + .expect("allow visual generation"); + let spec_request = capture_generation_request( + &root, + &spec_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/art-spec.png".to_string()), + asset_kind: "icon-spec".to_string(), + asset_label: "游戏统一视觉规范图".to_string(), + ..PlatformArtAssetGenerationOptions::default() + }, + ) + .await; + assert!(spec_request.starts_with("POST /api/external/v1/editor/images/generations ")); + assert!(spec_request.contains(r#""kind":"spec""#)); + assert!(spec_request.contains(r#""assetKind":"icon-spec""#)); + assert!(spec_request.contains(r#""referenceImageSrcs":[]"#)); + + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + let ui_config_dir = unique_project_path(); + let ui_request = capture_generation_request( + &root, + &ui_config_dir, + PlatformArtAssetGenerationOptions { + output_path: Some("assets/ui-prototype.png".to_string()), + aspect_ratio: "16:9".to_string(), + image_size: "2K".to_string(), + asset_kind: "ui-prototype".to_string(), + asset_label: "游戏横屏界面原型图".to_string(), + replace_existing: false, + }, + ) + .await; + assert!(ui_request.starts_with("POST /api/external/v1/editor/images/generations ")); + assert!(ui_request.contains(r#""kind":"ui-design""#)); + assert!(ui_request.contains(r#""referenceImageSrcs":["resource-icon-spec"]"#)); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(spec_config_dir).ok(); + fs::remove_dir_all(ui_config_dir).ok(); +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_manifest() { let root = unique_project_path(); @@ -717,6 +913,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m ); init_local_game_project_at(&root, "project-canvas-request-lock", "月光厨房") .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -763,7 +960,7 @@ async fn platform_art_external_request_does_not_hold_project_lock_or_overwrite_m let captured = request_receiver .recv_timeout(Duration::from_secs(15)) .expect("canvas request before generation response"); - if captured.starts_with("POST /api/external/v1/editor/images/generations ") { + if captured.starts_with("POST /api/external/v1/editor/icon-spritesheets/generations ") { saw_generation_request = true; break; } @@ -1224,6 +1421,9 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { task_id: Some("task-1".to_string()), prompt: Some("像素主角".to_string()), model: Some("image-model".to_string()), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) .expect("asset register"); @@ -1263,6 +1463,9 @@ fn register_local_asset_records_existing_asset_with_canvas_source() { task_id: None, prompt: None, model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) .expect("asset update"); @@ -1288,6 +1491,9 @@ fn register_local_asset_rejects_missing_or_unsafe_path() { task_id: None, prompt: None, model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }; assert!(register_local_asset_at( @@ -2536,6 +2742,109 @@ fn workspace_window_url_carries_encoded_project_path() { ); } +#[test] +fn game_chat_launch_args_are_strict_and_keep_normal_start_compatible() { + assert_eq!( + parse_game_chat_launch_args(&[]).expect("parse normal GUI start"), + None + ); + assert_eq!( + parse_game_chat_launch_args(&["--llm-status".to_string()]) + .expect("leave existing CLI command untouched"), + None + ); + assert_eq!( + parse_game_chat_launch_args(&["--game-chat".to_string()]) + .expect("parse game chat") + .expect("game chat options"), + GameChatLaunchOptions { + project_path: None, + initial_message: None, + } + ); + assert_eq!( + parse_game_chat_launch_args(&[ + "--game-chat".to_string(), + "--project-path".to_string(), + " /tmp/AI Game 项目 ".to_string(), + ]) + .expect("parse game chat project") + .expect("game chat project options"), + GameChatLaunchOptions { + project_path: Some("/tmp/AI Game 项目".to_string()), + initial_message: None, + } + ); + assert_eq!( + parse_game_chat_launch_args(&[ + "--game-chat".to_string(), + "--project-path".to_string(), + "/tmp/game".to_string(), + "--initial-message".to_string(), + "继续完成贪吃蛇".to_string(), + ]) + .expect("parse game chat initial message") + .expect("game chat initial message options"), + GameChatLaunchOptions { + project_path: Some("/tmp/game".to_string()), + initial_message: Some("继续完成贪吃蛇".to_string()), + } + ); + + for invalid in [ + vec!["--project-path", "/tmp/game"], + vec!["--game-chat", "--project-path"], + vec!["--game-chat", "--project-path", "relative-game"], + vec!["--game-chat", "--project-path", "/tmp/game\nnext"], + vec!["--game-chat", "--game-chat"], + vec!["--game-chat", "--initial-message", ""], + vec!["--game-chat", "--initial-message", "bad\nmessage"], + vec![ + "--game-chat", + "--initial-message", + "first", + "--initial-message", + "second", + ], + vec![ + "--game-chat", + "--project-path", + "/tmp/game", + "--project-path", + "/tmp/other", + ], + vec!["--game-chat", "--llm-status"], + vec!["--agent-run", "--game-chat"], + vec!["--game-chat", "--config-dir", "/tmp/appdata"], + ] { + let invalid = invalid.into_iter().map(str::to_string).collect::>(); + assert!( + parse_game_chat_launch_args(&invalid).is_err(), + "unexpected valid game chat args: {invalid:?}" + ); + } +} + +#[test] +fn game_chat_window_url_encodes_optional_project_path() { + assert_eq!( + game_chat_window_url(None, None).to_string(), + "index.html?game-chat" + ); + assert_eq!( + game_chat_window_url(Some("/tmp/AI Game 项目"), None).to_string(), + "index.html?game-chat&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE" + ); + assert_eq!( + game_chat_window_url(Some("/tmp/a&b?#%+c"), None).to_string(), + "index.html?game-chat&projectPath=%2Ftmp%2Fa%26b%3F%23%25%2Bc" + ); + assert_eq!( + game_chat_window_url(Some("/tmp/game"), Some("继续 & 验证")).to_string(), + "index.html?game-chat&projectPath=%2Ftmp%2Fgame&initialMessage=%E7%BB%A7%E7%BB%AD%20%26%20%E9%AA%8C%E8%AF%81" + ); +} + #[test] fn workspace_window_project_path_requires_absolute_path() { assert!(validate_workspace_window_project_path(" /tmp/game ").is_ok()); @@ -2832,22 +3141,21 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { replace_existing: false, }; let prompt = build_platform_art_asset_prompt( - "原创花园防守玩法,需要清楚的资源、波次和操作信息", + "原创网格贪吃蛇:分数与状态 HUD、四类不同分值食物、开始、方向键/WASD、触控方向键、失败与重开", &[], &options, ); for expected in [ "真正的游戏 UI/UX 原型图", - "资源数值与波次", - "单位卡牌", - "战场网格", - "敌人来袭方向", - "开始、暂停、重开控件", + "不得自行假设它属于塔防", + "状态 HUD", + "主要可玩区域", + "目标/收集物/危险物", + "失败状态与重新开始", + "键盘和触控提示", "禁止只画", - "玩法类型和机制词只用于理解功能", - "转换为新的原创命名", - "Peashooter", - "原创花园防守玩法", + "玩法机制只用于理解功能", + "原创网格贪吃蛇", ] { assert!( prompt.contains(expected), @@ -2860,12 +3168,13 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { assert_eq!(art_spec["format"], "16:9 2K"); for expected in [ "HUD", - "单位卡槽", - "波次", - "暂停", + "主要可玩区域", + "目标或收集物说明", + "失败与重开状态", + "不得假设项目属于塔防", "无 HUD 的场景插画", "原创标题", - "单位名", + "角色轮廓", ] { assert!( art_spec.to_string().contains(expected), @@ -2875,21 +3184,24 @@ fn ui_prototype_generation_uses_dedicated_prompt_and_art_spec() { } #[test] -fn art_spritesheet_generation_prompt_enforces_original_visual_language() { +fn art_spritesheet_generation_prompt_uses_current_game_instead_of_fixed_tower_defense() { let options = PlatformArtAssetGenerationOptions { output_path: Some("assets/art-spritesheet.png".to_string()), asset_kind: "art-spritesheet".to_string(), asset_label: "游戏首版核心美术素材".to_string(), ..PlatformArtAssetGenerationOptions::default() }; - let prompt = build_platform_art_asset_prompt("制作原创植物塔防小游戏", &[], &options); + let prompt = build_platform_art_asset_prompt( + "原创网格贪吃蛇,需要蛇头、直身、转角、尾部和四类食物", + &[], + &options, + ); for expected in [ - "原创核心美术素材图集", - "带脸向日葵", - "嘴状豌豆炮管", - "僵尸人形", + "原创透明核心美术素材图集", + "严格从用户需求和美术 brief 提取", + "不得自行假设为塔防", + "蛇头、直身、转角、尾部和四类食物", "不得把知名角色改色", - "晶体、菌丝、雾气、陶质器物、潮汐生态构装体", ] { assert!( prompt.contains(expected), @@ -2900,10 +3212,10 @@ fn art_spritesheet_generation_prompt_enforces_original_visual_language() { let art_spec = platform_art_asset_art_spec(&options).to_string(); for expected in [ "原创 Web 游戏素材图集", - "带脸向日葵", - "嘴状豌豆炮管", - "草坪横排", - "植物或生态主题不得直接画成", + "不预设塔防或其他固定玩法", + "玩家主体及状态", + "目标或收集物", + "不得擅自加入单位卡牌", ] { assert!(art_spec.contains(expected), "art spec missing {expected}"); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs index 97a8d81cb..4ff135ddf 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project_tools.rs @@ -5624,6 +5624,68 @@ fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() { fs::remove_dir_all(root).ok(); } +#[test] +fn steer_consumption_waits_across_a_legitimate_longer_project_write() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "steer lock wait").expect("project init"); + let lock = acquire_project_write_lock(&root, "test.steer.concurrent-writer") + .expect("acquire competing project lock"); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1_250)); + drop(lock); + }); + + let acquired = acquire_game_creator_agent_runtime_steer_project_write_lock_with_wait(&root) + .expect("steer consumption should outwait a legitimate project projection"); + drop(acquired); + release.join().expect("release competing project lock"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn provider_plan_waits_across_an_autonomous_manifest_wave_project_write() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "provider plan manifest lock wait") + .expect("project init"); + let lock = acquire_project_write_lock(&root, "runtime.autonomous.schedule_ready") + .expect("acquire manifest-wave project lock"); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1_250)); + drop(lock); + }); + + let acquired = acquire_game_creator_agent_provider_plan_project_write_lock_with_wait( + &root, + "runtime.provider_request.build.tool_plan", + ) + .expect("provider plan should outwait the manifest wave reservation"); + drop(acquired); + release.join().expect("release manifest-wave project lock"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn runtime_parallel_read_projection_waits_across_a_manifest_wave_project_write() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "parallel read projection lock wait") + .expect("project init"); + let lock = acquire_project_write_lock(&root, "runtime.autonomous.schedule_ready") + .expect("acquire manifest-wave project lock"); + let release = std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(1_250)); + drop(lock); + }); + + let acquired = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "runtime.parallel_read.project", + ) + .expect("parallel read projection should outwait the manifest wave reservation"); + drop(acquired); + release.join().expect("release manifest-wave project lock"); + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn agent_runtime_file_write_waits_for_short_parallel_project_writer() { let root = unique_project_path(); @@ -5963,6 +6025,9 @@ fn local_project_image_preview_obeys_auto_file_read_policy() { task_id: None, prompt: None, model: None, + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) .expect("register preview asset"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs index 231012c47..936a45962 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/provider.rs @@ -7213,7 +7213,13 @@ fn agent_native_function_catalog_exposes_each_runtime_tool_with_core_schemas() { ); assert_eq!( canvas_asset.parameters["properties"]["input"]["properties"]["assetKind"]["enum"], - serde_json::json!(["game-art", "ui-prototype", "art-spritesheet", null]) + serde_json::json!([ + "game-art", + "icon-spec", + "ui-prototype", + "art-spritesheet", + null + ]) ); let delegate_name = native_runtime_function_name("agent.delegate").expect("delegate name"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs index e8bdf8e91..3a9a597fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/response_stream.rs @@ -969,6 +969,7 @@ fn visual_specialist_finalization_requires_existing_registered_canvas_image() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "视觉 Runtime 完成门禁测试") .expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); let run_id = format!("visual-finalization-{agent_id}"); let state = start_game_creator_agent_runtime_task_at( &root, @@ -997,7 +998,7 @@ fn visual_specialist_finalization_requires_existing_registered_canvas_image() { _ => panic!("missing image must block finalization"), }; assert_eq!(blocker.tool, "runtime.visual_asset"); - assert!(blocker.summary.contains("尚未生成并登记")); + assert!(blocker.summary.contains("不能完成任务")); assert!(blocker .detail .as_deref() @@ -1006,7 +1007,15 @@ fn visual_specialist_finalization_requires_existing_registered_canvas_image() { let absolute_path = root.join(local_path); fs::create_dir_all(absolute_path.parent().expect("visual parent")) .expect("create unregistered visual directory"); - fs::write(&absolute_path, valid_test_png_bytes()).expect("write unregistered visual image"); + fs::write( + &absolute_path, + if kind == "art-spritesheet" { + transparent_test_png_bytes() + } else { + valid_test_png_bytes() + }, + ) + .expect("write unregistered visual image"); let unregistered = finish_game_creator_agent_background_runtime_turn_at( &root, state.clone(), @@ -1035,9 +1044,60 @@ fn visual_specialist_finalization_requires_existing_registered_canvas_image() { task_id: Some(format!("task-{kind}")), prompt: Some("测试视觉资产".to_string()), model: Some("gpt-image-2".to_string()), + generation_route: None, + generation_kind: None, + reference_resource_ids: Vec::new(), }, ) - .expect("register required canvas image"); + .expect("register legacy canvas image"); + let legacy = finish_game_creator_agent_background_runtime_turn_at( + &root, + state.clone(), + "旧文件不能冒充正式视觉产物", + revision, + &[], + ) + .expect("legacy image is a recoverable blocker"); + assert!(matches!( + legacy, + AgentBackgroundFinalizationOutcome::Stale(ref blocker) + if blocker.tool == "runtime.visual_asset" + && blocker.detail.as_deref().is_some_and(|detail| detail.contains("legacy")) + )); + register_local_asset_at( + &root, + local_path, + kind, + "image/png", + "canvas", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some("canvas-project-1".to_string()), + resource_id: Some(format!("resource-{kind}")), + asset_object_id: Some(format!("asset-object-{kind}")), + task_id: Some(format!("task-{kind}")), + prompt: Some("测试视觉资产".to_string()), + model: Some("gpt-image-2".to_string()), + generation_route: Some( + if kind == "ui-prototype" { + "/api/external/v1/editor/images/generations" + } else { + "/api/external/v1/editor/icon-spritesheets/generations" + } + .to_string(), + ), + generation_kind: Some( + if kind == "ui-prototype" { + "ui-design" + } else { + "icon-spritesheet" + } + .to_string(), + ), + reference_resource_ids: vec!["resource-icon-spec".to_string()], + }, + ) + .expect("register provenanced canvas image"); if agent_id == "design-foundation" { append_ui_prototype_inspection_fixture(&root, &run_id, true); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs index 118a39c7b..e4cf0c63e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/action_execution.rs @@ -748,7 +748,10 @@ async fn background_agent_runtime_can_schedule_ready_manifest_tasks() { async fn background_agent_runtime_can_schedule_ready_tasks_from_tool() { let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + update_manifest_task_status_at(&root, "art-director", GameCreationAppTaskStatus::Completed) + .expect("complete art spec dependency fixture"); write_project_permission_policy_at( &root, ProjectPermissionPolicy { @@ -795,7 +798,7 @@ async fn background_agent_runtime_can_schedule_ready_tasks_from_tool() { "reason": "完成前核对固定路径图片是否是真正的 UI 原型", "input": { "paths": ["assets/ui-prototype.png"], - "question": "执行 ui-prototype.v1 八项完成检查" + "question": "执行 ui-prototype.v2 八项完成检查" } } ], @@ -863,7 +866,7 @@ async fn background_agent_runtime_can_schedule_ready_tasks_from_tool() { let foundation_inspection_request = foundation_receiver .recv_timeout(Duration::from_secs(2)) .expect("foundation UI prototype inspection request"); - assert!(foundation_inspection_request.contains("resourceBar")); + assert!(foundation_inspection_request.contains("informationHud")); assert!(foundation_inspection_request.contains("assets/ui-prototype.png")); let foundation_update_request = foundation_receiver .recv_timeout(Duration::from_secs(2)) @@ -1880,7 +1883,17 @@ async fn design_ui_image_inspect_fails_scene_and_persists_canonical_checks() { let provider_request = receiver .recv_timeout(Duration::from_secs(2)) .expect("UI inspection provider request"); - assert!(provider_request.contains("resourceBar")); + assert!(provider_request.contains("informationHud")); + assert!(provider_request.contains("不要假设游戏一定属于塔防")); + for legacy_key in [ + "resourceBar", + "unitCardTray", + "battlefieldGrid", + "enemyEntryDirection", + "waveStatus", + ] { + assert!(!provider_request.contains(legacy_key)); + } assert!(provider_request.contains("不能依据文件名")); let records = read_agent_db_records_for_test(&root); @@ -1895,8 +1908,8 @@ async fn design_ui_image_inspect_fails_scene_and_persists_canonical_checks() { AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE ); assert_eq!(audit["passed"], false); - assert_eq!(audit["checks"]["resourceBar"], false); - assert_eq!(audit["checks"]["battlefieldGrid"], true); + assert_eq!(audit["checks"]["informationHud"], false); + assert_eq!(audit["checks"]["gameplaySurface"], true); assert!(audit["issues"] .as_array() .is_some_and(|issues| !issues.is_empty())); @@ -1914,6 +1927,61 @@ async fn design_ui_image_inspect_fails_scene_and_persists_canonical_checks() { fs::remove_dir_all(root).ok(); } +#[test] +fn image_inspect_safe_receipt_keeps_legacy_v1_audit_readable() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "旧 UI 审计兼容测试").expect("project init"); + let observation = AgentRuntimeToolObservation { + tool: "image.inspect".to_string(), + status: "ok".to_string(), + summary: "旧 UI 原型视觉检查已通过".to_string(), + detail: Some( + serde_json::json!({ + "images": [{ + "path": "assets/ui-prototype.png", + "sha256": "a".repeat(64), + "bytes": 1024, + }], + "responseId": "resp_legacy_ui_audit", + "conclusionChars": 20, + "conclusion": "历史 v1 审计只用于读取。", + "inspectionKind": AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, + "validationProfile": AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, + "passed": true, + "checks": { + "resourceBar": true, + "unitCardTray": true, + "battlefieldGrid": true, + "enemyEntryDirection": true, + "waveStatus": true, + "primaryControls": true, + "implementationClarity": true, + "originalTheme": true, + }, + "issues": [], + }) + .to_string(), + ), + }; + + let safe = agent_runtime_action_receipt_safe_detail_for_owner_for_test( + &root, + "design-foundation", + "legacy-ui-audit-run", + &observation, + ) + .expect("legacy v1 receipt remains safely readable"); + let safe = serde_json::from_str::(&safe).expect("parse safe legacy detail"); + assert_eq!( + safe["validationProfile"], + AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE + ); + assert_eq!(safe["checks"]["resourceBar"], true); + assert!(safe.get("conclusion").is_none()); + + fs::remove_dir_all(root).ok(); +} + #[test] fn seed_refresh_downgrades_completed_visual_tasks_when_registered_file_is_missing() { let _config_guard = crate::tests::write_test_local_config( @@ -1921,11 +1989,12 @@ fn seed_refresh_downgrades_completed_visual_tasks_when_registered_file_is_missin ); let root = unique_project_path(); init_local_game_project_at(&root, "project-1", "旧视觉任务升级测试").expect("project init"); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); let mut manifest = read_manifest_for_project(&root).expect("manifest with visual assets"); - for task_id in ["design-foundation", "art-asset-plan"] { + for task_id in ["art-director", "design-foundation", "art-asset-plan"] { manifest .tasks .iter_mut() diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs index ba3a41b23..909dccd90 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/planning_strategy/autonomous_build.rs @@ -446,13 +446,18 @@ fn autonomous_visual_gate_degrades_to_text_without_key_and_requires_images_with_ br#"{"assets":[],"degradedToText":true}"#, ) .expect("write text art manifest"); - for task_id in ["design-foundation", "art-asset-plan"] { + for task_id in ["art-director", "design-foundation", "art-asset-plan"] { update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) .expect("complete text-only visual owner task"); } + assert!(!root.join("assets/art-spec.png").exists()); assert!(!root.join("assets/ui-prototype.png").exists()); assert!(!root.join("assets/art-spritesheet.png").exists()); let without_key = autonomous_seed_task_statuses_for_test(&root); + assert_eq!( + without_key.get("art-director"), + Some(&GameCreationAppTaskStatus::Completed) + ); assert_eq!( without_key.get("design-foundation"), Some(&GameCreationAppTaskStatus::Completed) @@ -464,6 +469,10 @@ fn autonomous_visual_gate_degrades_to_text_without_key_and_requires_images_with_ write_autonomous_editor_api_config_for_test(&config_dir, "editor-runtime-test-key"); let with_key_missing_images = autonomous_seed_task_statuses_for_test(&root); + assert_eq!( + with_key_missing_images.get("art-director"), + Some(&GameCreationAppTaskStatus::Pending) + ); assert_eq!( with_key_missing_images.get("design-foundation"), Some(&GameCreationAppTaskStatus::Pending) @@ -473,13 +482,18 @@ fn autonomous_visual_gate_degrades_to_text_without_key_and_requires_images_with_ Some(&GameCreationAppTaskStatus::Pending) ); + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); register_canvas_visual_asset_fixture(&root, "assets/art-spritesheet.png", "art-spritesheet"); - for task_id in ["design-foundation", "art-asset-plan"] { + for task_id in ["art-director", "design-foundation", "art-asset-plan"] { update_manifest_task_status_at(&root, task_id, GameCreationAppTaskStatus::Completed) .expect("complete visual owner task with registered image"); } let with_key_and_images = autonomous_seed_task_statuses_for_test(&root); + assert_eq!( + with_key_and_images.get("art-director"), + Some(&GameCreationAppTaskStatus::Completed) + ); assert_eq!( with_key_and_images.get("design-foundation"), Some(&GameCreationAppTaskStatus::Completed) diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs index 5af24a99a..0f172d891 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_actions/support.rs @@ -31,10 +31,12 @@ pub(super) use super::super::{ }; pub(super) use crate::{ - advance_game_creator_agent_runtime_turn_at, agent_runtime_contains_secret_key_prefix, - agent_runtime_executable_tools, agent_runtime_read_only_delivery_completion_plan_update, - agent_runtime_run_profile_identity_at, agent_runtime_tool_action_fingerprint, - agent_runtime_tool_action_id, agent_runtime_tool_policy_snapshot_for_run_at, + advance_game_creator_agent_runtime_turn_at, + agent_runtime_action_receipt_safe_detail_for_owner_for_test, + agent_runtime_contains_secret_key_prefix, agent_runtime_executable_tools, + agent_runtime_read_only_delivery_completion_plan_update, agent_runtime_run_profile_identity_at, + agent_runtime_tool_action_fingerprint, agent_runtime_tool_action_id, + agent_runtime_tool_policy_snapshot_for_run_at, agent_runtime_tool_requires_pending_revision_gate, agent_runtime_tool_requires_repository_context_fingerprint_gate, agent_runtime_verified_delivery_completion_plan_update, append_agent_db_record, @@ -104,7 +106,9 @@ pub(super) use crate::{ AGENT_RUNTIME_RESPOND_FUNCTION_NAME, AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD, AGENT_RUNTIME_RUN_PROFILE_STANDARD, AGENT_RUNTIME_SCHEMA_VERSION, AGENT_RUNTIME_SUPERVISOR_CLI_SOURCE, AGENT_RUNTIME_SUPERVISOR_GUI_SOURCE, - AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION, AGENT_RUNTIME_UI_PROTOTYPE_PATH, + AGENT_RUNTIME_TOOL_OBSERVATION_STATUS_NEEDS_RECONCILIATION, + AGENT_RUNTIME_UI_PROTOTYPE_INSPECTION_KIND, + AGENT_RUNTIME_UI_PROTOTYPE_LEGACY_VALIDATION_PROFILE, AGENT_RUNTIME_UI_PROTOTYPE_PATH, AGENT_RUNTIME_UI_PROTOTYPE_VALIDATION_PROFILE, AGENT_RUNTIME_UPDATE_PLAN_FUNCTION_NAME, GAME_CREATOR_CONFIG_FILE_NAME, GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, GAME_CREATOR_USER_INPUT_REQUEST_TOOL, PROJECT_BLACKBOARD_MEMORY_PATH, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index b7ed4d4cc..78bd26122 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -2987,17 +2987,23 @@ async fn task_update_requires_registered_visual_asset_before_completion() { r#"{"editorApi":{"apiKey":"visual-task-update-test-key"}}"#.to_string(), ); for (task_id, local_path, kind, missing_summary) in [ + ( + "art-director", + "assets/art-spec.png", + "icon-spec", + "统一视觉规范图尚未按正式视觉流程生成并登记", + ), ( "design-foundation", "assets/ui-prototype.png", "ui-prototype", - "策划界面原型图尚未生成并登记", + "策划界面原型图尚未按正式视觉流程生成并登记", ), ( "art-asset-plan", "assets/art-spritesheet.png", "art-spritesheet", - "首版美术素材图尚未生成并登记", + "首版美术素材图尚未按正式视觉流程生成并登记", ), ] { let root = unique_project_path(); @@ -3023,7 +3029,7 @@ async fn task_update_requires_registered_visual_asset_before_completion() { }; let missing = execute_game_creator_agent_runtime_tool_action_with_action_id( &root, - "art-director", + task_id, &run_id, "完成视觉任务", &action, @@ -3043,12 +3049,18 @@ async fn task_update_requires_registered_visual_asset_before_completion() { GameCreationAppTaskStatus::Pending ); + if task_id != "art-director" { + register_canvas_visual_asset_fixture(&root, "assets/art-spec.png", "icon-spec"); + } + if task_id == "art-asset-plan" { + register_canvas_visual_asset_fixture(&root, "assets/ui-prototype.png", "ui-prototype"); + } register_canvas_visual_asset_fixture(&root, local_path, kind); if task_id == "design-foundation" { append_ui_prototype_inspection_fixture(&root, &run_id, false); let scene_rejected = execute_game_creator_agent_runtime_tool_action_with_action_id( &root, - "art-director", + task_id, &run_id, "拒绝用场景图完成 UI 原型任务", &action, @@ -3072,7 +3084,7 @@ async fn task_update_requires_registered_visual_asset_before_completion() { } let completed = execute_game_creator_agent_runtime_tool_action_with_action_id( &root, - "art-director", + task_id, &run_id, "完成视觉任务", &action, diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index c917b66e0..70428d311 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -1,5 +1,61 @@ use super::*; +const GAME_CHAT_LAUNCH_USAGE: &str = + "用法:--game-chat [--project-path <本地项目绝对路径>] [--initial-message <首条消息>]"; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) struct GameChatLaunchOptions { + pub(crate) project_path: Option, + pub(crate) initial_message: Option, +} + +pub(crate) fn parse_game_chat_launch_args( + args: &[String], +) -> Result, String> { + let has_game_chat_arg = args.iter().any(|arg| { + matches!( + arg.as_str(), + "--game-chat" | "--project-path" | "--initial-message" + ) + }); + if !has_game_chat_arg { + return Ok(None); + } + if args.first().map(String::as_str) != Some("--game-chat") { + return Err(GAME_CHAT_LAUNCH_USAGE.to_string()); + } + let mut project_path = None; + let mut initial_message = None; + let mut index = 1; + while index < args.len() { + let flag = args[index].as_str(); + let value = args + .get(index + 1) + .ok_or_else(|| GAME_CHAT_LAUNCH_USAGE.to_string())?; + match flag { + "--project-path" if project_path.is_none() => { + project_path = Some(validate_workspace_window_project_path(value)?.to_string()); + } + "--initial-message" if initial_message.is_none() => { + let value = value.trim(); + if value.is_empty() + || value.chars().count() > 4_000 + || value.chars().any(char::is_control) + { + return Err(GAME_CHAT_LAUNCH_USAGE.to_string()); + } + initial_message = Some(value.to_string()); + } + _ => return Err(GAME_CHAT_LAUNCH_USAGE.to_string()), + } + index += 2; + } + Ok(Some(GameChatLaunchOptions { + project_path, + initial_message, + })) +} + pub(crate) fn workspace_window_url(project_path: &str) -> tauri::WebviewUrl { tauri::WebviewUrl::App(PathBuf::from(format!( "index.html?main&projectPath={}", @@ -22,6 +78,29 @@ pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUr ))) } +#[cfg(test)] +pub(crate) fn game_chat_window_url( + project_path: Option<&str>, + initial_message: Option<&str>, +) -> tauri::WebviewUrl { + let query = game_chat_window_query(project_path, initial_message); + tauri::WebviewUrl::App(PathBuf::from(format!("index.html?{query}"))) +} + +#[cfg(any(debug_assertions, test))] +fn game_chat_window_query(project_path: Option<&str>, initial_message: Option<&str>) -> String { + let mut query = "game-chat".to_string(); + if let Some(project_path) = project_path { + query.push_str("&projectPath="); + query.push_str(&percent_encode_query_value(project_path)); + } + if let Some(initial_message) = initial_message { + query.push_str("&initialMessage="); + query.push_str(&percent_encode_query_value(initial_message)); + } + query +} + pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> { let project_path = project_path.trim(); if project_path.is_empty() { @@ -150,3 +229,22 @@ pub(crate) fn open_developer_window(app: &tauri::AppHandle) -> Result<(), String .map_err(|error| error.to_string())?; Ok(()) } + +#[cfg(all(debug_assertions, not(test)))] +pub(crate) fn navigate_client_to_game_chat( + app: &tauri::AppHandle, + options: &GameChatLaunchOptions, +) -> Result<(), String> { + let client = app + .get_webview_window("client") + .ok_or_else(|| "找不到 AI 游戏创作客户端窗口".to_string())?; + let mut url = client.url().map_err(|error| error.to_string())?; + url.set_path("/index.html"); + url.set_query(Some(&game_chat_window_query( + options.project_path.as_deref(), + options.initial_message.as_deref(), + ))); + url.set_fragment(None); + client.navigate(url).map_err(|error| error.to_string())?; + Ok(()) +} diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index cb13791ee..06dca00fb 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -64,6 +64,7 @@ import type { LocalGameMemoryResult, LocalPreviewResult, LocalPreviewStatus, + LocalProjectDirectoryStatus, LocalProjectCheckpointResult, LocalProjectCheckpointSummary, LocalProjectDiffResult, @@ -220,13 +221,106 @@ import { import { handleProjectSummaryChatCommand } from './features/project-workspace/projectSummaryCommands'; import { ProjectSupervisorView } from './features/project-workspace/ProjectSupervisorView'; import { ProjectWorkspaceChatPane } from './features/project-workspace/ProjectWorkspaceChatPane'; -import { SupervisorChatOnlyView } from './features/project-workspace/SupervisorChatOnlyView'; +import { resolveEmbeddedPreviewUrl } from './features/project-workspace/LocalGamePreviewFrame'; +import { + buildGameChatProgressEvidence, + collectGameChatResultImages, + formatGameChatStageRecord, + SupervisorChatOnlyView, +} from './features/project-workspace/SupervisorChatOnlyView'; import { RuntimeConfigDialog } from './features/runtime-config/RuntimeConfigDialog'; import { type ProjectAgentResultSummary, type ProjectAgentRuntimeSummary, } from './view/project-development'; +const initialSupervisorMessageClaimsByPage = new WeakMap>(); +const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = + 'genarrative.game-chat.auto-preview-authorization.v1'; + +type GameChatAutoPreviewAuthorization = { + projectPath: string; + runId: string; +}; + +function readStoredGameChatAutoPreviewAuthorization(): GameChatAutoPreviewAuthorization | null { + try { + const raw = window.localStorage.getItem( + GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, + ); + if (!raw) { + return null; + } + const parsed = JSON.parse(raw) as Partial; + const projectPath = parsed.projectPath?.trim() ?? ''; + const runId = parsed.runId?.trim() ?? ''; + if ( + !projectPath || + !runId || + !isAbsoluteProjectPath(projectPath) || + projectPathHasControlCharacter(projectPath) || + projectPathHasControlCharacter(runId) + ) { + window.localStorage.removeItem( + GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, + ); + return null; + } + return { projectPath, runId }; + } catch { + return null; + } +} + +function storeGameChatAutoPreviewAuthorization( + authorization: GameChatAutoPreviewAuthorization | null, +) { + try { + if (authorization) { + window.localStorage.setItem( + GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, + JSON.stringify(authorization), + ); + } else { + window.localStorage.removeItem( + GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY, + ); + } + } catch { + // A volatile in-memory authorization remains sufficient when storage is unavailable. + } +} + +function claimInitialSupervisorMessageForPage(projectPath: string) { + let claimedProjectPaths = initialSupervisorMessageClaimsByPage.get(window); + if (!claimedProjectPaths) { + claimedProjectPaths = new Set(); + initialSupervisorMessageClaimsByPage.set(window, claimedProjectPaths); + } + if (claimedProjectPaths.has(projectPath)) { + return false; + } + claimedProjectPaths.add(projectPath); + return true; +} + +export function consumeInitialGameChatMessage( + searchParams: URLSearchParams, + location: Pick, + replaceUrl: (url: string) => void, +) { + const message = searchParams.get('initialMessage')?.trim() ?? ''; + if (!message) { + return ''; + } + searchParams.delete('initialMessage'); + const remainingSearch = searchParams.toString(); + replaceUrl( + `${location.pathname}${remainingSearch ? `?${remainingSearch}` : ''}${location.hash}`, + ); + return message; +} + export { AuthenticatedClient } from './app/AuthenticatedClient'; export type { PendingCommand } from './app/types'; export { @@ -252,6 +346,8 @@ type AppProps = { initialProjectManifest?: GameCreationAppManifest; projectSupervisorOnly?: boolean; supervisorChatOnly?: boolean; + gameChatOnly?: boolean; + initialSupervisorMessage?: string; onPreviewChange?: (preview: GameCreationAppPreviewState | null) => void; onAgentRuntimeSummariesChange?: ( summaries: ProjectAgentRuntimeSummary[], @@ -264,6 +360,8 @@ export function App({ initialProjectManifest, projectSupervisorOnly = false, supervisorChatOnly = false, + gameChatOnly = false, + initialSupervisorMessage = '', onPreviewChange, onAgentRuntimeSummariesChange, onAgentResultsChange, @@ -274,12 +372,14 @@ export function App({ const [initialProjectPath] = useState( () => initialProjectPathOverride || readInitialProjectPath(), ); + const eagerSupervisorProject = + projectSupervisorOnly && Boolean(initialProjectPath) && !gameChatOnly; const [projectPath, setProjectPath] = useState( initialProjectPath || defaultProjectPath, ); const [localProject, setLocalProject] = useState(() => - projectSupervisorOnly && initialProjectPath + eagerSupervisorProject ? { projectPath: initialProjectPath, manifestPath: `${initialProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`, @@ -293,10 +393,28 @@ export function App({ initialProjectManifest ?? seedManifest, ); const [projectStatus, setProjectStatus] = useState( - projectSupervisorOnly && initialProjectPath ? '已初始化' : '未初始化', + eagerSupervisorProject ? '已初始化' : '未初始化', ); const [preview, setPreview] = useState(null); + const gameChatPreviewRef = useRef(null); + gameChatPreviewRef.current = preview; const [previewStatus, setPreviewStatus] = useState('未启动'); + const previewStatusRef = useRef(previewStatus); + previewStatusRef.current = previewStatus; + const [gameChatProjectSelectionBusy, setGameChatProjectSelectionBusy] = + useState(false); + const gameChatProjectSelectionVersionRef = useRef(0); + const gameChatAutoPreviewAuthorizationRef = + useRef( + readStoredGameChatAutoPreviewAuthorization(), + ); + const gameChatAutoPreviewAttemptedRef = useRef(new Set()); + const gameChatObservedRunKeysRef = useRef(new Set()); + const gameChatArchivedRunKeysRef = useRef(new Set()); + const initialSupervisorMessageLatchRef = useRef({ + projectPath: initialProjectPath, + prompt: initialSupervisorMessage.trim(), + }); function updateClientPreview( nextPreview: LocalPreviewResult | null, @@ -314,7 +432,7 @@ export function App({ ); } const [chatInput, setChatInput] = useState(() => - supervisorChatOnly && initialProjectPath + (supervisorChatOnly || gameChatOnly) && initialProjectPath ? readSupervisorChatDraft(initialProjectPath) : '', ); @@ -328,7 +446,7 @@ export function App({ useState(null); const [projectSupervisorRuntimeError, setProjectSupervisorRuntimeError] = useState(''); - const [projectSupervisorRetryRunId, setProjectSupervisorRetryRunId] = + const [projectSupervisorExpectedRunId, setProjectSupervisorExpectedRunId] = useState(null); const chatInputRef = useRef(null); const supervisorChatMessagesRef = useRef(null); @@ -390,9 +508,7 @@ export function App({ const [llmConfigStatus, setLlmConfigStatus] = useState(null); const [workspaceStatus, setWorkspaceStatus] = useState( - projectSupervisorOnly && initialProjectPath - ? `已打开:${initialProjectPath}` - : '请选择工作区', + eagerSupervisorProject ? `已打开:${initialProjectPath}` : '请选择工作区', ); const [selectedAgent, setSelectedAgent] = useState( null, @@ -444,12 +560,10 @@ export function App({ const [projectLogContent, setProjectLogContent] = useState(''); const [conversationWriteVersion, setConversationWriteVersion] = useState(0); const savedConversationCountRef = useRef( - projectSupervisorOnly && initialProjectPath - ? createDefaultChatMessages().length - : 0, + eagerSupervisorProject ? createDefaultChatMessages().length : 0, ); const savedConversationProjectPathRef = useRef( - projectSupervisorOnly && initialProjectPath ? initialProjectPath : null, + eagerSupervisorProject ? initialProjectPath : null, ); const projectConversationWriteConfirmedRef = useRef(null); const projectConversationWriteCancelledRef = useRef<{ @@ -467,8 +581,8 @@ export function App({ projectSupervisorSessionIdRef.current = projectSupervisorSessionId; const projectSupervisorRuntimeRef = useRef(null); projectSupervisorRuntimeRef.current = projectSupervisorRuntime; - const projectSupervisorRetryRunIdRef = useRef(null); - projectSupervisorRetryRunIdRef.current = projectSupervisorRetryRunId; + const projectSupervisorExpectedRunIdRef = useRef(null); + projectSupervisorExpectedRunIdRef.current = projectSupervisorExpectedRunId; const projectSupervisorResponseStreamRef = useRef(null); projectSupervisorResponseStreamRef.current = projectSupervisorResponseStream; @@ -493,12 +607,19 @@ export function App({ const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); function requestRuntimeConfigOpen() { - if (projectSupervisorOnly && !supervisorChatOnly) { + if (projectSupervisorOnly && !supervisorChatOnly && !gameChatOnly) { return; } setRuntimeConfigOpen(true); } + function setGameChatAutoPreviewAuthorization( + authorization: GameChatAutoPreviewAuthorization | null, + ) { + gameChatAutoPreviewAuthorizationRef.current = authorization; + storeGameChatAutoPreviewAuthorization(authorization); + } + function updateProjectSupervisorRuntime( runtime: AgentRuntimeState | null, previous = projectSupervisorRuntimeRef.current, @@ -506,6 +627,17 @@ export function App({ const nextRuntime = runtime ? normalizeAgentRuntimeState(runtime, previous) : null; + const nextProjectPath = localProjectPathRef.current; + if ( + gameChatOnly && + nextProjectPath && + nextRuntime?.runId && + !isAgentRuntimeTerminalState(nextRuntime) + ) { + gameChatObservedRunKeysRef.current.add( + `${nextProjectPath}\n${nextRuntime.runId}`, + ); + } projectSupervisorRuntimeRef.current = nextRuntime; setProjectSupervisorRuntime(nextRuntime); } @@ -542,12 +674,12 @@ export function App({ projectSupervisorRuntimeResumeProjectPathRef.current = null; projectSupervisorSessionIdRef.current = null; projectSupervisorRuntimeRef.current = null; - projectSupervisorRetryRunIdRef.current = null; + projectSupervisorExpectedRunIdRef.current = null; projectSupervisorResponseStreamRef.current = null; projectSupervisorRuntimeSyncingRef.current.clear(); setProjectSupervisorSessionId(null); setProjectSupervisorRuntime(null); - setProjectSupervisorRetryRunId(null); + setProjectSupervisorExpectedRunId(null); setProjectSupervisorResponseStream(null); setProjectSupervisorRuntimeError(''); } @@ -589,6 +721,43 @@ export function App({ }); } + function appendGameChatStageRecord( + nextProjectPath: string, + runtime: AgentRuntimeState, + ) { + if (!gameChatOnly || !isAgentRuntimeTerminalState(runtime)) { + return; + } + const archiveKey = `${nextProjectPath}\n${runtime.runId}`; + if ( + !gameChatObservedRunKeysRef.current.has(archiveKey) || + gameChatArchivedRunKeysRef.current.has(archiveKey) + ) { + return; + } + const progress = buildGameChatProgressEvidence( + runtime, + agentRuntimeById, + manifest, + ); + if (!progress) { + return; + } + const text = formatGameChatStageRecord( + runtime, + progress, + collectGameChatResultImages(manifest), + ); + gameChatArchivedRunKeysRef.current.add(archiveKey); + setMessages((current) => + current.some( + (message) => message.role === 'assistant' && message.text === text, + ) + ? current + : [...current, { role: 'assistant', text }], + ); + } + useEscapeToClose(closeAgentConversation, selectedAgent !== null); useEscapeToClose(cancelUiCommandConfirmation, pendingUiConfirmation !== null); useEscapeToClose( @@ -614,6 +783,10 @@ export function App({ } initialProjectOpenedRef.current = true; if (projectSupervisorOnly) { + if (gameChatOnly) { + void openGameChatProjectPath(initialProjectPath); + return; + } if (!isAbsoluteProjectPath(initialProjectPath)) { setWorkspaceStatus('请提供工作区绝对路径'); return; @@ -633,7 +806,7 @@ export function App({ void openWorkspace(initialProjectPath, false); // Initial project opening is guarded by initialProjectOpenedRef. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialProjectPath, projectSupervisorOnly]); + }, [gameChatOnly, initialProjectPath, projectSupervisorOnly]); useEffect(() => { if (projectSupervisorOnly) { @@ -696,20 +869,19 @@ export function App({ if (payload.agentId === PROJECT_SUPERVISOR_AGENT_ID) { const expectedSessionId = projectSupervisorSessionIdRef.current; const currentRuntime = projectSupervisorRuntimeRef.current; - const expectedRetryRunId = projectSupervisorRetryRunIdRef.current; + const expectedRunId = projectSupervisorExpectedRunIdRef.current; const sameRun = currentRuntime !== null && sameAgentRuntimeRun(nextRuntime, currentRuntime); - const isExpectedRetryRun = - expectedRetryRunId !== null && - nextRuntime.runId === expectedRetryRunId; + const isExpectedRun = + expectedRunId !== null && nextRuntime.runId === expectedRunId; if ( nextRuntime.agentId !== PROJECT_SUPERVISOR_AGENT_ID || nextRuntime.runId !== payload.runId || (expectedSessionId !== null && nextRuntime.sessionId !== expectedSessionId) || (currentRuntime !== null && - ((!sameRun && !isExpectedRetryRun) || + ((!sameRun && !isExpectedRun) || (sameRun && nextRuntime.updatedAt < currentRuntime.updatedAt))) ) { return; @@ -719,9 +891,9 @@ export function App({ setProjectSupervisorSessionId(nextRuntime.sessionId); } updateProjectSupervisorRuntime(nextRuntime); - if (isExpectedRetryRun) { - projectSupervisorRetryRunIdRef.current = null; - setProjectSupervisorRetryRunId(null); + if (isExpectedRun) { + projectSupervisorExpectedRunIdRef.current = null; + setProjectSupervisorExpectedRunId(null); } updateProjectSupervisorResponseStream( payload.runtime.responseStream, @@ -781,15 +953,16 @@ export function App({ const nextProjectPath = localProject?.projectPath ?? null; const sessionId = projectSupervisorSessionId; const trackedRunId = - projectSupervisorRetryRunId ?? projectSupervisorRuntime?.runId ?? null; + projectSupervisorExpectedRunId ?? projectSupervisorRuntime?.runId ?? null; if ( !invoke || !nextProjectPath || !sessionId || !trackedRunId || - !projectSupervisorRuntime || - (isAgentRuntimeTerminalState(projectSupervisorRuntime) && - !projectSupervisorRetryRunId) + (!projectSupervisorRuntime && !projectSupervisorExpectedRunId) || + (projectSupervisorRuntime !== null && + isAgentRuntimeTerminalState(projectSupervisorRuntime) && + !projectSupervisorExpectedRunId) ) { return; } @@ -834,9 +1007,9 @@ export function App({ return; } updateProjectSupervisorRuntime(nextRuntime); - if (nextRuntime.runId === projectSupervisorRetryRunIdRef.current) { - projectSupervisorRetryRunIdRef.current = null; - setProjectSupervisorRetryRunId(null); + if (nextRuntime.runId === projectSupervisorExpectedRunIdRef.current) { + projectSupervisorExpectedRunIdRef.current = null; + setProjectSupervisorExpectedRunId(null); } updateProjectSupervisorResponseStream( result.responseStream, @@ -881,7 +1054,7 @@ export function App({ projectSupervisorRuntime?.phase, projectSupervisorRuntime?.runId, projectSupervisorRuntime?.status, - projectSupervisorRetryRunId, + projectSupervisorExpectedRunId, ]); useEffect(() => { @@ -975,8 +1148,191 @@ export function App({ projectSupervisorRuntime?.status, ]); + useEffect(() => { + const invoke = resolveTauriInvoke(); + const nextProjectPath = localProject?.projectPath ?? null; + if (!gameChatOnly || !invoke || !nextProjectPath) { + return; + } + let disposed = false; + let inFlight = false; + const syncPreview = async () => { + if (disposed || inFlight) { + return; + } + inFlight = true; + try { + const status = await invoke( + 'get_local_game_preview_status', + { projectPath: nextProjectPath }, + ); + if (disposed || localProjectPathRef.current !== nextProjectPath) { + return; + } + const runningPreview = + status.status === 'running' && + status.url && + status.port && + status.root && + resolveEmbeddedPreviewUrl({ + status: status.status, + url: status.url, + }) + ? { + url: status.url, + port: status.port, + root: status.root, + } + : null; + if (runningPreview) { + updateClientPreview(runningPreview); + setPreviewStatus(`运行中:127.0.0.1:${runningPreview.port}`); + setGameChatAutoPreviewAuthorization(null); + return; + } + const hadRunningPreview = Boolean(gameChatPreviewRef.current); + updateClientPreview(null); + if (hadRunningPreview) { + setPreviewStatus('未启动'); + } + + const currentSupervisor = projectSupervisorRuntimeRef.current; + let authorization = gameChatAutoPreviewAuthorizationRef.current; + if ( + !authorization && + currentSupervisor?.runId && + isAgentRuntimeTerminalState(currentSupervisor) && + previewStatusRef.current.startsWith( + '项目正在被其他写操作占用:', + ) + ) { + const interruptedAttemptKey = `${nextProjectPath}\n${currentSupervisor.runId}`; + if ( + gameChatObservedRunKeysRef.current.has(interruptedAttemptKey) && + gameChatAutoPreviewAttemptedRef.current.delete( + interruptedAttemptKey, + ) + ) { + authorization = { + projectPath: nextProjectPath, + runId: currentSupervisor.runId, + }; + setGameChatAutoPreviewAuthorization(authorization); + } + } + if ( + !authorization || + authorization.projectPath !== nextProjectPath || + currentSupervisor?.runId !== authorization.runId + ) { + return; + } + const nextManifest = await invoke( + 'get_local_game_manifest', + { projectPath: nextProjectPath }, + ); + if ( + disposed || + localProjectPathRef.current !== nextProjectPath || + projectSupervisorRuntimeRef.current?.runId !== authorization.runId + ) { + return; + } + setManifest(nextManifest); + const firstPrototypeReady = nextManifest.tasks.some( + (task) => task.id === 'code-prototype' && task.status === 'completed', + ); + if (!firstPrototypeReady) { + if ( + currentSupervisor && + isAgentRuntimeTerminalState(currentSupervisor) + ) { + setGameChatAutoPreviewAuthorization(null); + } + return; + } + const attemptKey = `${nextProjectPath}\n${authorization.runId}`; + if (gameChatAutoPreviewAttemptedRef.current.has(attemptKey)) { + return; + } + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + const previewDenied = + policyView.policy.deniedCommands.includes('preview.start') || + Object.values(policyView.policy.agentPolicies ?? {}).some((policy) => + policy.deniedCommands.includes('preview.start'), + ); + if (previewDenied) { + gameChatAutoPreviewAttemptedRef.current.add(attemptKey); + setGameChatAutoPreviewAuthorization(null); + const message = '项目权限策略拒绝执行:preview.start'; + setCommandLog((current) => [ + ...current, + 'permission.deny preview.start', + ]); + setPreviewStatus(message); + return; + } + appendLocalPermissionLog( + nextProjectPath, + 'permission.confirm', + 'preview.start', + ); + let startedPreview: LocalPreviewResult; + try { + startedPreview = await invoke( + 'start_local_game_preview', + { projectPath: nextProjectPath }, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (!message.startsWith('项目正在被其他写操作占用:')) { + gameChatAutoPreviewAttemptedRef.current.add(attemptKey); + setGameChatAutoPreviewAuthorization(null); + } + throw error; + } + gameChatAutoPreviewAttemptedRef.current.add(attemptKey); + setGameChatAutoPreviewAuthorization(null); + if (disposed || localProjectPathRef.current !== nextProjectPath) { + return; + } + updateClientPreview(startedPreview); + setPreviewStatus(`运行中:127.0.0.1:${startedPreview.port}`); + setCommandLog((current) => [...current, 'preview.start']); + } catch (error) { + if (!disposed && localProjectPathRef.current === nextProjectPath) { + setPreviewStatus( + error instanceof Error ? error.message : String(error), + ); + } + } finally { + inFlight = false; + } + }; + void syncPreview(); + const timer = window.setInterval(() => { + void syncPreview(); + }, 1000); + return () => { + disposed = true; + window.clearInterval(timer); + }; + // The interval reads the latest Runtime through refs and is recreated only for identity changes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + gameChatOnly, + localProject?.projectPath, + projectSupervisorRuntime?.runId, + ]); + useLayoutEffect(() => { - if (!supervisorChatOnly || !supervisorChatShouldFollowLatestRef.current) { + if ( + (!supervisorChatOnly && !gameChatOnly) || + !supervisorChatShouldFollowLatestRef.current + ) { return; } const messageList = supervisorChatMessagesRef.current; @@ -988,15 +1344,25 @@ export function App({ projectSupervisorResponseStream?.sequence, projectSupervisorRuntime?.updatedAt, projectSupervisorRuntimeError, + gameChatOnly, supervisorChatOnly, ]); useEffect(() => { - if (!supervisorChatOnly) { + if (!supervisorChatOnly && !gameChatOnly) { return; } - persistSupervisorChatDraft(initialProjectPath, chatInput); - }, [chatInput, initialProjectPath, supervisorChatOnly]); + const draftProjectPath = gameChatOnly + ? (localProject?.projectPath ?? initialProjectPath) + : initialProjectPath; + persistSupervisorChatDraft(draftProjectPath, chatInput); + }, [ + chatInput, + gameChatOnly, + initialProjectPath, + localProject?.projectPath, + supervisorChatOnly, + ]); useEffect(() => { latestMessagesRef.current = messages; @@ -1782,7 +2148,7 @@ export function App({ invoke: TauriInvoke, nextProjectPath: string, sessionId: string, - _runId?: string, + runId?: string, ) { const loadVersion = projectSupervisorHistoryLoadVersionRef.current + 1; projectSupervisorHistoryLoadVersionRef.current = loadVersion; @@ -1823,6 +2189,14 @@ export function App({ latestMessagesRef.current = conversationMessages; setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setMessages(conversationMessages); + const terminalRuntime = projectSupervisorRuntimeRef.current; + if ( + runId && + terminalRuntime?.runId === runId && + isAgentRuntimeTerminalState(terminalRuntime) + ) { + appendGameChatStageRecord(nextProjectPath, terminalRuntime); + } setProjectSupervisorRuntimeError(''); } projectSupervisorRefreshConversationRef.current = @@ -2014,6 +2388,14 @@ export function App({ const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; resetProjectSupervisorState(); + if ( + gameChatAutoPreviewAuthorizationRef.current?.projectPath !== + nextProjectPath + ) { + setGameChatAutoPreviewAuthorization(null); + } + updateClientPreview(null); + setPreviewStatus('未启动'); setWorkspaceStatus('正在打开'); setProjectStatus('正在初始化'); setSelectedAgent(null); @@ -2036,7 +2418,9 @@ export function App({ { projectPath: trimmedProjectPath, projectId: seedManifest.projectId, - name: seedManifest.name, + name: gameChatOnly + ? projectNameFromPath(trimmedProjectPath) + : seedManifest.name, }, ); if (projectScopeVersionRef.current !== projectScopeVersion) { @@ -2071,6 +2455,9 @@ export function App({ localProjectPathRef.current = openedProject.projectPath; setProjectPath(openedProject.projectPath); setLocalProject(openedProject); + if (gameChatOnly) { + setChatInput(readSupervisorChatDraft(openedProject.projectPath)); + } setManifest(openedProject.manifest); setProjectFiles([]); setProjectCheckpoints([]); @@ -2116,6 +2503,90 @@ export function App({ } } + async function openGameChatProjectPath( + nextProjectPath: string, + requestedSelectionVersion?: number, + ) { + const selectionVersion = + requestedSelectionVersion ?? + gameChatProjectSelectionVersionRef.current + 1; + gameChatProjectSelectionVersionRef.current = selectionVersion; + setPendingNonEmptyProjectCreate(null); + const trimmedProjectPath = nextProjectPath.trim(); + if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { + setWorkspaceStatus('请提供工作区绝对路径'); + return; + } + if (projectPathHasControlCharacter(trimmedProjectPath)) { + setWorkspaceStatus('工作区路径不能包含控制字符'); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setWorkspaceStatus('需要在 Tauri App 内运行'); + return; + } + setGameChatProjectSelectionBusy(true); + try { + const directoryStatus = await invoke( + 'inspect_local_project_directory', + { projectPath: trimmedProjectPath }, + ); + if (gameChatProjectSelectionVersionRef.current !== selectionVersion) { + return; + } + if (directoryStatus.exists && !directoryStatus.isDirectory) { + setWorkspaceStatus('项目路径不是文件夹'); + return; + } + if (directoryStatus.isGameCreatorProject) { + await openWorkspace(trimmedProjectPath, false); + return; + } + await executeProjectCreate(trimmedProjectPath, false, selectionVersion); + } catch (error) { + setWorkspaceStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + if (gameChatProjectSelectionVersionRef.current === selectionVersion) { + setGameChatProjectSelectionBusy(false); + } + } + } + + async function handleGameChatProjectPick() { + const invoke = resolveTauriInvoke(); + if (!invoke || gameChatProjectSelectionBusy) { + return; + } + const selectionVersion = gameChatProjectSelectionVersionRef.current + 1; + gameChatProjectSelectionVersionRef.current = selectionVersion; + setPendingNonEmptyProjectCreate(null); + setGameChatProjectSelectionBusy(true); + try { + const selectedPath = await invoke( + 'pick_local_project_directory', + ); + if (gameChatProjectSelectionVersionRef.current !== selectionVersion) { + return; + } + if (!selectedPath) { + setWorkspaceStatus('已取消'); + return; + } + await openGameChatProjectPath(selectedPath, selectionVersion); + } catch (error) { + setWorkspaceStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + if (gameChatProjectSelectionVersionRef.current === selectionVersion) { + setGameChatProjectSelectionBusy(false); + } + } + } + async function openAgentConversation( agent: AgentStatusCard, skipConversationPolicyConfirm = false, @@ -4574,9 +5045,16 @@ export function App({ sessionId, prompt, runtime: projectSupervisorRuntimeRef.current, - runProfile: supervisorChatOnly ? 'standard' : 'autonomous-game-build', + runProfile: + supervisorChatOnly && !gameChatOnly + ? 'standard' + : 'autonomous-game-build', }); const runtimeResult = submission.runtimeResult; + const acceptedRunId = submission.acceptedRunId.trim(); + if (!acceptedRunId) { + throw new Error('项目总控 Agent 请求未进入后台队列'); + } setCommandLog((current) => [ ...current, `agent.runtime.${submission.mode} project-supervisor`, @@ -4597,11 +5075,29 @@ export function App({ ) { throw new Error('项目总控 Agent Runtime Session 身份不匹配'); } - updateProjectSupervisorRuntime(runtime); - updateProjectSupervisorResponseStream( - runtimeResult.responseStream, - runtime, - ); + const acceptedRuntimeReady = runtime.runId === acceptedRunId; + if (!acceptedRuntimeReady) { + projectSupervisorExpectedRunIdRef.current = acceptedRunId; + setProjectSupervisorExpectedRunId(acceptedRunId); + } + if (gameChatOnly) { + gameChatObservedRunKeysRef.current.add( + `${nextProjectPath}\n${acceptedRunId}`, + ); + setGameChatAutoPreviewAuthorization({ + projectPath: nextProjectPath, + runId: acceptedRunId, + }); + } + if (acceptedRuntimeReady) { + projectSupervisorExpectedRunIdRef.current = null; + setProjectSupervisorExpectedRunId(null); + updateProjectSupervisorRuntime(runtime); + updateProjectSupervisorResponseStream( + runtimeResult.responseStream, + runtime, + ); + } setProjectSupervisorRuntimeError(''); const refreshConversation = projectSupervisorRefreshConversationRef.current; @@ -4610,7 +5106,7 @@ export function App({ invoke, nextProjectPath, sessionId, - runtime.runId, + acceptedRunId, ).catch((error) => { if ( localProjectPathRef.current === nextProjectPath && @@ -4624,11 +5120,13 @@ export function App({ } }); } - syncTerminalProjectSupervisorConversation( - invoke, - nextProjectPath, - runtime, - ); + if (acceptedRuntimeReady) { + syncTerminalProjectSupervisorConversation( + invoke, + nextProjectPath, + runtime, + ); + } } catch (error) { if (localProjectPathRef.current !== nextProjectPath) { return; @@ -4651,6 +5149,29 @@ export function App({ } } + useEffect(() => { + const latch = initialSupervisorMessageLatchRef.current; + if (!gameChatOnly || !latch.prompt || !localProject) { + return; + } + if (localProject.projectPath !== latch.projectPath) { + claimInitialSupervisorMessageForPage(latch.projectPath); + return; + } + if ( + chatAgentBusy || + !claimInitialSupervisorMessageForPage(latch.projectPath) + ) { + return; + } + supervisorChatShouldFollowLatestRef.current = true; + setMessages((current) => [ + ...current, + { role: 'user', text: latch.prompt, runtimeOwned: true }, + ]); + void executeChatAgentReply(latch.prompt); + }, [chatAgentBusy, gameChatOnly, initialSupervisorMessage, localProject]); + async function handleProjectSupervisorToolAction( decision: 'confirm' | 'reject', ) { @@ -4921,8 +5442,8 @@ export function App({ if (!acceptedRunId) { throw new Error('项目总控重试请求未进入后台队列'); } - projectSupervisorRetryRunIdRef.current = acceptedRunId; - setProjectSupervisorRetryRunId(acceptedRunId); + projectSupervisorExpectedRunIdRef.current = acceptedRunId; + setProjectSupervisorExpectedRunId(acceptedRunId); const nextRuntime = agentRuntimeStateFromResult(result, currentRuntime); if (nextRuntime.runId === acceptedRunId) { if ( @@ -4936,8 +5457,8 @@ export function App({ result.responseStream, nextRuntime, ); - projectSupervisorRetryRunIdRef.current = null; - setProjectSupervisorRetryRunId(null); + projectSupervisorExpectedRunIdRef.current = null; + setProjectSupervisorExpectedRunId(null); } setCommandLog((current) => [ ...current, @@ -5402,6 +5923,7 @@ export function App({ async function executeProjectCreate( nextProjectPath: string, announceToChat: boolean, + expectedGameChatSelectionVersion?: number, ) { const trimmedProjectPath = nextProjectPath.trim(); const invoke = resolveTauriInvoke(); @@ -5416,6 +5938,13 @@ export function App({ 'is_local_project_directory_non_empty', { projectPath: trimmedProjectPath }, ); + if ( + expectedGameChatSelectionVersion !== undefined && + gameChatProjectSelectionVersionRef.current !== + expectedGameChatSelectionVersion + ) { + return; + } if (nonEmpty) { setPendingNonEmptyProjectCreate({ projectPath: trimmedProjectPath, @@ -5429,6 +5958,13 @@ export function App({ // ponytail: stale test doubles and older shells may miss this helper; init still validates. } } + if ( + expectedGameChatSelectionVersion !== undefined && + gameChatProjectSelectionVersionRef.current !== + expectedGameChatSelectionVersion + ) { + return; + } await openWorkspace(nextProjectPath, announceToChat); } @@ -9465,6 +10001,10 @@ export function App({ ) { event.preventDefault(); const prompt = chatInput.trim(); + if ((gameChatOnly || supervisorChatOnly) && prompt.startsWith('/')) { + void handleChatSubmit(event); + return; + } if (agentRuntimeNeedsUserInput(projectSupervisorRuntimeRef.current)) { setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; @@ -9472,7 +10012,7 @@ export function App({ if (!prompt || chatAgentBusy) { return; } - if (supervisorChatOnly) { + if (supervisorChatOnly || gameChatOnly) { supervisorChatShouldFollowLatestRef.current = true; } setChatInput(''); @@ -9489,6 +10029,53 @@ export function App({ (agent.runtimeStatus !== null || agent.hasRecentEvidence), ); + if (projectSupervisorOnly && gameChatOnly) { + const gameChatProjectPath = localProject?.projectPath ?? ''; + return ( + setRuntimeConfigOpen(false)} + onConfirmConfirmation={confirmUiCommand} + onOpenRuntimeConfig={() => setRuntimeConfigOpen(true)} + onProjectPick={() => void handleGameChatProjectPick()} + onScroll={handleSupervisorChatScroll} + onShowEarlierMessages={showEarlierConversationMessages} + onSubmit={handleProjectSupervisorOnlySubmit} + onToolAction={handleProjectSupervisorToolAction} + onUserInput={handleProjectSupervisorUserInput} + pendingConfirmation={pendingUiConfirmation} + pendingCommand={pendingCommand} + onCancelPendingCommand={handlePendingCommandCancel} + onConfirmPendingCommand={() => void handlePendingCommandConfirm()} + pendingNonEmptyProjectCreate={pendingNonEmptyProjectCreate} + onCancelNonEmptyProjectCreate={cancelProjectCreateInNonEmptyFolder} + onConfirmNonEmptyProjectCreate={confirmProjectCreateInNonEmptyFolder} + preview={preview} + previewStatus={previewStatus} + projectPath={gameChatProjectPath} + projectReady={Boolean(localProject)} + projectSelectionBusy={gameChatProjectSelectionBusy} + runtime={projectSupervisorRuntime} + runtimeByAgentId={agentRuntimeById} + manifest={manifest} + runtimeConfigOpen={runtimeConfigOpen} + runtimeError={projectSupervisorRuntimeError} + transientReply={projectSupervisorTransientReply} + hasConversationControls={projectSupervisorHasConversationControls} + hiddenConversationCount={hiddenConversationCount} + needsUserInput={projectSupervisorNeedsUserInput} + visibleMessages={visibleMessages} + workspaceStatus={workspaceStatus} + expectedRunId={projectSupervisorExpectedRunId} + /> + ); + } + if (projectSupervisorOnly && supervisorChatOnly) { const supervisorProjectPath = localProject?.projectPath || initialProjectPath || projectPath; @@ -9508,6 +10095,9 @@ export function App({ onToolAction={handleProjectSupervisorToolAction} onUserInput={handleProjectSupervisorUserInput} pendingConfirmation={pendingUiConfirmation} + pendingCommand={pendingCommand} + onCancelPendingCommand={handlePendingCommandCancel} + onConfirmPendingCommand={() => void handlePendingCommandConfirm()} projectPath={supervisorProjectPath} runtime={projectSupervisorRuntime} runtimeConfigOpen={runtimeConfigOpen} @@ -9518,6 +10108,7 @@ export function App({ needsUserInput={projectSupervisorNeedsUserInput} visibleMessages={visibleMessages} workspaceStatus={workspaceStatus} + expectedRunId={projectSupervisorExpectedRunId} /> ); } diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index fb76f67b7..2e8d5de12 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -792,19 +792,28 @@ export async function submitProjectSupervisorRuntimeTask({ runProfile, }, ); - return { mode: 'steer' as const, runtimeResult: steer.runtime }; + return { + mode: 'steer' as const, + runtimeResult: steer.runtime, + acceptedRunId: steerRuntime.runId, + }; } + const requestedRunId = createAgentChatRunId('project-supervisor-task'); const runtimeResult = await invoke( 'start_game_creator_supervisor_runtime_task', { projectPath, sessionId, task: prompt, - runId: createAgentChatRunId('project-supervisor-task'), + runId: requestedRunId, runProfile, }, ); - return { mode: 'start' as const, runtimeResult }; + return { + mode: 'start' as const, + runtimeResult, + acceptedRunId: agentRuntimeStartedRunId(runtimeResult, requestedRunId), + }; } export function agentRuntimeSteerStatus(result: AgentRuntimeSteerResult) { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GameChatImageViewer.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GameChatImageViewer.tsx new file mode 100644 index 000000000..855ea2d46 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GameChatImageViewer.tsx @@ -0,0 +1,192 @@ +import { Minus, Plus, RotateCcw, X } from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; + +import { + closeDialogOnBackdropMouseDown, + useEscapeToClose, +} from '../../app/dialogs'; + +const MIN_SCALE = 0.5; +const MAX_SCALE = 4; +const SCALE_STEP = 0.25; + +type ImageViewTransform = { + scale: number; + x: number; + y: number; +}; + +type DragState = { + pointerId: number; + x: number; + y: number; +}; + +function clampScale(scale: number) { + return Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale)); +} + +export function GameChatImageViewer({ + alt, + label, + path, + src, + onClose, +}: { + alt: string; + label: string; + path: string; + src: string; + onClose: () => void; +}) { + const [view, setView] = useState({ + scale: 1, + x: 0, + y: 0, + }); + const [dragging, setDragging] = useState(false); + const dragRef = useRef(null); + + useEscapeToClose(onClose); + useEffect(() => { + setView({ scale: 1, x: 0, y: 0 }); + dragRef.current = null; + setDragging(false); + }, [src]); + + function updateScale(nextScale: number) { + setView((current) => ({ + ...current, + scale: clampScale(nextScale), + })); + } + + function resetView() { + setView({ scale: 1, x: 0, y: 0 }); + } + + return ( +
closeDialogOnBackdropMouseDown(event, onClose)} + > +
+
+
+ {label} + {path} +
+
+ + + {`${Math.round(view.scale * 100)}%`} + + + + +
+
+
{ + event.preventDefault(); + updateScale( + view.scale + (event.deltaY < 0 ? SCALE_STEP : -SCALE_STEP), + ); + }} + onDoubleClick={resetView} + onPointerDown={(event) => { + if (event.button !== 0) { + return; + } + event.currentTarget.setPointerCapture?.(event.pointerId); + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + setDragging(true); + }} + onPointerMove={(event) => { + const drag = dragRef.current; + if (!drag || drag.pointerId !== event.pointerId) { + return; + } + const deltaX = event.clientX - drag.x; + const deltaY = event.clientY - drag.y; + dragRef.current = { + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + setView((current) => ({ + ...current, + x: current.x + deltaX, + y: current.y + deltaY, + })); + }} + onPointerUp={(event) => { + if (dragRef.current?.pointerId !== event.pointerId) { + return; + } + dragRef.current = null; + setDragging(false); + if (event.currentTarget.hasPointerCapture?.(event.pointerId)) { + event.currentTarget.releasePointerCapture?.(event.pointerId); + } + }} + onPointerCancel={() => { + dragRef.current = null; + setDragging(false); + }} + > + {alt} +
+
+
+ ); +} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx new file mode 100644 index 000000000..40a425b5a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/LocalGamePreviewFrame.tsx @@ -0,0 +1,48 @@ +export type LocalGamePreviewLike = { + status?: string | null; + url?: string | null; +}; + +export function resolveEmbeddedPreviewUrl( + preview: LocalGamePreviewLike | null | undefined, +) { + if ( + !preview?.url || + (preview.status !== undefined && preview.status !== 'running') + ) { + return null; + } + try { + const url = new URL(preview.url); + if (url.protocol !== 'http:' || url.hostname !== '127.0.0.1') { + return null; + } + return url.toString(); + } catch { + return null; + } +} + +export function LocalGamePreviewFrame({ + preview, + title, + className, +}: { + preview: LocalGamePreviewLike | null | undefined; + title: string; + className?: string; +}) { + const embeddedUrl = resolveEmbeddedPreviewUrl(preview); + if (!embeddedUrl) { + return null; + } + return ( +