From bdf7ed288aae3ba6a8c92bf36c28202916a4ad65 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 18 Aug 2026 11:59:54 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=BE=E5=AE=BD=E7=9B=B4=E8=BF=9E=E6=B8=B8?= =?UTF-8?q?=E6=88=8F=E9=AA=8C=E6=94=B6=E5=B9=B6=E5=A2=9E=E5=8A=A0=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=97=B6=E7=B4=A0=E6=9D=90=E8=AF=81=E6=8D=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将固定四切片与固定 drawImage 降为推荐路径 - 在导航前注入 Canvas/WebGL 平台素材运行时观测 - 对缺少核心素材渲染证据的 desktop/mobile 视口回灌同一 Codex thread 整改 - 补充直连验收、浏览器脚本与真实 Chrome fixture 测试 - 同步 AGC 直连 Runtime 实施计划 --- .../game-creator.config.json | 2 +- .../src-tauri/src/agent/direct_runtime.rs | 2020 +++++++++++++++-- .../src-tauri/src/agent/generation.rs | 1 + .../src/agent/generation/canvas_generation.rs | 7 +- .../runtime_actions/response_stream_tests.rs | 4 - .../src-tauri/src/agent/runtime_driver.rs | 3 +- .../src-tauri/src/assets.rs | 474 +++- .../src-tauri/src/browser.rs | 4 +- .../src-tauri/src/browser/capture.rs | 43 + .../src-tauri/src/browser/cdp.rs | 86 +- .../src-tauri/src/browser/process.rs | 21 +- .../src-tauri/src/browser/tests.rs | 52 +- .../src-tauri/src/config.rs | 12 +- .../src-tauri/src/main.rs | 2 +- .../src-tauri/src/platform_session.rs | 56 + .../src/process_session/lifecycle.rs | 12 +- .../src-tauri/src/tests/configuration.rs | 38 +- .../src/features/agent-runtime/model.ts | 56 + .../features/app-shell/WorkspaceLauncher.tsx | 24 + .../src/features/app-shell/model.ts | 5 + .../ProjectSupervisorView.tsx | 37 + .../src/view/project-development/index.tsx | 19 +- .../tests/agentRuntimeModel.test.ts | 39 + ...计划】AGC直连Codex Runtime迁移-2026-08-15.md | 44 +- .../shared-memory/decision-log.md | 14 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 +- .../api-server/src/editor_generation_queue.rs | 3 +- .../src/external_generation_worker.rs | 67 +- 28 files changed, 2914 insertions(+), 237 deletions(-) diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 2c566be25..19e45914c 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -6,7 +6,7 @@ "model": "gpt-5.6-sol", "apiKind": "openai_responses", "reasoningEffort": "max", - "stream": false, + "stream": true, "webSearchEnabled": false, "contextWindowTokens": 128000, "autoCompactTokenLimit": 64000, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index ef4d53e30..606f25217 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -1,4 +1,5 @@ use super::*; +use std::io::Write; use std::path::Path; const MAX_CONTEXT_FILE_BYTES: usize = 6 * 1024; @@ -12,6 +13,9 @@ const BUNDLED_AGC_IMPLEMENTATION_PLAN: &str = include_str!( const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; +const PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX: &str = + "platform-generation-source-preserved-no-retry:"; +#[cfg(test)] const DIRECT_CODEX_SPRITESHEET_SLICE_PATHS: [(&str, &str); 4] = [ ("player", "assets/art-spritesheet-slices/player.png"), ( @@ -37,8 +41,8 @@ const DIRECT_CODEX_GAME_OUTPUTS: [(&str, &str, &str); 3] = [ ("game/style.css", "game-style", "text/css"), ("game/game.js", "game-script", "text/javascript"), ]; -const DIRECT_CODEX_BUILTIN_SKILL: &str = "技能 direct-game-creation:先检查当前 game/ 入口和已有玩法,优先最小修改;需要生成新游戏时直接创建可运行的 game/index.html、style.css 和 game.js,保证 Canvas、输入、主要玩法、重开和移动端布局。游戏必须适配 16:9 横屏逻辑视口,页面本身不得出现横向或纵向滚动条。客户端会在本回合开始前通过陶泥儿平台按标准工作流生成并登记 `assets/art-spec.png`、场景背景 `assets/direct-game-background.png`、透明主图集 `assets/art-spritesheet.png` 及四个切片:`player.png`、`blocks-and-targets.png`、`obstacles-and-scene.png`、`feedback-effects.png`。规范图只能作为统一风格来源;你必须将背景图用于实际场景,并在 `game/game.js` 中加载四个切片、通过 Canvas `drawImage` 分别绘制玩家/可操作物、核心目标或道具、障碍或场景元素、反馈或界面装饰等核心视觉。不得用 emoji、纯色块或 CSS 图形代替这些核心视觉,也不得只在回复中提及图片路径。不要自行调用生图 API、不要伪造图片或修改 .agent/manifest.json;客户端会负责平台调用与资源登记。完成文件修改后运行可用的静态检查并通过 AGC 本地预览确认真实页面;执行环境没有命令或浏览器工具时必须如实说明。只使用当前工作区文件变更,不启动任何 Supervisor、child、harness 或隐藏返工。"; -const DIRECT_TAONIER_ART_WORKFLOW: &str = "陶泥儿游戏创作工作流:客户端先按标准三阶段生成并登记首版美术:`assets/art-spec.png` 是统一视觉规范,`assets/direct-game-background.png` 是 16:9 场景背景,`assets/art-spritesheet.png` 是基于规范图生成的透明核心图集;平台还会生成四份受合同保护的切片 `assets/art-spritesheet-slices/player.png`、`assets/art-spritesheet-slices/blocks-and-targets.png`、`assets/art-spritesheet-slices/obstacles-and-scene.png`、`assets/art-spritesheet-slices/feedback-effects.png`。你只负责在当前工作区实现游戏。项目结构固定为 `assets/` 存放已登记的真实媒体、`game/index.html` 为入口、`game/style.css` 与 `game/game.js` 为游戏代码、`memory/` 存放玩法记忆、`.agent/` 为客户端维护的项目状态。不得手写或修改 `.agent/manifest.json`,不得创建伪造 PNG、data URL、emoji 或纯 CSS 图形来代替核心美术。背景图必须实际用于游戏场景;四个切片必须由 `game/game.js` 加载,并分别通过 Canvas `drawImage` 渲染到可玩画面。只有完整平台美术包被实际使用、并且游戏代码存在时,本轮才可交付完成。"; +const DIRECT_CODEX_BUILTIN_SKILL: &str = "技能 direct-game-creation:先检查当前 game/ 入口和已有玩法,优先最小修改;需要生成新游戏时直接创建可运行的 game/index.html、style.css 和 game.js,保证 Canvas、输入、主要玩法、重开和移动端布局。游戏必须适配 16:9 横屏逻辑视口,页面本身不得出现横向或纵向滚动条。客户端会在本回合开始前通过陶泥儿平台准备已登记的真实美术资源。优先使用规范图、场景背景和可用图集/切片;如果平台只返回完整透明图集或某些切片缺失,不要伪造切片,也不要因此停止生成。至少让一个真实平台素材进入实际渲染路径,并通过真实浏览器试玩检查画面和交互。不要用 emoji、纯色块或 CSS 图形冒充平台核心美术,也不要只在回复中提及图片路径。不要自行调用生图 API、不要伪造图片或修改 .agent/manifest.json;客户端会负责平台调用与资源登记。完成文件修改后运行可用的静态检查并通过 AGC 本地预览确认真实页面;执行环境没有命令或浏览器工具时必须如实说明。收到客户端试玩证据后,必须读取实际文件并按证据继续修复;最终回复结构化列出检查文件、真实启动或试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题和剩余风险。只使用当前工作区文件变更,不启动任何 Supervisor、child、harness 或隐藏返工。"; +const DIRECT_TAONIER_ART_WORKFLOW: &str = "陶泥儿游戏创作工作流:客户端先准备并登记平台美术资源。项目结构固定为 `assets/` 存放已登记的真实媒体、`game/index.html` 为入口、`game/style.css` 与 `game/game.js` 为游戏代码、`memory/` 存放玩法记忆、`.agent/` 为客户端维护的项目状态。规范图用于统一视觉风格;背景、完整图集和平台返回的切片可按实际可用性接入。不得手写或修改 `.agent/manifest.json`,不得创建伪造 PNG、data URL、emoji 或纯 CSS 图形来代替真实平台素材。至少一个已登记平台图片必须被游戏源码引用并在页面中实际加载。"; #[derive(Clone, Debug, Eq, PartialEq)] struct DirectTaonierArtAssetIdentity { @@ -47,6 +51,159 @@ struct DirectTaonierArtAssetIdentity { canvas_project_id: String, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum DirectCodexFailureStage { + ArtPreparation, + CodeGeneration, + BrowserValidation, + VersionRegistration, +} + +impl DirectCodexFailureStage { + fn id(self) -> &'static str { + match self { + Self::ArtPreparation => "art-preparation", + Self::CodeGeneration => "code-generation", + Self::BrowserValidation => "browser-validation", + Self::VersionRegistration => "version-registration", + } + } +} + +#[derive(Debug)] +struct DirectCodexTurnFailure { + stage: DirectCodexFailureStage, + error: String, +} + +impl DirectCodexTurnFailure { + fn new(stage: DirectCodexFailureStage, error: impl Into) -> Self { + Self { + stage, + error: error.into(), + } + } +} + +fn direct_codex_failure_recovery_hint(stage: DirectCodexFailureStage, error: &str) -> &'static str { + let normalized = error.to_ascii_lowercase(); + if private_external_editor_credentials_storage_preparation_failed(error) { + return "请检查当前 Windows 用户对本机私有凭据目录的权限后重试"; + } + if private_external_editor_credentials_persistence_failed(error) { + return "请先在账户开发者凭据页面撤销刚创建但未保存的凭据,再重试"; + } + if error.contains("本机陶泥儿开发者 Key") { + return "请先在已登录的陶泥儿客户端发起一次直连创作,以创建仅保存在本机的开发者 Key"; + } + if normalized.contains("authentication-required") + || normalized.contains("unauthorized") + || normalized.contains("http 401") + { + return "登录态可能已失效,请重新登录陶泥儿后重试"; + } + if normalized.contains("permission-denied") || normalized.contains("http 403") { + return "当前陶泥儿账号可能没有访问该资源的权限,请检查账号后重试"; + } + if error.contains("项目正在被其他写操作占用") { + return "当前项目仍有写入正在结束,请稍后再次发送该需求"; + } + if error.contains("身份不唯一") + || error.contains("身份不匹配") + || error.contains("未找到身份完整的历史图集") + || error.contains("没有可见像素") + { + return "历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集"; + } + match stage { + DirectCodexFailureStage::ArtPreparation => { + "平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断" + } + DirectCodexFailureStage::CodeGeneration => { + "Codex 未完成本轮代码修改,请检查运行时配置后重试" + } + DirectCodexFailureStage::BrowserValidation => { + "游戏未通过真实试玩,请根据项目诊断修复后再次发送需求" + } + DirectCodexFailureStage::VersionRegistration => { + "产物尚未安全登记为版本,请检查项目目录后重试" + } + } +} + +fn direct_codex_failure_public_summary(error: &str) -> Option<&'static str> { + if private_external_editor_credentials_storage_preparation_failed(error) { + return Some("本机开发者凭据存储目录未安全初始化;未创建远端凭据"); + } + if private_external_editor_credentials_persistence_failed(error) { + return Some("本机开发者凭据已创建但未能安全保存"); + } + None +} + +fn direct_codex_failure_is_retryable(error: &str) -> bool { + ![ + "private-external-editor-credential-storage-preparation-failed", + "private-external-editor-credential-persistence-failed", + "身份不唯一", + "身份不匹配", + "未找到身份完整的历史图集", + "没有可见像素", + "合同发生变化", + ] + .iter() + .any(|marker| error.contains(marker)) +} + +fn record_direct_codex_turn_failure(root: &Path, failure: DirectCodexTurnFailure) -> String { + let summary = direct_codex_failure_public_summary(&failure.error) + .map(str::to_string) + .unwrap_or_else(|| redact_agent_runtime_error(root, &failure.error, 320)); + let summary = summary.split_whitespace().collect::>().join(" "); + let summary = if summary.trim().is_empty() { + "未提供可安全展示的详细原因".to_string() + } else { + summary + }; + let retryable = direct_codex_failure_is_retryable(&failure.error); + let recovery_hint = direct_codex_failure_recovery_hint(failure.stage, &failure.error); + let diagnostic = serde_json::json!({ + "schemaVersion": "direct-codex-diagnostic.v1", + "stage": failure.stage.id(), + "summary": summary, + "retryable": retryable, + "recoveryHint": recovery_hint, + "occurredAtUnixNanos": unix_timestamp_nanos().to_string(), + }); + let relative_path = format!( + ".agent/runtime/direct-codex-diagnostics/{}/failure.json", + unix_timestamp_nanos() + ); + let diagnostic_written = write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "直连 Codex 失败诊断", + &diagnostic, + 8 * 1024, + ) + .is_ok(); + let diagnostics_suffix = if diagnostic_written { + "已保存脱敏项目诊断" + } else { + "未能保存项目诊断" + }; + format!( + "direct-codex-failure:v1 stage={} retryable={} summary={};建议:{};{}", + failure.stage.id(), + retryable, + diagnostic["summary"] + .as_str() + .unwrap_or("未提供可安全展示的详细原因"), + recovery_hint, + diagnostics_suffix, + ) +} + fn direct_taonier_art_generation_runtime_context( root: &Path, output_path: &str, @@ -102,10 +259,22 @@ fn direct_taonier_art_asset_identity( .eq(expected_reference_resource_ids.iter().copied()) || expected_canvas_project_id .is_some_and(|expected| asset.source.canvas_project_id.as_deref() != Some(expected)) - || !root.join(&asset.local_path).is_file() { return None; } + let asset_path = resolve_local_project_path(root, &asset.local_path).ok()?; + if !asset_path.is_file() { + return None; + } + let bytes = std::fs::read(asset_path).ok()?; + let validated = validate_platform_art_png_bytes_with_limits( + &bytes, + &format!("陶泥儿平台素材 {}", asset.local_path), + ) + .ok()?; + if !validated.has_visible_pixels { + return None; + } Some(DirectTaonierArtAssetIdentity { resource_id: asset .source @@ -132,93 +301,7 @@ fn direct_taonier_art_asset_identity( }) } -fn direct_taonier_spritesheet_slice_contract_is_valid( - root: &Path, - spritesheet_resource_id: &str, - spritesheet_asset_object_id: &str, - spritesheet_canvas_project_id: &str, - art_spec_resource_id: &str, -) -> bool { - let Some(manifest) = std::fs::read(root.join("assets/art-spritesheet-slices/manifest.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - else { - return false; - }; - let Some(receipt) = std::fs::read(root.join(".agent/runtime/art-spritesheet-contract.json")) - .ok() - .and_then(|bytes| serde_json::from_slice::(&bytes).ok()) - else { - return false; - }; - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS - .iter() - .all(|(usage, path)| { - root.join(path).is_file() - && manifest - .get("slices") - .and_then(serde_json::Value::as_array) - .is_some_and(|slices| { - slices.iter().any(|slice| { - slice.get("usage").and_then(serde_json::Value::as_str) == Some(*usage) - && slice.get("path").and_then(serde_json::Value::as_str) - == Some(*path) - }) - }) - && receipt - .get("slices") - .and_then(serde_json::Value::as_array) - .is_some_and(|slices| { - slices.iter().any(|slice| { - slice.get("usage").and_then(serde_json::Value::as_str) == Some(*usage) - && slice.get("path").and_then(serde_json::Value::as_str) - == Some(*path) - }) - }) - }) - && manifest.get("source").and_then(serde_json::Value::as_str) - == Some(DIRECT_CODEX_SPRITESHEET_ASSET_PATH) - && manifest - .get("sourceResourceId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_resource_id) - && manifest - .get("sourceAssetObjectId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_asset_object_id) - && manifest - .get("sourceCanvasProjectId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_canvas_project_id) - && manifest - .get("sourceReferenceResourceIds") - .and_then(serde_json::Value::as_array) - .is_some_and(|references| { - references.len() == 1 && references[0].as_str() == Some(art_spec_resource_id) - }) - && receipt.get("source").and_then(serde_json::Value::as_str) - == Some(DIRECT_CODEX_SPRITESHEET_ASSET_PATH) - && receipt - .get("sourceResourceId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_resource_id) - && receipt - .get("sourceAssetObjectId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_asset_object_id) - && receipt - .get("sourceCanvasProjectId") - .and_then(serde_json::Value::as_str) - == Some(spritesheet_canvas_project_id) - && receipt - .get("sourceReferenceResourceIds") - .and_then(serde_json::Value::as_array) - .is_some_and(|references| { - references.len() == 1 && references[0].as_str() == Some(art_spec_resource_id) - }) -} - -fn direct_taonier_art_package_is_valid(root: &Path) -> bool { +fn direct_taonier_art_base_is_valid(root: &Path) -> bool { let Some(art_spec) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, @@ -241,6 +324,25 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { ) else { return false; }; + let _ = (background, art_spec); + true +} + +fn direct_taonier_art_package_is_valid(root: &Path) -> bool { + let Some(art_spec) = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) else { + return false; + }; + if !direct_taonier_art_base_is_valid(root) { + return false; + } let Some(spritesheet) = direct_taonier_art_asset_identity( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, @@ -252,14 +354,8 @@ fn direct_taonier_art_package_is_valid(root: &Path) -> bool { ) else { return false; }; - let _ = background; - direct_taonier_spritesheet_slice_contract_is_valid( - root, - &spritesheet.resource_id, - &spritesheet.asset_object_id, - &spritesheet.canvas_project_id, - &art_spec.resource_id, - ) + let _ = (spritesheet, art_spec); + true } fn direct_asset_file_name(path: &str) -> &str { @@ -269,28 +365,337 @@ fn direct_asset_file_name(path: &str) -> &str { .unwrap_or(path) } -fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool { - let background_name = direct_asset_file_name(DIRECT_CODEX_BACKGROUND_ASSET_PATH); +fn direct_game_sources_referenced_taonier_assets(root: &Path) -> Vec<&'static str> { let sources = ["game/index.html", "game/style.css", "game/game.js"] .iter() .filter_map(|relative_path| std::fs::read_to_string(root.join(relative_path)).ok()) .collect::>(); - let background_is_referenced = sources + let mut available_paths = Vec::new(); + if direct_taonier_art_base_is_valid(root) { + available_paths.push(DIRECT_CODEX_ART_SPEC_ASSET_PATH); + available_paths.push(DIRECT_CODEX_BACKGROUND_ASSET_PATH); + } + if direct_taonier_art_package_is_valid(root) { + available_paths.push(DIRECT_CODEX_SPRITESHEET_ASSET_PATH); + } + available_paths + .into_iter() + .filter(|path| { + sources.iter().any(|source| { + source.contains(path) || source.contains(direct_asset_file_name(path)) + }) + }) + .collect() +} + +fn direct_game_sources_reference_taonier_art_package(root: &Path) -> bool { + !direct_game_sources_referenced_taonier_assets(root).is_empty() +} + +fn direct_normalized_project_asset_path(path: &str) -> Option { + let normalized = path.trim().replace('\\', "/"); + let normalized = normalized.split(['?', '#']).next().unwrap_or_default(); + let normalized = normalized.trim_start_matches('/'); + (!normalized.is_empty() && !normalized.contains("..")).then(|| normalized.to_string()) +} + +const DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX: &str = "[advisory] rendered-local-images:"; + +fn direct_browser_rendered_image_paths(viewport: &BrowserViewportValidationResult) -> Vec<&str> { + viewport + .diagnostics .iter() - .any(|source| source.contains(background_name)); - let game_script = std::fs::read_to_string(root.join("game/game.js")).ok(); - let core_slices_are_rendered = game_script.is_some_and(|source| { - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS + .filter_map(|diagnostic| { + diagnostic.strip_prefix(DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX) + }) + .flat_map(|paths| paths.split('|')) + .map(str::trim) + .filter(|path| !path.is_empty()) + .collect() +} + +fn direct_rendered_taonier_assets_in_viewport( + root: &Path, + viewport: &BrowserViewportValidationResult, +) -> Vec<&'static str> { + let referenced = direct_game_sources_referenced_taonier_assets(root); + referenced + .into_iter() + .filter(|asset_path| { + direct_browser_rendered_image_paths(viewport) + .iter() + .any(|rendered_path| { + direct_normalized_project_asset_path(rendered_path) + .as_deref() + .is_some_and(|rendered_path| { + rendered_path == *asset_path + || rendered_path.ends_with(&format!("/{asset_path}")) + }) + }) + }) + .collect() +} + +fn direct_browser_evidence_needs_art_repair( + root: &Path, + evidence: Option<&BrowserValidationResult>, +) -> bool { + let Some(evidence) = evidence else { + return false; + }; + evidence.passed + && evidence + .viewport_results .iter() - .all(|(_, path)| source.contains(direct_asset_file_name(path))) - && source.contains("drawImage") - && source.matches("drawImage(").count() >= DIRECT_CODEX_SPRITESHEET_SLICE_PATHS.len() - && (source.contains("new Image") - || source.contains("new window.Image") - || source.contains("document.createElement('img')") - || source.contains("document.createElement(\"img\")")) + .any(|viewport| direct_rendered_taonier_assets_in_viewport(root, viewport).is_empty()) +} + +/// The only output-shape proof the direct Runtime owns. It deliberately does +/// not prescribe a spritesheet layout, slice names, `drawImage` count, or a +/// particular canvas implementation: those are quality questions for the +/// same Codex thread after it has seen real browser evidence. +fn direct_game_output_completion_error(root: &Path) -> Option { + if !root.join("game/index.html").is_file() { + return Some("Codex 返回后未找到 game/index.html,项目未进入可运行状态".to_string()); + } + if !direct_game_sources_reference_taonier_art_package(root) { + return Some( + "游戏代码已生成,但未在源码中引用任何已登记的陶泥儿平台图片;不会将项目标记为完成" + .to_string(), + ); + } + None +} + +fn direct_acceptance_needs_repair( + root: &Path, + evidence: Option<&BrowserValidationResult>, + completion_error: Option<&str>, +) -> bool { + completion_error.is_some() + || evidence.is_none_or(|result| !result.passed) + || direct_browser_evidence_needs_art_repair(root, evidence) +} + +fn direct_recoverable_spritesheet_resource<'a>( + resources: &'a [serde_json::Value], + art_spec: &DirectTaonierArtAssetIdentity, +) -> Result, String> { + let mut candidates = resources.iter().filter(|resource| { + let kind = json_string_field(resource, "assetKind") + .or_else(|| json_string_field(resource, "kind")); + let source_resource_id = json_string_field(resource, "sourceResourceId"); + let referenced_spec_ids = resource + .pointer("/generationInputs/references") + .and_then(serde_json::Value::as_array) + .map(|references| { + references + .iter() + .filter_map(|reference| { + json_string_field(reference, "refId") + .or_else(|| json_string_field(reference, "resourceId")) + .or_else(|| json_string_field(reference, "id")) + }) + .collect::>() + }); + let bound_to_current_spec = match source_resource_id { + Some(source_resource_id) => source_resource_id == art_spec.resource_id, + // The platform's source-only fallback preserves the generated + // spritesheet as the primary resource and therefore has no + // sourceResourceId. Its immutable request reference is an + // equivalent identity only when it contains exactly the current + // spec and no other resource. + None => referenced_spec_ids.as_ref().is_some_and(|references| { + references.len() == 1 && references[0] == art_spec.resource_id + }), + }; + kind.as_deref() == Some("icon-spritesheet") + && json_string_field(resource, "projectId").as_deref() + == Some(art_spec.canvas_project_id.as_str()) + && bound_to_current_spec }); - background_is_referenced && core_slices_are_rendered + let Some(resource) = candidates.next() else { + return Ok(None); + }; + if candidates.next().is_some() { + return Err("陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复".to_string()); + } + if json_string_field(resource, "generationRoute") + .or_else(|| json_string_field(resource, "generation_route")) + .is_some_and(|route| route != "/api/external/v1/editor/icon-spritesheets/generations") + || json_string_field(resource, "generationKind") + .or_else(|| json_string_field(resource, "generation_kind")) + .is_some_and(|kind| kind != "icon-spritesheet") + { + return Err("匹配到的陶泥儿图集资源生成身份不匹配,已拒绝恢复".to_string()); + } + Ok(Some(resource)) +} + +async fn recover_direct_taonier_spritesheet_read_only_at( + root: &Path, + art_spec: &DirectTaonierArtAssetIdentity, +) -> Result { + let (api_base_url, api_key, _) = resolve_canvas_sync_api_credentials(None, None)?; + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|error| format!("创建陶泥儿只读资源恢复客户端失败:{error}"))?; + let route = resolve_platform_editor_api_route(&format!( + "/api/external/v1/editor/projects/{}", + percent_encode_query_component(&art_spec.canvas_project_id) + )); + let response = client + .get(format!("{api_base_url}{route}")) + .bearer_auth(&api_key) + .send() + .await + .map_err(|error| format!("读取陶泥儿画布资源失败:{error}"))?; + let status = response.status(); + if !status.is_success() { + return Err(format!("读取陶泥儿画布资源失败:HTTP {}", status.as_u16())); + } + let payload = response + .json::() + .await + .map_err(|error| format!("解析陶泥儿画布资源失败:{error}"))?; + let project = payload + .get("project") + .or_else(|| payload.pointer("/data/project")) + .ok_or_else(|| "陶泥儿画布资源响应缺少 project".to_string())?; + let resources = project + .get("resources") + .and_then(serde_json::Value::as_array) + .ok_or_else(|| "陶泥儿画布资源响应缺少 resources".to_string())?; + let Some(resource) = direct_recoverable_spritesheet_resource(resources, art_spec)? else { + return Ok(false); + }; + let resource_id = json_string_field(resource, "resourceId") + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 resourceId".to_string())?; + let asset_object_id = json_string_field(resource, "assetObjectId") + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 assetObjectId".to_string())?; + let task_id = json_string_field(resource, "taskId") + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少 taskId".to_string())?; + let download = resolve_canvas_resource_download(&client, &api_base_url, &api_key, resource) + .await? + .ok_or_else(|| "匹配到的陶泥儿图集资源缺少可下载媒体".to_string())?; + let validated = validate_platform_art_png_bytes_with_limits( + &download.bytes, + "恢复的陶泥儿 art-spritesheet", + )?; + if !validated.has_visible_pixels { + return Err("恢复的陶泥儿 art-spritesheet 没有可见像素".to_string()); + } + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "direct-codex.art-spritesheet-recovery", + )?; + let current_art_spec = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + Some(&art_spec.canvas_project_id), + ) + .ok_or_else(|| "只读恢复期间本地规范图合同发生变化,已拒绝写入图集".to_string())?; + if current_art_spec != *art_spec { + return Err("只读恢复期间本地规范图身份发生变化,已拒绝写入图集".to_string()); + } + let output = resolve_local_project_path(root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH)?; + if output.exists() { + return Err("本地图集文件已存在但合同不完整,已拒绝覆盖并保持失败状态".to_string()); + } + if let Some(parent) = output.parent() { + std::fs::create_dir_all(parent) + .map_err(|error| format!("创建陶泥儿图集目录失败:{error}"))?; + } + let mut output_file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&output) + .map_err(|error| format!("创建恢复的陶泥儿图集文件失败:{error}"))?; + output_file + .write_all(&download.bytes) + .and_then(|_| output_file.sync_all()) + .map_err(|error| { + let _ = std::fs::remove_file(&output); + format!("写入恢复的陶泥儿图集失败:{error}") + })?; + drop(output_file); + let register = register_local_asset_at( + root, + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "art-spritesheet", + "image/png", + "platform-art-recovery", + GameCreationAppAssetSource { + kind: GameCreationAppAssetSourceKind::Canvas, + canvas_project_id: Some(art_spec.canvas_project_id.clone()), + resource_id: Some(resource_id), + asset_object_id: Some(asset_object_id), + task_id: Some(task_id), + prompt: json_string_field(resource, "actualPrompt") + .or_else(|| json_string_field(resource, "prompt")), + model: json_string_field(resource, "model"), + generation_route: Some( + "/api/external/v1/editor/icon-spritesheets/generations".to_string(), + ), + generation_kind: Some("icon-spritesheet".to_string()), + reference_resource_ids: vec![art_spec.resource_id.clone()], + }, + ); + if let Err(error) = register { + let _ = std::fs::remove_file(&output); + return Err(error); + } + emit_direct_game_creator_progress( + root, + "art.spritesheet.recovered", + "已从陶泥儿画布只读恢复核心图集", + ); + Ok(true) +} + +fn direct_spritesheet_source_preserved_failure(output_path: &str, error: &str) -> bool { + output_path == DIRECT_CODEX_SPRITESHEET_ASSET_PATH + && error + .trim_start() + .starts_with(PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX) +} + +fn clear_direct_taonier_generation_ledger_after_source_recovery( + root: &Path, + runtime_context: &PlatformArtGenerationRuntimeContext, +) -> Result<(), String> { + remove_platform_art_generation_runtime_state_at( + root, + &runtime_context.agent_id, + &runtime_context.run_id, + ) + .map_err(|error| format!("已恢复平台源图,但清理已完成生成账本失败:{error}")) +} + +/// A postprocess fallback has already completed the paid platform generation +/// and preserved its source resource. The direct Runtime may only make a +/// bounded, read-only attempt to recover that exact resource; it must never +/// turn this branch into another generation submission. +async fn recover_direct_taonier_spritesheet_after_source_preserved_failure_at( + root: &Path, + art_spec: &DirectTaonierArtAssetIdentity, +) -> Result { + const MAX_READ_ONLY_ATTEMPTS: usize = 3; + + for attempt in 0..MAX_READ_ONLY_ATTEMPTS { + if recover_direct_taonier_spritesheet_read_only_at(root, art_spec).await? { + return Ok(true); + } + if attempt + 1 < MAX_READ_ONLY_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + } + } + Ok(false) } async fn generate_direct_taonier_art_asset_at( @@ -312,7 +717,7 @@ async fn generate_direct_taonier_art_asset_at( }; let runtime_context = direct_taonier_art_generation_runtime_context(root, output_path, asset_kind)?; - let generated = generate_platform_art_asset_with_runtime_options_at( + let generated = match generate_platform_art_asset_with_runtime_options_at( root, prompt, &[], @@ -321,9 +726,50 @@ async fn generate_direct_taonier_art_asset_at( &runtime_context, ) .await - .map_err(|error| { - format!("陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}") - })?; + { + Ok(generated) => generated, + Err(error) if direct_spritesheet_source_preserved_failure(output_path, &error) => { + let art_spec = direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + "/api/external/v1/editor/images/generations", + "spec", + &[], + None, + ) + .ok_or_else(|| { + "陶泥儿核心图集透明后处理失败,但当前规范图不再满足来源、下载或解码校验,已拒绝恢复源图" + .to_string() + })?; + let recovered = recover_direct_taonier_spritesheet_after_source_preserved_failure_at( + root, &art_spec, + ) + .await + .map_err(|recovery_error| { + format!( + "陶泥儿核心图集透明后处理失败后无法安全恢复已保留源图:{recovery_error};原始结果:{error}" + ) + })?; + if !recovered { + return Err(format!( + "陶泥儿核心图集透明后处理失败,平台已保留源图但在有界只读查询内未找到可安全恢复的唯一资源:{error}" + )); + } + clear_direct_taonier_generation_ledger_after_source_recovery(root, &runtime_context)?; + emit_direct_game_creator_progress( + root, + "art.spritesheet.source-recovered", + "透明后处理未完成,已安全恢复陶泥儿保留的核心源图", + ); + return Ok(()); + } + Err(error) => { + return Err(format!( + "陶泥儿美术包生成失败({asset_label}),已在启动智能创作前终止:{error}" + )); + } + }; if generated.asset.local_path != output_path { return Err(format!( "陶泥儿美术包生成返回后未形成可用的已登记平台素材合同 {output_path},已终止代码生成" @@ -344,7 +790,7 @@ async fn ensure_direct_taonier_art_package_at( .map(|path| (*path).to_string()) .collect()); } - let art_spec = match direct_taonier_art_asset_identity( + let existing_art_spec = direct_taonier_art_asset_identity( root, DIRECT_CODEX_ART_SPEC_ASSET_PATH, "icon-spec", @@ -352,7 +798,20 @@ async fn ensure_direct_taonier_art_package_at( "spec", &[], None, - ) { + ); + let existing_art_spec_and_background = existing_art_spec.as_ref().is_some_and(|art_spec| { + direct_taonier_art_asset_identity( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + "/api/external/v1/editor/images/generations", + "spec", + &[&art_spec.resource_id], + Some(&art_spec.canvas_project_id), + ) + .is_some() + }); + let art_spec = match existing_art_spec { Some(identity) => identity, None => { emit_direct_game_creator_progress(root, "art.spec", "正在生成统一视觉规范图"); @@ -410,6 +869,29 @@ async fn ensure_direct_taonier_art_package_at( .map(|path| (*path).to_string()) .collect()); } + if recover_direct_taonier_spritesheet_read_only_at(root, &art_spec).await? { + if direct_taonier_art_package_is_valid(root) { + emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); + return Ok(DIRECT_CODEX_ART_ASSET_PATHS + .iter() + .map(|path| (*path).to_string()) + .collect()); + } + } + if existing_art_spec_and_background { + if !direct_taonier_art_base_is_valid(root) { + return Err("当前项目已有的陶泥儿规范图或背景图不再满足可下载、可解码和来源一致性要求,已终止代码生成".to_string()); + } + emit_direct_game_creator_progress( + root, + "art.ready", + "核心图集未能安全恢复,已使用现有陶泥儿规范图和背景图继续创作", + ); + return Ok(vec![ + DIRECT_CODEX_ART_SPEC_ASSET_PATH.to_string(), + DIRECT_CODEX_BACKGROUND_ASSET_PATH.to_string(), + ]); + } emit_direct_game_creator_progress( root, "art.spritesheet", @@ -422,11 +904,11 @@ async fn ensure_direct_taonier_art_package_at( "1:1", "art-spritesheet", "陶泥儿首版核心游戏美术图集", - true, + false, ) .await?; if !direct_taonier_art_package_is_valid(root) { - return Err("陶泥儿美术包不完整,已终止代码生成".to_string()); + return Err("陶泥儿美术包缺少可用的已登记主图,已终止代码生成".to_string()); } emit_direct_game_creator_progress(root, "art.ready", "陶泥儿美术包已就绪"); Ok(DIRECT_CODEX_ART_ASSET_PATHS @@ -467,21 +949,399 @@ fn direct_codex_output_fingerprint(root: &Path) -> String { format!("{:x}", hasher.finalize()) } +fn direct_browser_evidence_root(root: &Path, attempt: usize) -> Result { + let revision = read_game_creator_agent_runtime_project_revision(root) + .map(|value| value.revision) + .unwrap_or(0); + resolve_local_project_path( + root, + &format!(".agent/runtime/direct-codex-browser-validation/{revision}/attempt-{attempt}"), + ) +} + +fn direct_browser_viewport_evidence( + root: &Path, + result: &BrowserViewportValidationResult, +) -> String { + let screenshot = result + .screenshot_path + .strip_prefix(root) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")) + .unwrap_or_else(|| "evidence-screenshot.png".to_string()); + let canvas_non_empty = result + .canvases + .iter() + .filter(|canvas| canvas.non_empty == Some(true)) + .count(); + let fatal_requests = result + .failed_requests + .iter() + .filter(|request| request.fatal) + .count(); + format!( + "{}: passed={}, readyState={}, canvas={}/{} nonEmpty, consoleErrors={}, exceptions={}, fatalRequests={}, screenshot={}", + match result.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }, + result.passed, + result.ready_state, + canvas_non_empty, + result.canvases.len(), + result.console_errors.len(), + result.exceptions.len(), + fatal_requests, + screenshot, + ) +} + +fn direct_browser_evidence_prompt( + root: &Path, + result: Option<&BrowserValidationResult>, + attempt: usize, + output_changed: bool, + completion_error: Option<&str>, +) -> String { + let completion_status = completion_error + .map(|error| { + format!( + "未通过:{}。这是客户端最低完成证明,不是可忽略的建议;请直接修改真实 `game/` 文件后再回复。", + redact_agent_runtime_error(root, error, 320) + ) + }) + .unwrap_or_else(|| { + "通过:已找到 `game/index.html`,且源码至少引用了一份已登记的陶泥儿平台图片。" + .to_string() + }); + let (browser_status, viewport_lines, details, advisory) = match result { + Some(result) => { + let viewport_lines = result + .viewport_results + .iter() + .map(|viewport| direct_browser_viewport_evidence(root, viewport)) + .collect::>(); + let art_observations = result + .viewport_results + .iter() + .map(|viewport| { + let viewport_name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let rendered = direct_rendered_taonier_assets_in_viewport(root, viewport); + if rendered.is_empty() { + format!( + "{viewport_name}: 未在 Canvas/WebGL 渲染调用中观察到已登记陶泥儿图片;旁侧缩略图不能证明素材进入核心玩法,源码引用也不能代替运行时证据" + ) + } else { + format!( + "{viewport_name}: Canvas/WebGL 渲染调用观察到陶泥儿图片 {}", + rendered.join("、") + ) + } + }) + .collect::>(); + let advisory = result + .viewport_results + .iter() + .flat_map(|viewport| viewport.diagnostics.iter()) + .filter(|diagnostic| diagnostic.starts_with("[advisory]")) + .map(|diagnostic| redact_agent_runtime_project_paths(root, diagnostic, 240)) + .collect::>(); + let details = result + .viewport_results + .iter() + .flat_map(|viewport| { + let viewport_name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let mut details = Vec::new(); + details.extend( + viewport + .diagnostics + .iter() + .filter(|diagnostic| !diagnostic.starts_with("[advisory]")) + .map(|diagnostic| { + format!( + "{viewport_name} diagnostic: {}", + redact_agent_runtime_error(root, diagnostic, 320) + ) + }), + ); + details.extend(viewport.console_errors.iter().take(4).map(|message| { + format!( + "{viewport_name} console.error: {}", + redact_agent_runtime_error(root, &message.text, 320) + ) + })); + details.extend(viewport.exceptions.iter().take(4).map(|exception| { + format!( + "{viewport_name} exception: {}", + redact_agent_runtime_error(root, &exception.text, 320) + ) + })); + details.extend( + viewport + .failed_requests + .iter() + .filter(|request| request.fatal) + .take(4) + .map(|request| { + format!( + "{viewport_name} fatal request: {} {} ({})", + request.method, + redact_agent_runtime_error(root, &request.url, 320), + redact_agent_runtime_error(root, &request.error_text, 220), + ) + }), + ); + details + }) + .collect::>(); + ( + format!("整体 passed={}", result.passed), + viewport_lines, + { + let mut details = details; + details.extend(art_observations); + details + }, + advisory, + ) + } + None => ( + "本次未启动 Chromium:最低完成证明尚未满足。不得把这当作试玩通过;修复后客户端才会启动真实 desktop/mobile 试玩。".to_string(), + Vec::new(), + Vec::new(), + Vec::new(), + ), + }; + format!( + "请在同一 Codex 会话中进行第 {attempt} 次自主验收。客户端只会把真实运行证据交给你,不能把未启动的浏览器、截图或结构化结果假装成已通过。\n\n[客户端最低完成证明]\n{completion_status}\n\n[结构化浏览器证据]\n{browser_status};代码文件相对指纹是否变化={output_changed}\n{}\n详细诊断与平台素材运行时观察:{}\n交互探针:{}\n证据文件留在项目 `.agent/runtime/direct-codex-browser-validation/` 下;如果原生文件工具能安全读取 PNG,可查看截图,否则只依据下列结构化数据,不要声称看过截图。\n\n请读取当前工作区的实际 `game/` 文件和可用 `assets/`,判断游戏是否真的可运行、画布是否有内容、素材是否实际进入画面、布局是否适合 desktop/mobile、主要交互是否有响应。若平台素材只作为旁侧缩略图或未观察到 Canvas/WebGL 实际渲染,请把已登记素材接入核心可玩画面后再回复;不得用纯色圆点、emoji 或 CSS 图形替代核心素材。发现任何问题就直接修改文件并再次自检;不要创建 Supervisor、child 或 harness。最终回复必须结构化列出:检查文件、真实启动/试玩动作、desktop/mobile 观察、平台素材实际使用、已修复问题、剩余风险。", + viewport_lines.join("\n"), + if details.is_empty() { + "无额外硬失败详情".to_string() + } else { + details.join("\n") + }, + if advisory.is_empty() { + "未找到可执行的可见控件".to_string() + } else { + advisory.join(";") + }, + ) +} + +async fn run_direct_browser_evidence_at( + root: &Path, + attempt: usize, +) -> Result { + let evidence_root = direct_browser_evidence_root(root, attempt)?; + let (preview, stop_sender) = start_local_game_preview_for_project(root)?; + let validation = validate_local_preview_in_browser_with_interaction( + BrowserValidationInput { + url: preview.url, + viewports: vec![ + BrowserValidationViewport::Desktop, + BrowserValidationViewport::Mobile, + ], + expected_text: Vec::new(), + settle_ms: 800, + fail_on_console_error: true, + playtest_scenario: None, + evidence_root, + }, + true, + ) + .await; + let _ = stop_sender.send(()); + validation +} + +fn write_direct_browser_acceptance_summary( + root: &Path, + evidence: &BrowserValidationResult, + accepted: bool, + attempts: usize, +) -> Result<(), String> { + let evidence_root = evidence + .evidence + .root + .strip_prefix(root) + .map_err(|_| "直连 Codex 浏览器证据目录不在当前项目内".to_string())?; + let evidence_directory = evidence_root + .parent() + .filter(|path| !path.as_os_str().is_empty()) + .ok_or_else(|| "直连 Codex 浏览器证据目录缺少版本目录".to_string())?; + let relative_path = evidence_directory + .join("acceptance-summary.json") + .to_string_lossy() + .replace('\\', "/"); + let viewports = evidence + .viewport_results + .iter() + .map(|viewport| { + serde_json::json!({ + "viewport": match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }, + "passed": viewport.passed, + "readyState": viewport.ready_state, + "nonEmptyCanvasCount": viewport + .canvases + .iter() + .filter(|canvas| canvas.non_empty == Some(true)) + .count(), + "canvasCount": viewport.canvases.len(), + "consoleErrorCount": viewport.console_errors.len(), + "exceptionCount": viewport.exceptions.len(), + "fatalRequestCount": viewport + .failed_requests + .iter() + .filter(|request| request.fatal) + .count(), + "screenshot": viewport + .screenshot_path + .strip_prefix(root) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")), + }) + }) + .collect::>(); + let diagnostics = evidence + .diagnostics + .iter() + .take(12) + .map(|detail| redact_agent_runtime_project_paths(root, detail, 320)) + .collect::>(); + let rendered_assets_by_viewport = evidence + .viewport_results + .iter() + .map(|viewport| { + serde_json::json!({ + "viewport": match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }, + "assets": direct_rendered_taonier_assets_in_viewport(root, viewport), + }) + }) + .collect::>(); + let source_report = evidence + .evidence + .report_path + .strip_prefix(root) + .ok() + .and_then(|path| path.to_str()) + .map(|path| path.replace('\\', "/")); + let summary = serde_json::json!({ + "schemaVersion": "direct-codex-browser-acceptance.v1", + "accepted": accepted, + "attempts": attempts, + "browserPassed": evidence.passed, + "viewports": viewports, + "diagnostics": diagnostics, + "renderedTaonierAssetsByViewport": rendered_assets_by_viewport, + "sourceReport": source_report, + }); + write_agent_runtime_json_sidecar_with_max_bytes( + root, + &relative_path, + "直连 Codex 浏览器验收摘要", + &summary, + 32 * 1024, + ) +} + +fn render_direct_browser_acceptance_report( + root: &Path, + evidence: &BrowserValidationResult, +) -> String { + let referenced_assets = direct_game_sources_referenced_taonier_assets(root); + let viewports = evidence + .viewport_results + .iter() + .map(|viewport| { + let name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let non_empty_canvas_count = viewport + .canvases + .iter() + .filter(|canvas| canvas.non_empty == Some(true)) + .count(); + format!( + "{name}: readyState={},非空 Canvas {non_empty_canvas_count}/{},控制台错误 {},异常 {},致命请求 {}", + viewport.ready_state, + viewport.canvases.len(), + viewport.console_errors.len(), + viewport.exceptions.len(), + viewport + .failed_requests + .iter() + .filter(|request| request.fatal) + .count(), + ) + }) + .collect::>(); + let interaction = evidence + .viewport_results + .iter() + .flat_map(|viewport| viewport.diagnostics.iter()) + .filter(|diagnostic| diagnostic.starts_with("[advisory]")) + .map(|diagnostic| redact_agent_runtime_project_paths(root, diagnostic, 180)) + .collect::>(); + let rendered_assets = evidence + .viewport_results + .iter() + .map(|viewport| { + let name = match viewport.viewport { + BrowserValidationViewport::Desktop => "desktop", + BrowserValidationViewport::Mobile => "mobile", + }; + let assets = direct_rendered_taonier_assets_in_viewport(root, viewport); + if assets.is_empty() { + format!("{name}: 未观察到平台素材进入 Canvas/WebGL 渲染") + } else { + format!("{name}: {}", assets.join("、")) + } + }) + .collect::>(); + format!( + "### 客户端真实试玩证据\n\n- 检查文件:`game/index.html`、`game/style.css`、`game/game.js` 及已登记陶泥儿图片。\n- 试玩动作:受限本地预览 + 真实 Chromium desktop/mobile 双视口;截图和完整报告已保存到项目 `.agent/runtime/direct-codex-browser-validation/`。\n- 双视口观察:{}。\n- 平台素材来源:已登记且可解码的陶泥儿图片;游戏源码引用:{}。\n- 平台素材运行时观察:{}。\n- 交互探针:{}。\n- 边界说明:客户端只证明来源、文件和浏览器运行基础;画面质量与玩法判断已由同一 Codex thread 依据上述证据复核。", + viewports.join(";"), + if referenced_assets.is_empty() { + "无(此状态不应被登记为完成)".to_string() + } else { + referenced_assets.join("、") + }, + rendered_assets.join(";"), + if interaction.is_empty() { + "未找到可执行的可见控件".to_string() + } else { + interaction.join(";") + }, + ) +} + fn sync_direct_codex_project_outputs_at( root: &Path, previous_output_fingerprint: Option<&str>, ) -> Result<(), String> { - if !root.join("game/index.html").is_file() { - return Err("Codex 返回后未找到 game/index.html,项目未进入可运行状态".to_string()); + if !direct_taonier_art_base_is_valid(root) { + return Err( + "游戏代码已生成,但缺少可用且已登记的陶泥儿平台美术资源;不会将项目标记为完成" + .to_string(), + ); } - if !direct_taonier_art_package_is_valid(root) { - return Err("游戏代码已生成,但缺少完整已登记的陶泥儿平台美术包(规范图、背景、核心图集与四类切片);不会将项目标记为完成".to_string()); - } - if !direct_game_sources_reference_taonier_art_package(root) { - return Err(format!( - "游戏代码已生成,但未在实际渲染中同时使用陶泥儿背景图 {} 与四类核心切片;不会将项目标记为完成", - DIRECT_CODEX_BACKGROUND_ASSET_PATH - )); + if let Some(error) = direct_game_output_completion_error(root) { + return Err(error); } let current_output_fingerprint = direct_codex_output_fingerprint(root); let outputs_changed = previous_output_fingerprint @@ -720,28 +1580,212 @@ pub(crate) async fn run_direct_game_creator_turn_at( return Err("聊天内容不能为空".to_string()); } emit_direct_game_creator_progress(root, "request.accepted", "已收到需求,正在准备智能创作"); - ensure_direct_taonier_art_package_at(root, prompt).await?; + match run_direct_game_creator_turn_inner(root, prompt).await { + Ok(reply) => Ok(reply), + Err(failure) => Err(record_direct_codex_turn_failure(root, failure)), + } +} + +async fn run_direct_game_creator_turn_inner( + root: &Path, + prompt: &str, +) -> Result { + if direct_taonier_art_base_is_valid(root) { + return run_direct_game_creator_turn_with_private_editor_credentials(root, prompt).await; + } + let external_editor_credentials = ensure_private_external_editor_api_credentials() + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error) + })?; + with_external_editor_api_credentials(external_editor_credentials, async { + run_direct_game_creator_turn_with_private_editor_credentials(root, prompt).await + }) + .await +} + +async fn run_direct_game_creator_turn_with_private_editor_credentials( + root: &Path, + prompt: &str, +) -> Result { + ensure_direct_taonier_art_package_at(root, prompt) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::ArtPreparation, error) + })?; let previous_output_fingerprint = direct_codex_output_fingerprint(root); let mut system_prompt = build_direct_codex_system_prompt(root); - system_prompt.push_str(&format!( - "\n本回合已由陶泥儿平台按标准合同生成并登记美术包:规范图 `/{}`、背景图 `/{}`、核心主图集 `/{}`,以及玩家 `/{}`、目标/道具 `/{}`、障碍/场景 `/{}`、反馈 `/{}` 四类切片。规范图只用于统一风格;游戏必须实际使用背景图和四个切片:`game/game.js` 要加载每个切片,并通过 Canvas `drawImage` 绘制对应核心类别。不得只在回复中提及路径。客户端将在回合结束时复核全部平台合同、文件存在、背景图被引用且四类切片进入实际 Canvas 渲染路径;任一条件不满足就不会把项目标记为完成。", - DIRECT_CODEX_ART_SPEC_ASSET_PATH, - DIRECT_CODEX_BACKGROUND_ASSET_PATH, - DIRECT_CODEX_SPRITESHEET_ASSET_PATH, - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[0].1, - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[1].1, - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[2].1, - DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[3].1, - )); + system_prompt.push_str("\n本回合已由陶泥儿平台准备并登记真实美术资源。请读取当前 `assets/` 与 `game/` 文件,选择实际存在且适合玩法的素材接入;不要假设四切片一定存在,也不要伪造缺失衍生物。客户端会在回合后启动真实 desktop/mobile 浏览器试玩,把结构化截图、Canvas、控制台、网络和交互证据发回同一会话;你必须依据证据继续修复,并在最终回复中给出检查文件、试玩动作、双视口观察、平台素材实际使用、已修复问题和剩余风险。"); emit_direct_game_creator_progress(root, "codex.start", "美术素材已准备,正在生成游戏代码"); - let reply = direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()) + let _initial_reply = + direct_game_creator_codex_chat_at(root, system_prompt.clone(), prompt.to_string()) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let initial_output_fingerprint = direct_codex_output_fingerprint(root); + let mut completion_error = direct_game_output_completion_error(root); + let mut evidence_attempts = 0_usize; + let mut evidence: Option = None; + let mut browser_checked_output_fingerprint: Option = None; + if completion_error.is_none() { + emit_direct_game_creator_progress( + root, + "codex.validate", + "游戏代码已生成,正在通过真实浏览器试玩", + ); + evidence_attempts = 1; + evidence = Some( + run_direct_browser_evidence_at(root, evidence_attempts) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?, + ); + browser_checked_output_fingerprint = Some(initial_output_fingerprint.clone()); + } else { + emit_direct_game_creator_progress(root, "codex.validate", "正在检查游戏入口与平台素材引用"); + } + let repair_prompt = direct_browser_evidence_prompt( + root, + evidence.as_ref(), + 1, + initial_output_fingerprint != previous_output_fingerprint, + completion_error.as_deref(), + ); + emit_direct_game_creator_progress(root, "codex.review", "正在根据试玩证据自主检查游戏"); + let mut reply = direct_game_creator_codex_chat_at(root, system_prompt.clone(), repair_prompt) .await - .map_err(|error| format!("direct-codex-error:{error}"))?; - emit_direct_game_creator_progress(root, "codex.ready", "游戏代码已生成,正在登记项目版本"); - sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)) - .map_err(|error| format!("Codex 已返回,但客户端登记生成产物失败:{error}"))?; + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let repaired_fingerprint = direct_codex_output_fingerprint(root); + completion_error = direct_game_output_completion_error(root); + if completion_error.is_none() + && (browser_checked_output_fingerprint.as_deref() != Some(repaired_fingerprint.as_str()) + || evidence.as_ref().is_none_or(|result| !result.passed)) + { + evidence_attempts += 1; + emit_direct_game_creator_progress(root, "codex.revalidate", "正在复核自主修改后的游戏"); + evidence = Some( + run_direct_browser_evidence_at(root, evidence_attempts) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?, + ); + browser_checked_output_fingerprint = Some(repaired_fingerprint.clone()); + } else if completion_error.is_some() { + evidence = None; + } + if direct_acceptance_needs_repair(root, evidence.as_ref(), completion_error.as_deref()) { + let repair_prompt = direct_browser_evidence_prompt( + root, + evidence.as_ref(), + 2, + repaired_fingerprint != previous_output_fingerprint, + completion_error.as_deref(), + ); + emit_direct_game_creator_progress( + root, + "codex.repair", + "试玩未通过,正在进行最后一次自主修复", + ); + reply = direct_game_creator_codex_chat_at(root, system_prompt, repair_prompt) + .await + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let final_fingerprint = direct_codex_output_fingerprint(root); + completion_error = direct_game_output_completion_error(root); + if completion_error.is_none() + && browser_checked_output_fingerprint.as_deref() != Some(final_fingerprint.as_str()) + { + evidence_attempts += 1; + emit_direct_game_creator_progress( + root, + "codex.final-validate", + "正在进行最终真实浏览器试玩", + ); + evidence = Some( + run_direct_browser_evidence_at(root, evidence_attempts) + .await + .map_err(|error| { + DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + error, + ) + })?, + ); + } else if completion_error.is_some() { + evidence = None; + } + } + if let Some(completion_error) = completion_error { + if let Some(evidence) = evidence.as_ref() { + write_direct_browser_acceptance_summary(root, evidence, false, evidence_attempts) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error) + })?; + } + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::VersionRegistration, + format!("Codex 自主验收未通过,未登记完成版本:{completion_error}"), + )); + } + let Some(evidence) = evidence else { + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + "Codex 自主试玩未产生真实浏览器证据,未登记完成版本", + )); + }; + if !evidence.passed { + write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?; + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + format!( + "Codex 自主试玩未通过,未登记完成版本:{}", + evidence + .diagnostics + .iter() + .take(4) + .cloned() + .collect::>() + .join(";") + ), + )); + } + if direct_browser_evidence_needs_art_repair(root, Some(&evidence)) { + write_direct_browser_acceptance_summary(root, &evidence, false, evidence_attempts) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::BrowserValidation, error) + })?; + return Err(DirectCodexTurnFailure::new( + DirectCodexFailureStage::BrowserValidation, + "Codex 自主试玩未证明陶泥儿平台素材进入核心 Canvas/WebGL 渲染,未登记完成版本" + .to_string(), + )); + } + emit_direct_game_creator_progress( + root, + "codex.ready", + "游戏已通过真实浏览器试玩,正在登记项目版本", + ); + sync_direct_codex_project_outputs_at(root, Some(&previous_output_fingerprint)).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error), + )?; + write_direct_browser_acceptance_summary(root, &evidence, true, evidence_attempts).map_err( + |error| DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error), + )?; emit_direct_game_creator_progress(root, "project.ready", "项目版本已登记,正在刷新运行预览"); - Ok(reply) + Ok(format!( + "{}\n\n{}", + reply.trim(), + render_direct_browser_acceptance_report(root, &evidence) + )) } #[tauri::command] @@ -821,12 +1865,470 @@ mod tests { let prompt = build_direct_codex_system_prompt(Path::new(".")); assert!(prompt.contains("陶泥儿游戏创作工作流")); assert!(prompt.contains("`assets/`")); - assert!(prompt.contains(DIRECT_CODEX_ART_SPEC_ASSET_PATH)); - assert!(prompt.contains(DIRECT_CODEX_BACKGROUND_ASSET_PATH)); - assert!(prompt.contains(DIRECT_CODEX_SPRITESHEET_ASSET_PATH)); - assert!(prompt.contains(DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[0].1)); - assert!(prompt.contains(DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[3].1)); + assert!(prompt.contains("规范图用于统一视觉风格")); + assert!(prompt.contains("完整图集和平台返回的切片可按实际可用性接入")); assert!(prompt.contains("不得手写或修改 `.agent/manifest.json`")); + assert!(prompt.contains("最终回复结构化列出检查文件")); + assert!(prompt.contains("desktop/mobile 观察")); + } + + #[test] + fn missing_platform_art_reference_becomes_a_same_thread_repair_prompt() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-repair-project", "直连整改") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + std::fs::write( + root.path().join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {};").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "document.querySelector('#game').getContext('2d').fillRect(0, 0, 1, 1);", + ) + .expect("script"); + + let completion_error = direct_game_output_completion_error(root.path()) + .expect("missing platform image reference must require repair"); + let prompt = + direct_browser_evidence_prompt(root.path(), None, 1, true, Some(&completion_error)); + + assert!(direct_acceptance_needs_repair( + root.path(), + None, + Some(&completion_error) + )); + assert!(prompt.contains("同一 Codex 会话")); + assert!(prompt.contains("客户端最低完成证明")); + assert!(prompt.contains("未在源码中引用任何已登记的陶泥儿平台图片")); + assert!(prompt.contains("本次未启动 Chromium")); + assert!(prompt.contains("直接修改真实 `game/` 文件")); + } + + fn direct_browser_evidence_fixture( + root: &Path, + viewport_diagnostics: &[(&str, &[&str])], + ) -> BrowserValidationResult { + let viewport_results = viewport_diagnostics + .iter() + .map(|(viewport, diagnostics)| BrowserViewportValidationResult { + viewport: if *viewport == "mobile" { + BrowserValidationViewport::Mobile + } else { + BrowserValidationViewport::Desktop + }, + width: if *viewport == "mobile" { 390 } else { 1280 }, + height: if *viewport == "mobile" { 844 } else { 720 }, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "fixture".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: String::new(), + visible_text_character_count: 0, + dom_character_count: 0, + expected_text: Vec::new(), + console_errors: Vec::new(), + console_warnings: Vec::new(), + exceptions: Vec::new(), + failed_requests: Vec::new(), + canvases: Vec::new(), + blocked_popup_count: 0, + blocked_dialog_count: 0, + blocked_download_count: 0, + blocked_permission_count: 0, + blocked_service_worker_count: 0, + screenshot_path: root.join(format!("{viewport}.png")), + passed: true, + diagnostics: diagnostics + .iter() + .map(|value| (*value).to_string()) + .collect(), + }) + .collect(); + BrowserValidationResult { + schema_version: "fixture".to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "Chrome/fixture".to_string(), + protocol_version: "1.3".to_string(), + }, + passed: true, + viewport_results, + playtest: None, + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: root.join( + ".agent/runtime/direct-codex-browser-validation/fixture/evidence", + ), + report_path: root.join( + ".agent/runtime/direct-codex-browser-validation/fixture/evidence/validation.json", + ), + }, + completed_at_unix_ms: 1, + } + } + + #[test] + fn rendered_taonier_asset_observation_satisfies_both_viewports_and_is_reported() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-rendered-art", "运行时素材观察") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + write_minimal_direct_game_using_spritesheet(root.path()); + let rendered = format!( + "{DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX}/assets/art-spritesheet.png?cache=fixture" + ); + let evidence = direct_browser_evidence_fixture( + root.path(), + &[ + ("desktop", &[rendered.as_str()]), + ("mobile", &[rendered.as_str()]), + ], + ); + + assert!(!direct_browser_evidence_needs_art_repair( + root.path(), + Some(&evidence) + )); + let report = render_direct_browser_acceptance_report(root.path(), &evidence); + assert!(report.contains("平台素材运行时观察"), "{report}"); + assert!( + report.contains("desktop: assets/art-spritesheet.png"), + "{report}" + ); + assert!( + report.contains("mobile: assets/art-spritesheet.png"), + "{report}" + ); + + write_direct_browser_acceptance_summary(root.path(), &evidence, true, 1) + .expect("write acceptance summary"); + let summary = std::fs::read_to_string(root.path().join( + ".agent/runtime/direct-codex-browser-validation/fixture/acceptance-summary.json", + )) + .expect("read acceptance summary"); + assert!(summary.contains("renderedTaonierAssetsByViewport")); + assert_eq!(summary.matches("assets/art-spritesheet.png").count(), 2); + } + + #[test] + fn source_reference_without_canvas_or_webgl_observation_requests_same_thread_repair() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-side-thumbnail", "旁侧缩略图整改") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + write_minimal_direct_game_using_spritesheet(root.path()); + let evidence = + direct_browser_evidence_fixture(root.path(), &[("desktop", &[]), ("mobile", &[])]); + + assert!(direct_game_output_completion_error(root.path()).is_none()); + assert!(direct_browser_evidence_needs_art_repair( + root.path(), + Some(&evidence) + )); + let prompt = direct_browser_evidence_prompt(root.path(), Some(&evidence), 1, true, None); + assert!( + prompt.contains("旁侧缩略图不能证明素材进入核心玩法"), + "{prompt}" + ); + assert!( + prompt.contains("请读取当前工作区的实际 `game/` 文件"), + "{prompt}" + ); + } + + #[test] + fn one_viewport_without_rendered_platform_art_still_requests_repair() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-mobile-art-gap", "移动端素材整改") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + write_minimal_direct_game_using_spritesheet(root.path()); + let rendered = + format!("{DIRECT_BROWSER_RENDERED_IMAGE_PATHS_PREFIX}/assets/art-spritesheet.png"); + let evidence = direct_browser_evidence_fixture( + root.path(), + &[("desktop", &[rendered.as_str()]), ("mobile", &[])], + ); + + assert!(direct_browser_evidence_needs_art_repair( + root.path(), + Some(&evidence) + )); + } + + #[test] + fn browser_diagnostics_are_redacted_before_same_thread_repair() { + let root = tempfile::tempdir().expect("temp dir"); + let browser_error = format!( + "Uncaught ReferenceError at {}?token=secret", + root.path().join("game/game.js").display() + ); + let evidence = BrowserValidationResult { + schema_version: "fixture".to_string(), + url: "http://127.0.0.1:34567/".to_string(), + browser: BrowserIdentity { + kind: DiscoveredBrowserKind::Chrome, + product: "Chrome/fixture".to_string(), + protocol_version: "1.3".to_string(), + }, + passed: false, + viewport_results: vec![BrowserViewportValidationResult { + viewport: BrowserValidationViewport::Desktop, + width: 1280, + height: 720, + final_url: "http://127.0.0.1:34567/".to_string(), + title: "fixture".to_string(), + ready_state: "complete".to_string(), + visible_text_summary: String::new(), + visible_text_character_count: 0, + dom_character_count: 0, + expected_text: Vec::new(), + console_errors: Vec::new(), + console_warnings: Vec::new(), + exceptions: vec![BrowserException { + text: browser_error, + source_url: None, + line_number: 1, + column_number: 1, + }], + failed_requests: Vec::new(), + canvases: Vec::new(), + blocked_popup_count: 0, + blocked_dialog_count: 0, + blocked_download_count: 0, + blocked_permission_count: 0, + blocked_service_worker_count: 0, + screenshot_path: root.path().join("desktop.png"), + passed: false, + diagnostics: vec!["canvas is empty".to_string()], + }], + playtest: None, + diagnostics: Vec::new(), + evidence: BrowserValidationEvidencePaths { + root: root.path().join("evidence"), + report_path: root.path().join("evidence/validation.json"), + }, + completed_at_unix_ms: 1, + }; + let prompt = direct_browser_evidence_prompt(root.path(), Some(&evidence), 2, false, None); + + assert!(prompt.contains("desktop exception:")); + assert!(prompt.contains("同一 Codex 会话")); + assert!(!prompt.contains("secret"), "{prompt}"); + assert!( + !prompt.contains(&root.path().display().to_string()), + "{prompt}" + ); + } + + #[test] + fn direct_failure_diagnostic_is_redacted_and_persisted_with_a_stable_stage() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") + .expect("init project"); + let error = record_direct_codex_turn_failure( + root.path(), + DirectCodexTurnFailure::new( + DirectCodexFailureStage::ArtPreparation, + "读取陶泥儿画布资源失败:https://provider.example/private?token=secret C:\\Users\\private\\project authorization=Bearer secret", + ), + ); + + assert!(error + .starts_with("direct-codex-failure:v1 stage=art-preparation retryable=true summary=")); + assert!(error.contains(""), "{error}"); + assert!(error.contains(""), "{error}"); + assert!(!error.contains("secret"), "{error}"); + assert!(!error.contains("provider.example"), "{error}"); + + let diagnostics = root.path().join(".agent/runtime/direct-codex-diagnostics"); + let entries = std::fs::read_dir(&diagnostics) + .expect("diagnostic directory") + .filter_map(Result::ok) + .collect::>(); + assert_eq!(entries.len(), 1); + let diagnostic_path = entries[0].path().join("failure.json"); + let diagnostic = std::fs::read_to_string(diagnostic_path).expect("diagnostic sidecar"); + assert!(diagnostic.contains("\"stage\": \"art-preparation\"")); + assert!(diagnostic.contains("\"retryable\": true")); + assert!(diagnostic.contains("")); + assert!(!diagnostic.contains("secret")); + assert!(!diagnostic.contains("provider.example")); + } + + #[test] + fn direct_failure_diagnostic_marks_ambiguous_canvas_identity_as_not_retryable() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") + .expect("init project"); + let error = record_direct_codex_turn_failure( + root.path(), + DirectCodexTurnFailure::new( + DirectCodexFailureStage::ArtPreparation, + "陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复", + ), + ); + + assert!( + error.contains("stage=art-preparation retryable=false"), + "{error}" + ); + assert!(error.contains("历史画布资源不满足安全恢复条件"), "{error}"); + } + + #[test] + fn direct_failure_diagnostic_keeps_private_credential_storage_failure_actionable() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-diagnostic", "直连诊断") + .expect("init project"); + let error = record_direct_codex_turn_failure( + root.path(), + DirectCodexTurnFailure::new( + DirectCodexFailureStage::ArtPreparation, + "private-external-editor-credential-storage-preparation-failed: 本机开发者凭据存储目录未安全初始化;未创建远端凭据", + ), + ); + + assert!( + error.contains("summary=本机开发者凭据存储目录未安全初始化;未创建远端凭据"), + "{error}" + ); + assert!(error.contains("请检查当前 Windows 用户对本机私有凭据目录的权限后重试")); + assert!(!error.contains("[redacted sensitive context]"), "{error}"); + } + + fn direct_art_spec_identity_fixture() -> DirectTaonierArtAssetIdentity { + DirectTaonierArtAssetIdentity { + resource_id: "taonier-resource-icon-spec".to_string(), + asset_object_id: "taonier-object-icon-spec".to_string(), + canvas_project_id: "taonier-project".to_string(), + } + } + + #[test] + fn direct_source_only_spritesheet_recovery_accepts_single_current_spec_reference() { + let art_spec = direct_art_spec_identity_fixture(); + let resources = vec![serde_json::json!({ + "resourceId": "taonier-resource-spritesheet", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "assetObjectId": "taonier-object-spritesheet", + "taskId": "taonier-task-spritesheet", + "generationInputs": { + "references": [{ "refId": "taonier-resource-icon-spec" }] + } + })]; + + let recovered = direct_recoverable_spritesheet_resource(&resources, &art_spec) + .expect("source-only identity must be accepted") + .expect("a single matching resource must be returned"); + + assert_eq!( + json_string_field(recovered, "resourceId").as_deref(), + Some("taonier-resource-spritesheet") + ); + } + + #[test] + fn direct_source_only_spritesheet_recovery_rejects_multiple_or_foreign_references() { + let art_spec = direct_art_spec_identity_fixture(); + let foreign_or_ambiguous = vec![ + serde_json::json!({ + "resourceId": "foreign-reference", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [{ "refId": "someone-else" }] + } + }), + serde_json::json!({ + "resourceId": "multiple-references", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [ + { "refId": "taonier-resource-icon-spec" }, + { "refId": "another-spec" } + ] + } + }), + ]; + + assert!( + direct_recoverable_spritesheet_resource(&foreign_or_ambiguous, &art_spec) + .expect("unrelated entries are not an identity conflict") + .is_none() + ); + } + + #[test] + fn direct_source_only_spritesheet_recovery_fails_closed_when_multiple_candidates_match() { + let art_spec = direct_art_spec_identity_fixture(); + let candidates = vec![ + serde_json::json!({ + "resourceId": "candidate-one", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [{ "refId": "taonier-resource-icon-spec" }] + } + }), + serde_json::json!({ + "resourceId": "candidate-two", + "assetKind": "icon-spritesheet", + "projectId": "taonier-project", + "generationInputs": { + "references": [{ "refId": "taonier-resource-icon-spec" }] + } + }), + ]; + + let error = direct_recoverable_spritesheet_resource(&candidates, &art_spec) + .expect_err("multiple source-only candidates must fail closed"); + assert!(error.contains("身份不唯一"), "{error}"); + } + + #[test] + fn direct_source_preserved_recovery_is_limited_to_the_canonical_spritesheet() { + let error = + format!("{PLATFORM_GENERATION_SOURCE_PRESERVED_NO_RETRY_PREFIX} postprocess failed"); + assert!(direct_spritesheet_source_preserved_failure( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + &error, + )); + assert!(!direct_spritesheet_source_preserved_failure( + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + &error, + )); + assert!(!direct_spritesheet_source_preserved_failure( + DIRECT_CODEX_SPRITESHEET_ASSET_PATH, + "ordinary platform failure", + )); + } + + #[test] + fn source_preserved_recovery_clears_the_completed_generation_ledger() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-ledger-cleanup", "源图恢复账本清理") + .expect("init project"); + let pending = setup_platform_art_generation_runtime_accepted_for_recovery_test( + root.path(), + "art-spritesheet", + ) + .expect("accepted generation ledger"); + let runtime_context = platform_art_generation_runtime_context_from_pending(&pending); + let ledger = root + .path() + .join(".agent/runtime/canvas-generation-requests/art-director/art-spritesheet.json"); + assert!(ledger.is_file(), "{pending:?}"); + + clear_direct_taonier_generation_ledger_after_source_recovery(root.path(), &runtime_context) + .expect("clear completed source-preserved ledger"); + + assert!(!ledger.exists()); } fn register_direct_taonier_art_asset_fixture(root: &Path, local_path: &str, kind: &str) { @@ -847,7 +2349,19 @@ mod tests { Vec::new(), ), }; - std::fs::write(root.join(local_path), b"png").expect("visual asset"); + let mut png_bytes = Vec::new(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([255, 96, 32, 255]), + )); + image + .write_to( + &mut std::io::Cursor::new(&mut png_bytes), + image::ImageFormat::Png, + ) + .expect("encode visual asset"); + std::fs::write(root.join(local_path), &png_bytes).expect("visual asset"); register_local_asset_at( root, local_path, @@ -871,17 +2385,7 @@ mod tests { } fn register_direct_taonier_art_package_fixture(root: &Path) { - std::fs::create_dir_all(root.join("assets")).expect("assets dir"); - register_direct_taonier_art_asset_fixture( - root, - DIRECT_CODEX_ART_SPEC_ASSET_PATH, - "icon-spec", - ); - register_direct_taonier_art_asset_fixture( - root, - DIRECT_CODEX_BACKGROUND_ASSET_PATH, - "game-background", - ); + register_direct_taonier_art_base_fixture(root); register_direct_taonier_art_asset_fixture( root, DIRECT_CODEX_SPRITESHEET_ASSET_PATH, @@ -893,7 +2397,7 @@ mod tests { let absolute = root.join(path); std::fs::create_dir_all(absolute.parent().expect("slice parent")) .expect("slice directory"); - std::fs::write(&absolute, b"png").expect("slice png"); + std::fs::write(&absolute, tiny_visible_png()).expect("slice png"); serde_json::json!({ "name": usage, "usage": usage, @@ -936,6 +2440,66 @@ mod tests { .expect("receipt"); } + fn register_direct_taonier_art_base_fixture(root: &Path) { + std::fs::create_dir_all(root.join("assets")).expect("assets dir"); + register_direct_taonier_art_asset_fixture( + root, + DIRECT_CODEX_ART_SPEC_ASSET_PATH, + "icon-spec", + ); + register_direct_taonier_art_asset_fixture( + root, + DIRECT_CODEX_BACKGROUND_ASSET_PATH, + "game-background", + ); + } + + fn tiny_visible_png() -> Vec { + let mut bytes = Vec::new(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([255, 96, 32, 255]), + )); + image + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode tiny PNG"); + bytes + } + + fn tiny_transparent_png() -> Vec { + let mut bytes = Vec::new(); + let image = image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 1, + 1, + image::Rgba([255, 96, 32, 0]), + )); + image + .write_to( + &mut std::io::Cursor::new(&mut bytes), + image::ImageFormat::Png, + ) + .expect("encode transparent PNG"); + bytes + } + + fn write_minimal_direct_game_using_spritesheet(root: &Path) { + std::fs::write( + root.join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.join("game/style.css"), "body {};").expect("style"); + std::fs::write( + root.join("game/game.js"), + "const art = new Image(); art.src = '/assets/art-spritesheet.png'; document.querySelector('#game').getContext('2d').drawImage(art, 0, 0);", + ) + .expect("script"); + } + #[test] fn direct_output_sync_registers_game_files_and_marks_prototype_completed() { let root = tempfile::tempdir().expect("temp dir"); @@ -1059,7 +2623,7 @@ mod tests { let error = sync_direct_codex_project_outputs_at(root.path(), None) .expect_err("missing TaoNier art package must block completion"); - assert!(error.contains("缺少完整已登记的陶泥儿平台美术包")); + assert!(error.contains("缺少可用且已登记的陶泥儿平台美术资源")); let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); assert!(manifest .assets @@ -1076,26 +2640,29 @@ mod tests { } #[test] - fn direct_output_sync_refuses_a_spritesheet_that_is_not_rendered() { + fn direct_output_sync_refuses_code_without_any_platform_art_reference() { let root = tempfile::tempdir().expect("temp dir"); init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") .expect("init project"); register_direct_taonier_art_package_fixture(root.path()); std::fs::write( root.path().join("game/index.html"), - "", + "", ) .expect("index"); std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); std::fs::write( root.path().join("game/game.js"), - "const sprite = new Image(); sprite.src = '/assets/art-spritesheet-slices/player.png';", + "const canvas = document.querySelector('canvas'); canvas.getContext('2d').fillRect(0, 0, 10, 10);", ) .expect("script"); let error = sync_direct_codex_project_outputs_at(root.path(), None) - .expect_err("non-rendered spritesheet must block completion"); - assert!(error.contains("未在实际渲染中同时使用陶泥儿背景图")); + .expect_err("code without a platform art reference must block completion"); + assert!( + error.contains("未在源码中引用任何已登记的陶泥儿平台图片"), + "{error}" + ); let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); assert!(manifest .assets @@ -1104,14 +2671,115 @@ mod tests { } #[test] - fn direct_taonier_art_package_rejects_missing_required_slice() { + fn direct_output_sync_accepts_trusted_spec_and_background_without_a_historical_spritesheet() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_base_fixture(root.path()); + std::fs::write( + root.path().join("game/index.html"), + "", + ) + .expect("index"); + std::fs::write(root.path().join("game/style.css"), "body {}").expect("style"); + std::fs::write( + root.path().join("game/game.js"), + "const background = new Image(); background.src = '/assets/direct-game-background.png'; document.querySelector('#game').getContext('2d').drawImage(background, 0, 0); requestAnimationFrame(()=>{});", + ) + .expect("script"); + + sync_direct_codex_project_outputs_at(root.path(), None) + .expect("trusted TaoNier spec and background must be sufficient for direct completion"); + + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert_eq!(manifest.versions.len(), 1); + assert!(manifest + .assets + .iter() + .all(|asset| { asset.local_path != DIRECT_CODEX_SPRITESHEET_ASSET_PATH })); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + } + + #[test] + fn direct_taonier_art_package_accepts_a_valid_platform_package_without_slices() { let root = tempfile::tempdir().expect("temp dir"); init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") .expect("init project"); register_direct_taonier_art_package_fixture(root.path()); - std::fs::remove_file(root.path().join(DIRECT_CODEX_SPRITESHEET_SLICE_PATHS[2].1)) - .expect("remove required slice"); + for (_, path) in DIRECT_CODEX_SPRITESHEET_SLICE_PATHS { + let _ = std::fs::remove_file(root.path().join(path)); + } + let _ = std::fs::remove_file( + root.path() + .join("assets/art-spritesheet-slices/manifest.json"), + ); + let _ = std::fs::remove_file( + root.path() + .join(".agent/runtime/art-spritesheet-contract.json"), + ); - assert!(!direct_taonier_art_package_is_valid(root.path())); + assert!(direct_taonier_art_package_is_valid(root.path())); + } + + #[test] + fn direct_output_sync_accepts_a_complete_spritesheet_without_slices() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-sync-project", "直连产物同步") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + for (_, path) in DIRECT_CODEX_SPRITESHEET_SLICE_PATHS { + let _ = std::fs::remove_file(root.path().join(path)); + } + let _ = std::fs::remove_file( + root.path() + .join("assets/art-spritesheet-slices/manifest.json"), + ); + let _ = std::fs::remove_file( + root.path() + .join(".agent/runtime/art-spritesheet-contract.json"), + ); + write_minimal_direct_game_using_spritesheet(root.path()); + + sync_direct_codex_project_outputs_at(root.path(), None) + .expect("a valid complete spritesheet must be a sufficient direct art path"); + + let manifest = read_manifest(&root.path().join(".agent/manifest.json")).expect("manifest"); + assert_eq!(manifest.versions.len(), 1); + assert_eq!( + manifest + .tasks + .iter() + .find(|task| task.id == "code-prototype") + .map(|task| task.status.clone()), + Some(GameCreationAppTaskStatus::Completed) + ); + } + + #[test] + fn direct_taonier_art_package_rejects_a_corrupt_or_transparent_primary_png() { + let root = tempfile::tempdir().expect("temp dir"); + init_local_game_project_at(root.path(), "direct-art-integrity", "直连美术完整性") + .expect("init project"); + register_direct_taonier_art_package_fixture(root.path()); + let spritesheet = root.path().join(DIRECT_CODEX_SPRITESHEET_ASSET_PATH); + + std::fs::write(&spritesheet, b"not-a-png").expect("write corrupt PNG"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "a corrupt platform PNG must fail closed" + ); + + std::fs::write(&spritesheet, tiny_transparent_png()).expect("write transparent PNG"); + assert!( + !direct_taonier_art_package_is_valid(root.path()), + "a fully transparent platform PNG must fail closed" + ); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs index a14024431..e6d8ca19f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/generation.rs @@ -24,6 +24,7 @@ pub(crate) use canvas_generation::{ }; pub(in crate::agent) use canvas_generation::{ commit_prepared_platform_art_asset_at, commit_prepared_platform_art_asset_strict_slices_at, + generate_platform_art_asset_with_runtime_options_at, platform_art_generation_error_result_unknown, request_platform_art_asset_with_runtime_options_at, validate_platform_art_png_bytes_with_limits, 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 0c1c99497..900c97c42 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 @@ -133,7 +133,10 @@ pub(crate) fn needs_platform_art_asset_generation(root: &Path, briefs: &[AgentGr } pub(crate) fn editor_api_key_is_configured() -> bool { - if editor_api_mode() == EditorApiMode::PlatformAccount { + if external_editor_api_credentials_override_is_active() { + return true; + } + if platform_editor_uses_account_routes() { return platform_session_is_available(); } load_game_creator_app_config() @@ -662,7 +665,7 @@ pub(crate) async fn submit_external_generation_request( ) -> Result { let mut request_body = serde_json::from_str::(request_body_json) .map_err(|error| format!("解析平台图片生成请求失败:{error}"))?; - if editor_api_mode() == EditorApiMode::PlatformAccount { + if platform_editor_uses_account_routes() { let inputs = request_body.as_object_mut().and_then(|body| { body.entry("generationInputs") .or_insert_with(|| serde_json::json!({})) 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 b6953e4a3..e7597e6ad 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 @@ -517,10 +517,6 @@ fn response_stream_finalization_commits_exactly_one_canonical_assistant() { #[test] fn non_stream_professional_final_reply_remains_queryable_after_later_project_revision() { let _config_guard = crate::tests::write_test_local_config("{}".to_string()); - assert!( - !GameCreatorLlmConfig::default().stream, - "the production default exercises the non-stream final-reply path" - ); let project = tempfile::tempdir().expect("create non-stream professional reply project"); let root = project.path(); init_local_game_project_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 5b6c82f2e..4ddb4fe01 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 @@ -260,9 +260,8 @@ pub(crate) use entrypoints::{ chat_with_game_creator_role_agent_runtime_for_session_at, chat_with_game_creator_role_agent_stream_at, chat_with_game_creator_role_agent_stream_for_session_at, - configure_game_creator_manifest_invalidation_event_sink, + configure_game_creator_manifest_invalidation_event_sink, emit_direct_game_creator_progress, emit_game_creator_agent_runtime_update, game_creator_agent_runtime_update_event, - emit_direct_game_creator_progress, generate_local_game_draft_at, read_game_creator_agent_runtime_at, read_game_creator_agent_runtime_for_session_at, read_game_creator_agent_runtimes_at, set_game_creator_agent_runtime_update_app_handle, 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 75f8bb086..6b7143b93 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/assets.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/assets.rs @@ -1,4 +1,363 @@ use super::*; +use std::future::Future; + +const PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME: &str = "external-editor-api.json"; +const PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES: u64 = 8 * 1024; +const DIRECT_EXTERNAL_EDITOR_API_KEY_NAME: &str = "陶泥儿 AGC 直连客户端(本机)"; +const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE: &str = + "private-external-editor-credential-storage-preparation-failed"; +const PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE: &str = + "private-external-editor-credential-persistence-failed"; + +/// A task-scoped External Editor credential used only by the direct Codex +/// runtime. It never changes the GUI account session and deliberately keeps +/// the key private: callers may use it to make authenticated requests but may +/// not format or serialize this value for diagnostics. +#[derive(Clone)] +pub(crate) struct ExternalEditorApiCredentials { + api_base_url: String, + api_key: String, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +struct PrivateExternalEditorApiKeyFile { + api_key: String, + #[serde(skip_serializing_if = "Option::is_none")] + base_url: Option, +} + +tokio::task_local! { + static EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE: ExternalEditorApiCredentials; +} + +fn active_external_editor_api_credentials() -> Option { + EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE + .try_with(Clone::clone) + .ok() +} + +pub(crate) async fn with_external_editor_api_credentials( + credentials: ExternalEditorApiCredentials, + operation: impl Future, +) -> T { + EXTERNAL_EDITOR_API_CREDENTIALS_OVERRIDE + .scope(credentials, operation) + .await +} + +fn private_external_editor_api_key_path() -> Result { + let home = std::env::var_os("USERPROFILE") + .or_else(|| std::env::var_os("HOME")) + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .ok_or_else(|| "无法定位当前用户的私有开发者 Key 目录".to_string())?; + Ok(home + .join(".config") + .join("genarrative") + .join(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME)) +} + +pub(crate) fn normalize_external_editor_api_base_url(value: &str) -> Result { + let value = value.trim().trim_end_matches('/'); + let parsed = + url::Url::parse(value).map_err(|_| "陶泥儿 External Editor 地址无效".to_string())?; + if !matches!(parsed.scheme(), "http" | "https") + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.query().is_some() + || parsed.fragment().is_some() + || parsed.path() != "/" + { + return Err("陶泥儿 External Editor 地址必须是纯 HTTP(S) origin".to_string()); + } + let host = parsed + .host_str() + .ok_or_else(|| "陶泥儿 External Editor 地址缺少 host".to_string())?; + let production = matches!(host, "www.genarrative.world" | "dev.genarrative.world"); + let loopback = host == "localhost" + || host == "127.0.0.1" + || host + .parse::() + .is_ok_and(|ip| ip.is_loopback()); + if !production && !(cfg!(debug_assertions) && loopback && parsed.scheme() == "http") { + return Err("陶泥儿 External Editor 地址不在受信任白名单内".to_string()); + } + Ok(value.to_string()) +} + +fn normalize_external_editor_api_key(value: &str) -> Result { + let value = value.trim(); + if !value.starts_with("tnr_sk_") + || value.len() > 1024 + || value + .chars() + .any(|character| character.is_whitespace() || character.is_control()) + { + return Err("本机陶泥儿开发者 API Key 无效,请重新登录客户端后重试".to_string()); + } + Ok(value.to_string()) +} + +fn private_external_editor_api_credentials_from_file_at( + path: &Path, +) -> Result, String> { + let metadata = match fs::symlink_metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(error) => { + return Err(format!("读取本机陶泥儿开发者 Key 配置失败:{error}")); + } + }; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("本机陶泥儿开发者 Key 配置必须是普通文件".to_string()); + } + if metadata.len() > PRIVATE_EXTERNAL_EDITOR_API_KEY_MAX_BYTES { + return Err("本机陶泥儿开发者 Key 配置过大,已拒绝读取".to_string()); + } + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user(path, false, false)?; + let content = fs::read_to_string(path) + .map_err(|error| format!("读取本机陶泥儿开发者 Key 配置失败:{error}"))?; + let parsed = serde_json::from_str::(&content) + .map_err(|_| "本机陶泥儿开发者 Key 配置格式无效,请重新登录客户端后重试".to_string())?; + let api_key = normalize_external_editor_api_key(&parsed.api_key)?; + let api_base_url = normalize_external_editor_api_base_url( + parsed + .base_url + .as_deref() + .unwrap_or(DEFAULT_CANVAS_SYNC_API_BASE_URL), + )?; + Ok(Some(ExternalEditorApiCredentials { + api_base_url, + api_key, + })) +} + +fn ensure_plain_private_external_editor_directory( + path: &Path, + label: &str, +) -> Result { + match fs::symlink_metadata(path) { + Ok(metadata) => { + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录")); + } + Ok(false) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let created = match fs::create_dir(path) { + Ok(()) => true, + Err(create_error) if create_error.kind() == std::io::ErrorKind::AlreadyExists => { + false + } + Err(create_error) => { + return Err(format!( + "创建本机陶泥儿开发者凭据{label}失败:{create_error}" + )); + } + }; + let metadata = fs::symlink_metadata(path).map_err(|metadata_error| { + format!("读取本机陶泥儿开发者凭据{label}失败:{metadata_error}") + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("本机陶泥儿开发者凭据{label}必须是普通目录")); + } + Ok(created) + } + Err(error) => Err(format!("读取本机陶泥儿开发者凭据{label}失败:{error}")), + } +} + +/// Prepares the exact private directory before a one-time remote developer key +/// is requested. Existing directories are verified, not adopted; only the +/// directory created by this invocation may have its Windows owner initialized. +fn prepare_private_external_editor_api_credentials_parent_dir_at( + path: &Path, +) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "本机陶泥儿开发者凭据配置缺少父目录".to_string())?; + let container = parent + .parent() + .ok_or_else(|| "本机陶泥儿开发者凭据配置缺少上级目录".to_string())?; + ensure_plain_private_external_editor_directory(container, "上级目录")?; + let parent_created = ensure_plain_private_external_editor_directory(parent, "目录")?; + #[cfg(windows)] + if parent_created { + initialize_windows_game_creator_directory_owner_for_current_user(parent)?; + } else { + secure_windows_game_creator_path_for_current_user(parent, true, false)?; + } + #[cfg(unix)] + if parent_created { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700)) + .map_err(|error| format!("收紧本机陶泥儿开发者凭据目录权限失败:{error}"))?; + } + Ok(()) +} + +pub(crate) fn private_external_editor_credentials_storage_preparation_failed(error: &str) -> bool { + error.starts_with(PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE) +} + +pub(crate) fn private_external_editor_credentials_persistence_failed(error: &str) -> bool { + error.starts_with(PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE) +} + +fn write_private_external_editor_api_credentials_at( + path: &Path, + credentials: &ExternalEditorApiCredentials, +) -> Result<(), String> { + prepare_private_external_editor_api_credentials_parent_dir_at(path)?; + let parent = path + .parent() + .ok_or_else(|| "本机陶泥儿开发者 Key 配置缺少父目录".to_string())?; + let parent_metadata = fs::symlink_metadata(parent) + .map_err(|error| format!("读取本机陶泥儿开发者 Key 目录失败:{error}"))?; + if parent_metadata.file_type().is_symlink() || !parent_metadata.is_dir() { + return Err("本机陶泥儿开发者 Key 目录必须是普通目录".to_string()); + } + if path.exists() { + return Err("本机陶泥儿开发者 Key 已存在,拒绝覆盖".to_string()); + } + let body = serde_json::to_string_pretty(&PrivateExternalEditorApiKeyFile { + api_key: credentials.api_key.clone(), + base_url: Some(credentials.api_base_url.clone()), + }) + .map_err(|error| format!("序列化本机陶泥儿开发者 Key 配置失败:{error}"))?; + let temporary = parent.join(format!( + ".{PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME}.tmp.{}.{}", + std::process::id(), + unix_millis(), + )); + let mut options = fs::OpenOptions::new(); + options.create_new(true).write(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|error| format!("创建本机陶泥儿开发者 Key 临时文件失败:{error}"))?; + let write_result = file + .write_all(format!("{body}\n").as_bytes()) + .and_then(|_| file.sync_all()); + drop(file); + if let Err(error) = write_result { + let _ = fs::remove_file(&temporary); + return Err(format!("写入本机陶泥儿开发者 Key 临时文件失败:{error}")); + } + #[cfg(windows)] + initialize_windows_game_creator_file_owner_for_current_user(&temporary)?; + match fs::hard_link(&temporary, path) { + Ok(()) => { + let _ = fs::remove_file(&temporary); + } + Err(error) => { + let _ = fs::remove_file(&temporary); + return Err(match error.kind() { + std::io::ErrorKind::AlreadyExists => { + "本机陶泥儿开发者 Key 已被另一个进程写入,请直接重试".to_string() + } + _ => format!("安全保存本机陶泥儿开发者 Key 失败:{error}"), + }); + } + } + #[cfg(windows)] + secure_windows_game_creator_path_for_current_user(path, false, true)?; + Ok(()) +} + +async fn create_private_external_editor_api_credentials_from_platform_session( +) -> Result { + let session = current_platform_session().ok_or_else(|| { + "authentication-required: 未找到本机陶泥儿开发者 Key;请先在客户端登录一次以创建本机 Key" + .to_string() + })?; + let api_base_url = normalize_external_editor_api_base_url(&session.api_base_url)?; + let client = reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(30)) + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| format!("创建本机开发者 Key 客户端失败:{error}"))?; + let response = client + .post(format!("{api_base_url}/api/profile/api-keys")) + .bearer_auth(&session.access_token) + .json(&serde_json::json!({ "name": DIRECT_EXTERNAL_EDITOR_API_KEY_NAME })) + .send() + .await + .map_err(|error| { + format!("创建本机陶泥儿开发者 Key 未取得确定响应;不会自动重试:{error}") + })?; + let status = response.status(); + if !status.is_success() { + return Err(match status { + reqwest::StatusCode::UNAUTHORIZED => { + "authentication-required: 陶泥儿登录已失效,无法创建本机开发者 Key".to_string() + } + reqwest::StatusCode::FORBIDDEN => { + "permission-denied: 当前陶泥儿账号无权创建本机开发者 Key".to_string() + } + _ => format!("创建本机陶泥儿开发者 Key 失败:HTTP {}", status.as_u16()), + }); + } + let payload = response + .json::() + .await + .map_err(|_| "创建本机陶泥儿开发者 Key 响应格式无效".to_string())?; + let api_key = payload + .get("apiKey") + .or_else(|| payload.pointer("/data/apiKey")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| "创建本机陶泥儿开发者 Key 响应缺少一次性 Key".to_string())?; + Ok(ExternalEditorApiCredentials { + api_base_url, + api_key: normalize_external_editor_api_key(api_key)?, + }) +} + +/// Reads the user-private External Editor key used by the direct Runtime. If +/// it does not exist, an already authenticated GUI account may create exactly +/// one key and write it to private local storage. The GUI login token is never +/// copied into the file or passed to the later isolated Runtime. +pub(crate) async fn ensure_private_external_editor_api_credentials( +) -> Result { + let path = private_external_editor_api_key_path()?; + if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { + return Ok(credentials); + } + prepare_private_external_editor_api_credentials_parent_dir_at(&path).map_err(|_| { + format!( + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_STORAGE_PREPARATION_FAILURE}: 本机开发者凭据存储目录未安全初始化;未创建远端凭据" + ) + })?; + // Another local client may have completed the atomic write while this call + // prepared the directory. Re-read before creating a distinct remote key. + if let Some(credentials) = private_external_editor_api_credentials_from_file_at(&path)? { + return Ok(credentials); + } + let credentials = + create_private_external_editor_api_credentials_from_platform_session().await?; + if write_private_external_editor_api_credentials_at(&path, &credentials).is_err() { + return Err(format!( + "{PRIVATE_EXTERNAL_EDITOR_CREDENTIAL_PERSISTENCE_FAILURE}: 本机开发者凭据已创建但未能安全保存;请在账户开发者凭据页面撤销后重试" + )); + } + Ok(credentials) +} + +pub(crate) fn platform_editor_uses_account_routes() -> bool { + active_external_editor_api_credentials().is_none() + && editor_api_mode() == EditorApiMode::PlatformAccount +} + +pub(crate) fn external_editor_api_credentials_override_is_active() -> bool { + active_external_editor_api_credentials().is_some() +} pub(crate) fn upload_local_asset_at( root: &Path, @@ -779,6 +1138,9 @@ pub(crate) async fn resolve_external_asset_signed_url( pub(crate) fn resolve_canvas_sync_api_base_url( api_base_url: Option, ) -> Result { + if let Some(credentials) = active_external_editor_api_credentials() { + return Ok(credentials.api_base_url); + } if editor_api_mode() == EditorApiMode::PlatformAccount { return current_platform_session() .map(|session| session.api_base_url) @@ -797,6 +1159,9 @@ pub(crate) fn resolve_canvas_sync_api_base_url( } pub(crate) fn resolve_canvas_sync_api_key(api_key: Option) -> Result { + if let Some(credentials) = active_external_editor_api_credentials() { + return Ok(credentials.api_key); + } if editor_api_mode() == EditorApiMode::PlatformAccount { return current_platform_session() .map(|session| session.access_token) @@ -817,6 +1182,9 @@ pub(crate) fn resolve_canvas_sync_api_credentials( api_base_url: Option, api_key: Option, ) -> Result<(String, String, Option), String> { + if let Some(credentials) = active_external_editor_api_credentials() { + return Ok((credentials.api_base_url, credentials.api_key, None)); + } if editor_api_mode() == EditorApiMode::PlatformAccount { let session = current_platform_session() .ok_or_else(|| "authentication-required: 请先登录陶泥儿账号".to_string())?; @@ -836,7 +1204,7 @@ pub(crate) fn resolve_canvas_sync_api_credentials( pub(crate) fn resolve_platform_editor_api_route(external_route: &str) -> String { resolve_platform_editor_api_route_for_mode( external_route, - editor_api_mode() == EditorApiMode::PlatformAccount, + platform_editor_uses_account_routes(), ) } @@ -859,28 +1227,30 @@ fn resolve_platform_editor_api_route_for_mode( pub(crate) fn resolve_platform_generation_status_route(operation_id: &str) -> String { resolve_platform_generation_status_route_for_mode( operation_id, - editor_api_mode() == EditorApiMode::PlatformAccount, + platform_editor_uses_account_routes(), ) } pub(crate) fn editor_api_authentication_error() -> String { - match editor_api_mode() { - EditorApiMode::PlatformAccount => { + match (platform_editor_uses_account_routes(), editor_api_mode()) { + (false, _) => "authentication-required: External Editor API Key 无效或权限不足".to_string(), + (true, EditorApiMode::PlatformAccount) => { "authentication-required: 陶泥儿登录已失效,请重新登录".to_string() } - EditorApiMode::ExternalDeveloper => { - "authentication-required: External Editor API Key 无效或权限不足".to_string() + (true, EditorApiMode::ExternalDeveloper) => { + unreachable!("external developer mode never uses account routes") } } } pub(crate) fn editor_api_authorization_error() -> String { - match editor_api_mode() { - EditorApiMode::PlatformAccount => { + match (platform_editor_uses_account_routes(), editor_api_mode()) { + (false, _) => "permission-denied: External Editor API Key 权限不足".to_string(), + (true, EditorApiMode::PlatformAccount) => { "permission-denied: 当前陶泥儿账号没有执行此操作的权限".to_string() } - EditorApiMode::ExternalDeveloper => { - "permission-denied: External Editor API Key 权限不足".to_string() + (true, EditorApiMode::ExternalDeveloper) => { + unreachable!("external developer mode never uses account routes") } } } @@ -1260,6 +1630,90 @@ mod tests { ); } + #[test] + fn private_external_editor_credentials_require_a_trusted_origin_and_key_shape() { + assert_eq!( + normalize_external_editor_api_base_url("https://dev.genarrative.world/") + .expect("trusted dev origin"), + "https://dev.genarrative.world" + ); + assert!(normalize_external_editor_api_base_url("https://untrusted.example.test").is_err()); + assert!( + normalize_external_editor_api_base_url("https://dev.genarrative.world/path").is_err() + ); + assert_eq!( + normalize_external_editor_api_key(" tnr_sk_fixture_123 ").expect("fixture API key"), + "tnr_sk_fixture_123" + ); + assert!(normalize_external_editor_api_key("plain-token").is_err()); + assert!(normalize_external_editor_api_key("tnr_sk_has whitespace").is_err()); + } + + #[test] + fn private_external_editor_credentials_storage_failure_markers_are_closed_and_stable() { + assert!( + private_external_editor_credentials_storage_preparation_failed( + "private-external-editor-credential-storage-preparation-failed: safe public detail" + ) + ); + assert!(private_external_editor_credentials_persistence_failed( + "private-external-editor-credential-persistence-failed: safe public detail" + )); + assert!( + !private_external_editor_credentials_storage_preparation_failed("ordinary failure") + ); + assert!(!private_external_editor_credentials_persistence_failed( + "ordinary failure" + )); + } + + #[cfg(windows)] + #[test] + fn newly_created_private_external_editor_credentials_directory_is_owned_by_token_user() { + let root = tempfile::tempdir().expect("temp dir"); + let path = root + .path() + .join("config") + .join("genarrative") + .join(PRIVATE_EXTERNAL_EDITOR_API_KEY_FILE_NAME); + + prepare_private_external_editor_api_credentials_parent_dir_at(&path) + .expect("prepare private credential directory"); + let parent = path.parent().expect("credential directory"); + secure_windows_game_creator_path_for_current_user(parent, true, false) + .expect("new credential directory must be owned by TokenUser"); + } + + #[tokio::test] + async fn direct_runtime_private_credentials_force_external_v1_routes_without_session_copy() { + let credentials = ExternalEditorApiCredentials { + api_base_url: "https://dev.genarrative.world".to_string(), + api_key: "tnr_sk_fixture_123".to_string(), + }; + with_external_editor_api_credentials(credentials, async { + assert!(external_editor_api_credentials_override_is_active()); + assert!(!platform_editor_uses_account_routes()); + assert_eq!( + resolve_canvas_sync_api_base_url(None).expect("scoped API base URL"), + "https://dev.genarrative.world" + ); + assert_eq!( + resolve_canvas_sync_api_key(None).expect("scoped API key"), + "tnr_sk_fixture_123" + ); + assert_eq!( + resolve_platform_editor_api_route("/api/external/v1/editor/projects"), + "/api/external/v1/editor/projects" + ); + assert_eq!( + resolve_platform_generation_status_route("operation-1"), + "/api/external/v1/generations/operation-1" + ); + }) + .await; + assert!(!external_editor_api_credentials_override_is_active()); + } + fn read_asset_test_request(stream: &mut std::net::TcpStream) { stream .set_read_timeout(Some(Duration::from_secs(2))) diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser.rs b/apps/ai-game-creator-shell/src-tauri/src/browser.rs index aded90b99..7b605276b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser.rs @@ -16,7 +16,9 @@ pub use model::{ BrowserValidationInput, BrowserValidationResult, BrowserValidationViewport, BrowserViewportValidationResult, DiscoveredBrowser, DiscoveredBrowserKind, }; -pub use process::validate_local_preview_in_browser; +pub use process::{ + validate_local_preview_in_browser, validate_local_preview_in_browser_with_interaction, +}; pub(crate) use playtest::browser_playtest_scenario_fingerprint; diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs index c9120792b..ee5048997 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/capture.rs @@ -156,6 +156,7 @@ pub(super) struct PageSnapshot { pub(super) dom_character_count: usize, pub(super) expected_text_matches: Vec, pub(super) canvases: Vec, + pub(super) canvas_or_webgl_image_paths: Vec, pub(super) blocked_popup_count: u32, pub(super) blocked_download_count: u32, pub(super) blocked_permission_count: u32, @@ -612,6 +613,7 @@ pub(super) fn build_snapshot_script(expected_text: &[String]) -> Result text.includes(value)), canvases, + canvasOrWebglImagePaths: Array.from(new Set(security.canvasOrWebglImagePaths || [])).slice(0, 32), blockedPopupCount: security.blockedPopupCount || 0, blockedDownloadCount: security.blockedDownloadCount || 0, blockedPermissionCount: security.blockedPermissionCount || 0, @@ -702,6 +704,47 @@ pub(super) const RESTRICTION_SCRIPT: &str = r#" })(); "#; +pub(super) const RENDERED_IMAGE_CAPTURE_SCRIPT: &str = r#" +(() => { + const state = window.__GENARRATIVE_PREVIEW_VALIDATION__; + if (!state) return; + state.canvasOrWebglImagePaths = []; + const recordCanvasOrWebglImage = (candidate) => { + const source = candidate && (candidate.currentSrc || candidate.src); + if (typeof source !== 'string' || source.length === 0) return; + try { + const url = new URL(source, location.href); + if (url.origin !== location.origin || !url.pathname) return; + if (!state.canvasOrWebglImagePaths.includes(url.pathname) && state.canvasOrWebglImagePaths.length < 32) { + state.canvasOrWebglImagePaths.push(url.pathname); + } + } catch (_) {} + }; + const wrapImageRenderMethod = (prototype, method) => { + const original = prototype && prototype[method]; + if (typeof original !== 'function') return; + try { + Object.defineProperty(prototype, method, { + value: function(...args) { + for (const candidate of args) recordCanvasOrWebglImage(candidate); + return original.apply(this, args); + }, + configurable: true, + writable: true + }); + } catch (_) {} + }; + wrapImageRenderMethod(globalThis.CanvasRenderingContext2D?.prototype, 'drawImage'); + for (const prototype of [ + globalThis.WebGLRenderingContext?.prototype, + globalThis.WebGL2RenderingContext?.prototype + ]) { + wrapImageRenderMethod(prototype, 'texImage2D'); + wrapImageRenderMethod(prototype, 'texSubImage2D'); + } +})(); +"#; + fn remote_object_text(object: &RemoteObject) -> String { if let Some(value) = &object.value { if let Some(value) = value.as_str() { diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs index e56a8a0ad..71cc23fa0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/cdp.rs @@ -14,7 +14,8 @@ use url::Url; use super::capture::{ build_snapshot_script, canvas_validation_diagnostic, sanitize_url, start_capture_tasks, - truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RESTRICTION_SCRIPT, + truncate_chars, BrowserCanvasSnapshot, PageSnapshot, RENDERED_IMAGE_CAPTURE_SCRIPT, + RESTRICTION_SCRIPT, }; use super::evidence::write_atomic; use super::model::{ @@ -36,6 +37,7 @@ pub(super) async fn run_browser_validation( discovered: &DiscoveredBrowser, preview_url: &Url, input: &BrowserValidationInput, + advisory_interaction: bool, ) -> Result { browser .execute(SetDownloadBehaviorParams::new( @@ -51,7 +53,8 @@ pub(super) async fn run_browser_validation( let mut viewport_results = Vec::with_capacity(REQUIRED_VIEWPORTS.len()); let mut playtest = None; for viewport in REQUIRED_VIEWPORTS { - let outcome = validate_viewport(browser, preview_url, input, viewport).await?; + let outcome = + validate_viewport(browser, preview_url, input, viewport, advisory_interaction).await?; if outcome.playtest.is_some() { playtest = outcome.playtest; } @@ -116,6 +119,7 @@ async fn validate_viewport( preview_url: &Url, input: &BrowserValidationInput, viewport: BrowserValidationViewport, + advisory_interaction: bool, ) -> Result { let (width, height, mobile) = viewport.dimensions(); let page = browser @@ -139,6 +143,14 @@ async fn validate_viewport( page.evaluate_on_new_document(RESTRICTION_SCRIPT) .await .map_err(|error| format!("安装浏览器限制脚本失败:{error}"))?; + page.evaluate_on_new_document(RENDERED_IMAGE_CAPTURE_SCRIPT) + .await + .map_err(|error| { + format!( + "安装 {} 平台素材渲染观察失败:{error}", + viewport.file_stem() + ) + })?; let tasks = start_capture_tasks(&page, preview_url).await?; let navigation = tokio::time::timeout(BROWSER_TIMEOUT, page.goto(preview_url.as_str())) @@ -151,6 +163,12 @@ async fn validate_viewport( } tokio::time::sleep(Duration::from_millis(input.settle_ms)).await; + let advisory_interaction_diagnostic = if advisory_interaction { + Some(run_advisory_interaction_probe(&page).await) + } else { + None + }; + let playtest = if viewport == BrowserValidationViewport::Desktop { match input.playtest_scenario { Some(scenario) => Some(run_desktop_playtest(&page, scenario).await), @@ -245,7 +263,15 @@ async fn validate_viewport( if let Some(diagnostic) = canvas_validation_diagnostic(&canvases) { diagnostics.push(diagnostic.to_string()); } - + if !snapshot.canvas_or_webgl_image_paths.is_empty() { + diagnostics.push(format!( + "[advisory] rendered-local-images:{}", + snapshot.canvas_or_webgl_image_paths.join("|") + )); + } + if let Some(diagnostic) = advisory_interaction_diagnostic { + diagnostics.push(format!("[advisory] {diagnostic}")); + } Ok(BrowserViewportValidationOutcome { result: BrowserViewportValidationResult { viewport, @@ -269,9 +295,61 @@ async fn validate_viewport( blocked_permission_count: snapshot.blocked_permission_count, blocked_service_worker_count: snapshot.blocked_service_worker_count, screenshot_path, - passed: diagnostics.is_empty(), + passed: diagnostics.is_empty() + || diagnostics + .iter() + .all(|diagnostic| diagnostic.starts_with("[advisory]")), diagnostics, }, playtest, }) } + +async fn run_advisory_interaction_probe(page: &chromiumoxide::Page) -> String { + const PROBE: &str = r#"function() { + const candidates = Array.from(document.querySelectorAll( + 'button:not([disabled]), a[href], input[type="button"]:not([disabled]), input[type="submit"]:not([disabled])' + )); + const item = candidates.find((element) => { + const style = getComputedStyle(element); + const rect = element.getBoundingClientRect(); + return style.display !== 'none' && style.visibility !== 'hidden' && + rect.width > 0 && rect.height > 0 && + element.getAttribute('aria-disabled') !== 'true'; + }); + if (!item) return JSON.stringify({ ok: false, reason: 'no-visible-enabled-control' }); + item.scrollIntoView({ block: 'center', inline: 'center' }); + return JSON.stringify({ ok: true, tag: item.tagName.toLowerCase() }); + }"#; + let mut elements = match page.find_elements( + "button:not([disabled]), a[href], input[type=button]:not([disabled]), input[type=submit]:not([disabled])", + ) + .await + { + Ok(elements) => elements, + Err(_) => return "真实交互探针无法查询可见控件".to_string(), + }; + for element in elements.drain(..).take(8) { + let evaluated = match element.call_js_fn(PROBE, false).await { + Ok(value) => value, + Err(_) => continue, + }; + let Some(value) = evaluated.result.value else { + continue; + }; + let Some(text) = value.as_str() else { + continue; + }; + let Ok(probe) = serde_json::from_str::(text) else { + continue; + }; + if probe.get("ok").and_then(serde_json::Value::as_bool) != Some(true) { + continue; + } + return match element.click().await { + Ok(_) => "真实交互探针已点击一个可见启用控件".to_string(), + Err(_) => "真实交互探针发现控件但点击未完成".to_string(), + }; + } + "真实交互探针未发现可见启用控件".to_string() +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs index 0223b16c9..7366df54d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/process.rs @@ -34,6 +34,17 @@ pub(super) fn create_browser_process_temp_dir() -> Result { pub async fn validate_local_preview_in_browser( input: BrowserValidationInput, +) -> Result { + validate_local_preview_in_browser_with_interaction(input, false).await +} + +/// Runs the same restricted Chromium validation, optionally adding one bounded, +/// advisory click on a visible enabled control. The interaction is evidence for +/// the direct Codex reviewer; it is not a fixed gameplay contract and never +/// changes the deterministic browser safety checks. +pub async fn validate_local_preview_in_browser_with_interaction( + input: BrowserValidationInput, + advisory_interaction: bool, ) -> Result { let preview_url = validate_input(&input)?; prepare_evidence_root(&input.evidence_root)?; @@ -77,8 +88,14 @@ pub async fn validate_local_preview_in_browser( } }); - let validation = - run_browser_validation(&browser, &browser_executable, &preview_url, &input).await; + let validation = run_browser_validation( + &browser, + &browser_executable, + &preview_url, + &input, + advisory_interaction, + ) + .await; let close_result = browser .close() diff --git a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs index 086f410d2..ef4709782 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/browser/tests.rs @@ -12,7 +12,7 @@ use url::Url; use super::capture::{ canvas_validation_diagnostic, classify_canvas_pixel_probe, preview_fetch_enable_params, - BrowserCanvasSnapshot, + BrowserCanvasSnapshot, RENDERED_IMAGE_CAPTURE_SCRIPT, }; use super::discovery::system_browser_candidates; use super::evidence::browser_validation_result_for_report; @@ -70,6 +70,16 @@ fn canvas_snapshot( } } +#[test] +fn rendered_image_capture_script_observes_canvas_and_webgl_without_cross_origin_urls() { + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("CanvasRenderingContext2D")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("drawImage")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("texImage2D")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("texSubImage2D")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("url.origin !== location.origin")); + assert!(RENDERED_IMAGE_CAPTURE_SCRIPT.contains("url.pathname")); +} + fn generic_state(sequence: u64, phase: BrowserPlaytestPhase, level: u64) -> PlayableWebGameState { PlayableWebGameState { sequence, @@ -2362,20 +2372,41 @@ async fn real_chrome_validation_smoke() { let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).expect("bind preview"); let port = listener.local_addr().expect("preview address").port(); listener.set_nonblocking(true).expect("nonblocking preview"); + let mut png = Vec::new(); + image::DynamicImage::ImageRgba8(image::RgbaImage::from_pixel( + 2, + 2, + image::Rgba([255, 96, 32, 255]), + )) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .expect("encode browser fixture PNG"); let (stop_tx, stop_rx) = mpsc::channel(); let server = thread::spawn(move || { - let html = br#"Browser Probe
Expected local preview
"#; + let html = br#"Browser Probe
Expected local preview
side only"#; while stop_rx.try_recv().is_err() { match listener.accept() { Ok((mut stream, _)) => { let mut request = [0_u8; 2048]; - let _ = stream.read(&mut request); + let count = stream.read(&mut request).unwrap_or_default(); + let request = String::from_utf8_lossy(&request[..count]); + let is_png = request.starts_with("GET /drawn.png ") + || request.starts_with("GET /side-only.png "); + let body = if is_png { + png.as_slice() + } else { + html.as_slice() + }; + let content_type = if is_png { + "image/png" + } else { + "text/html; charset=utf-8" + }; let headers = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", - html.len() + "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() ); let _ = stream.write_all(headers.as_bytes()); - let _ = stream.write_all(html); + let _ = stream.write_all(body); } Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { thread::sleep(Duration::from_millis(10)); @@ -2389,7 +2420,7 @@ async fn real_chrome_validation_smoke() { url: format!("http://127.0.0.1:{port}/"), viewports: REQUIRED_VIEWPORTS.to_vec(), expected_text: vec!["Expected local preview".to_string()], - settle_ms: 100, + settle_ms: 300, fail_on_console_error: true, playtest_scenario: None, evidence_root: evidence.path().join("evidence"), @@ -2425,5 +2456,12 @@ async fn real_chrome_validation_smoke() { ); assert_eq!(viewport_result.canvases[1].non_empty, Some(false)); assert_eq!(viewport_result.canvases[2].non_empty, Some(true)); + let rendered_diagnostic = viewport_result + .diagnostics + .iter() + .find(|diagnostic| diagnostic.starts_with("[advisory] rendered-local-images:")) + .expect("rendered image observation"); + assert!(rendered_diagnostic.contains("/drawn.png")); + assert!(!rendered_diagnostic.contains("/side-only.png")); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/config.rs b/apps/ai-game-creator-shell/src-tauri/src/config.rs index 5a6922531..25343cd2f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/config.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/config.rs @@ -213,7 +213,7 @@ pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfi model: None, api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(), - stream: false, + stream: true, web_search_enabled: false, context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS, auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT, @@ -835,6 +835,16 @@ pub(crate) fn initialize_windows_game_creator_file_owner_for_current_user( secure_windows_game_creator_path_for_current_user_with_owner_policy(path, false, true, true) } +/// Initialize ownership only for a directory that was created by the current +/// operation. Existing directories must use the strict verifier instead, so a +/// foreign-owned path is never silently adopted. +#[cfg(windows)] +pub(crate) fn initialize_windows_game_creator_directory_owner_for_current_user( + path: &Path, +) -> Result<(), String> { + secure_windows_game_creator_path_for_current_user_with_owner_policy(path, true, true, true) +} + #[cfg(windows)] pub(crate) fn windows_private_dacl_security_information( initialize_owner: bool, 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 5783fa333..651654b6f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1319,7 +1319,7 @@ impl Default for GameCreatorLlmConfig { model: DEFAULT_GAME_CREATOR_LLM_MODEL.to_string(), api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), reasoning_effort: DEFAULT_GAME_CREATOR_LLM_REASONING_EFFORT.to_string(), - stream: false, + stream: true, web_search_enabled: false, context_window_tokens: DEFAULT_GAME_CREATOR_LLM_CONTEXT_WINDOW_TOKENS, auto_compact_token_limit: DEFAULT_GAME_CREATOR_LLM_AUTO_COMPACT_TOKEN_LIMIT, diff --git a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs index 7b9ec1341..c76a3deb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/platform_session.rs @@ -182,6 +182,62 @@ pub(crate) fn platform_session_service_identity() -> Option { .map(|snapshot| format!("{}\nuser:{}", snapshot.api_base_url, snapshot.user_id)) } +#[cfg(test)] +static PLATFORM_SESSION_TEST_LOCK: OnceLock> = OnceLock::new(); + +#[cfg(test)] +pub(crate) struct TestPlatformSessionGuard { + _isolation: std::sync::MutexGuard<'static, ()>, + previous: Option, +} + +#[cfg(test)] +impl Drop for TestPlatformSessionGuard { + fn drop(&mut self) { + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + *current = self + .previous + .take() + .expect("platform session test guard must restore its prior state"); + } +} + +/// Installs a scoped platform-account session for tests that must exercise the +/// production-default client authentication path. The previous process state +/// is restored on drop so the fixture cannot leak credentials or account +/// identity to another test. +#[cfg(test)] +pub(crate) fn install_test_platform_session( + user_id: &str, + access_token: &str, + api_base_url: &str, +) -> TestPlatformSessionGuard { + let isolation = PLATFORM_SESSION_TEST_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let mut current = platform_session() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let previous = std::mem::take(&mut *current); + *current = PlatformSessionState { + generation: 1, + snapshot: Some(PlatformSessionSnapshot { + user_id: user_id.to_string(), + access_token: access_token.to_string(), + api_base_url: api_base_url.to_string(), + generation: 1, + }), + }; + drop(current); + TestPlatformSessionGuard { + _isolation: isolation, + previous: Some(previous), + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs index 8a5e21147..ec5882bd2 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/process_session/lifecycle.rs @@ -145,7 +145,9 @@ pub(super) fn process_session_command_builder( #[cfg(windows)] if is_npm_launch { let node_executable = launch.executable.to_string_lossy(); - let node_executable = node_executable.strip_prefix(r"\\?\").unwrap_or(&node_executable); + let node_executable = node_executable + .strip_prefix(r"\\?\") + .unwrap_or(&node_executable); command.env("npm_node_execpath", node_executable); command.env("NODE", node_executable); command.env("npm_config_node_gyp", ""); @@ -1187,9 +1189,7 @@ fn drain_process_session_output( let before = pending.len(); ansi.push(*byte, &mut pending); let visible_bytes = pending.len().saturating_sub(before); - if visible_bytes > 0 - && !matches!(pending.last(), Some(b'\n' | b'\r')) - { + if visible_bytes > 0 && !matches!(pending.last(), Some(b'\n' | b'\r')) { pending_logical_line_bytes = pending_logical_line_bytes.saturating_add(visible_bytes); if pending_logical_line_bytes > PROCESS_SESSION_MAX_PENDING_LINE_BYTES { @@ -1225,8 +1225,8 @@ fn drain_process_session_output( const PROCESS_SESSION_PTY_COLS: usize = 120; match pending.last() { Some(b'\r') => { - conpty_soft_wrap = pending_logical_line_bytes - >= PROCESS_SESSION_PTY_COLS; + conpty_soft_wrap = + pending_logical_line_bytes >= PROCESS_SESSION_PTY_COLS; if !conpty_soft_wrap { pending_logical_line_bytes = 0; } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs index 778ae1180..0ef277145 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/configuration.rs @@ -107,14 +107,50 @@ fn agent_mode_defaults_to_codex_app_server_and_preserves_explicit_modes() { default_config.agent_mode, GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER ); + assert!( + default_config.llm.stream, + "新的全局 LLM 配置默认使用流式响应" + ); + + let inherited_agent_llm = + resolve_game_creator_llm_config_for_agent(&default_config, "design-director"); + assert!( + inherited_agent_llm.stream, + "未覆盖时 Agent 必须继承全局流式默认" + ); + + let mut explicitly_non_streaming_config = default_config.clone(); + explicitly_non_streaming_config.agent_llm.insert( + "design-director".to_string(), + GameCreatorLlmConfigFile { + stream: Some(false), + ..GameCreatorLlmConfigFile::default() + }, + ); + assert!( + !resolve_game_creator_llm_config_for_agent( + &explicitly_non_streaming_config, + "design-director", + ) + .stream, + "Agent 显式关闭流式请求时必须覆盖全局默认" + ); let root = unique_project_path(); fs::create_dir_all(&root).expect("runtime config dir"); let config_path = root.join(GAME_CREATOR_CONFIG_FILE_NAME); - fs::write(&config_path, r#"{"agentMode":"provider"}"#).expect("write explicit provider mode"); + fs::write( + &config_path, + r#"{"agentMode":"provider","llm":{"stream":false}}"#, + ) + .expect("write explicit provider mode and non-stream override"); let mut config = GameCreatorAppConfig::default(); merge_game_creator_config_file(&mut config, &config_path).expect("merge provider mode"); assert_eq!(config.agent_mode, GAME_CREATOR_AGENT_MODE_PROVIDER); + assert!( + !config.llm.stream, + "已有配置显式关闭流式请求时必须保留该选择" + ); assert_eq!( normalize_game_creator_agent_mode(GAME_CREATOR_AGENT_MODE_CODEX_APP_SERVER) .expect("app-server mode"), 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 cd3c836bc..9fbbb9059 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 @@ -1729,6 +1729,58 @@ export function isMudPointInsufficientRuntimeError(message: string) { ); } +function directCodexDiagnosticFailureDetail(message: string) { + const trimmed = message.trim(); + const match = + /^direct-codex-failure:v1 stage=(request|art-preparation|code-generation|browser-validation|version-registration) retryable=(true|false) summary=(.+?);建议:(.+?);(?:已保存脱敏项目诊断|未能保存项目诊断)$/u.exec( + trimmed, + ); + if (!match) { + return null; + } + const [, stage, retryable, rawSummary, rawHint] = match; + if (!stage || !retryable || !rawSummary || !rawHint) { + return null; + } + const redactMarkerForDisplay = (value: string) => + value + .replace(//gi, '[已隐藏链接]') + .replace(/\$PROJECT_ROOT|/g, '[已隐藏路径]') + .replace(/\[redacted-secret\]/gi, '[已隐藏凭据]'); + const summary = redactMarkerForDisplay(rawSummary) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 320); + const hint = redactMarkerForDisplay(rawHint) + .replace(/\s+/g, ' ') + .trim() + .slice(0, 180); + const combined = `${summary} ${hint}`; + if ( + /(?:authorization|bearer|api[_ -]?key|token|secret|cookie|set-cookie|https?:\/\/|(?:^|[\s::])\/?(?:users|home|var|tmp|private)\/|[A-Z]:\\Users\\)/i.test( + combined, + ) + ) { + return null; + } + const stageLabel = { + request: '智能创作请求失败', + 'art-preparation': '平台资源准备失败', + 'code-generation': '代码生成失败', + 'browser-validation': '真实试玩未通过', + 'version-registration': '项目版本登记失败', + }[stage]; + if (!stageLabel) { + return null; + } + if (!summary || !hint) { + return null; + } + return `${stageLabel}:${summary}。${hint}${ + retryable === 'true' ? '(可直接重试)' : '' + }`; +} + function directPlatformFailureDetail(message: string) { const trimmed = message.trim(); if (!trimmed) { @@ -1829,6 +1881,10 @@ export function projectRuntimeVisibleError( if (isMudPointInsufficientRuntimeError(message)) { return MUD_POINT_INSUFFICIENT_INTERRUPTION_MESSAGE; } + const directDiagnosticDetail = directCodexDiagnosticFailureDetail(message); + if (directDiagnosticDetail) { + return `${subject}:${directDiagnosticDetail}`; + } const directFailureDetail = directPlatformFailureDetail(message); if (directFailureDetail) { return `${subject}:${directFailureDetail}`; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx index 5ba4ce45b..d0b261e8a 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx @@ -39,6 +39,11 @@ export function WorkspaceLauncherShell({ const { recentProjectRows, rememberRecentWorkspace } = recentProjects; const [launcherView, setLauncherView] = useState(initialView); const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); + const [playRequest, setPlayRequest] = useState<{ + projectPath: string; + requestId: number; + } | null>(null); + const playRequestIdRef = useRef(0); const [launcherNotice, setLauncherNotice] = useState<{ title: string; message: string; @@ -188,6 +193,20 @@ export function WorkspaceLauncherShell({ [applyManifestSnapshot, setCurrentProjectContext], ); + const requestCurrentProjectPlay = useCallback((nextProjectPath: string) => { + playRequestIdRef.current += 1; + setPlayRequest({ + projectPath: nextProjectPath, + requestId: playRequestIdRef.current, + }); + }, []); + + const handlePlayRequestHandled = useCallback((requestId: number) => { + setPlayRequest((current) => + current?.requestId === requestId ? null : current, + ); + }, []); + function showLauncherNotice(title: string) { setLauncherNotice({ title, @@ -289,6 +308,9 @@ export function WorkspaceLauncherShell({ preview={activeProjectPreview} agentRuntimeSummaries={activeProjectAgentRuntimeSummaries} agentResults={activeProjectAgentResults} + onPlay={() => + requestCurrentProjectPlay(currentProjectContext.projectPath) + } onManifestChange={syncActiveProjectManifest} onHomeOpen={() => setLauncherView('home')} onProjectsOpen={() => setLauncherView('projects')} @@ -301,6 +323,8 @@ export function WorkspaceLauncherShell({ initialSupervisorMessage={currentProjectContext.initialPrompt} orchestrationMode="single-supervisor" projectSupervisorOnly + playRequest={playRequest} + onPlayRequestHandled={handlePlayRequestHandled} onManifestChange={syncActiveProjectManifest} onPreviewChange={setActiveProjectPreview} onAgentRuntimeSummariesChange={ diff --git a/apps/ai-game-creator-shell/src/features/app-shell/model.ts b/apps/ai-game-creator-shell/src/features/app-shell/model.ts index 8bf9982b1..08a635840 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/model.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/model.ts @@ -38,6 +38,11 @@ export type ProjectSupervisorComponentProps = { initialSupervisorMessage?: string; orchestrationMode?: 'single-supervisor' | 'professional-dag'; projectSupervisorOnly?: boolean; + playRequest?: { + projectPath: string; + requestId: number; + } | null; + onPlayRequestHandled?: (requestId: number) => void; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 0e3fdcaa8..b58c35b7c 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -10,6 +10,7 @@ import type { import type { AgentStatusCard, ChatMessage, + PendingCommand, PendingUiConfirmation, } from '../../app/types'; import { @@ -21,6 +22,11 @@ import { } from '../agent-runtime'; import { formatAgentCardRuntimeStatus } from '../project-summary/agentPresentation'; import { taskStatusLabels } from '../project-summary/projectSummary'; +import { + pendingCommandDetail, + pendingCommandTitle, +} from './pendingCommandPresentation'; +import { resolvePendingCommandProjectPath } from './projectCommandPolicy'; type RuntimePanelProps = ComponentProps; @@ -30,12 +36,16 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { hiddenConversationCount: number; needsUserInput: boolean; onCancelConfirmation: () => void; + onCancelPendingCommand: () => void; onChatInputChange: Dispatch>; onConfirmConfirmation: () => void; + onConfirmPendingCommand: () => void; onScroll: UIEventHandler; onShowEarlierMessages: () => void; onSubmit: FormEventHandler; pendingConfirmation: PendingUiConfirmation | null; + pendingCommand: PendingCommand | null; + projectPath: string; showProfessionalCollaboration?: boolean; transientReply: string; visibleMessages: ChatMessage[]; @@ -49,12 +59,16 @@ export function ProjectSupervisorView({ hiddenConversationCount, needsUserInput, onCancelConfirmation, + onCancelPendingCommand, onChatInputChange, onConfirmConfirmation, + onConfirmPendingCommand, onScroll, onShowEarlierMessages, onSubmit, pendingConfirmation, + pendingCommand, + projectPath, showProfessionalCollaboration = true, transientReply, visibleMessages, @@ -104,6 +118,29 @@ export function ProjectSupervisorView({ {directCodex ? null : ( )} + {pendingCommand ? ( +
+ + {pendingCommandTitle(pendingCommand)} + + {pendingCommandDetail( + pendingCommand, + resolvePendingCommandProjectPath( + pendingCommand, + { projectPath }, + projectPath, + ), + )} + + + + +
+ ) : null} {pendingConfirmation ? (
diff --git a/apps/ai-game-creator-shell/src/view/project-development/index.tsx b/apps/ai-game-creator-shell/src/view/project-development/index.tsx index ecfd267b9..ef0c5e017 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/index.tsx +++ b/apps/ai-game-creator-shell/src/view/project-development/index.tsx @@ -273,6 +273,7 @@ export type ProjectDevelopmentViewProps = { supervisor: ReactNode; onHomeOpen: () => void; onProjectsOpen: () => void; + onPlay?: () => void; onManifestChange?: ( projectPath: string, manifest: GameCreationAppManifest, @@ -710,6 +711,7 @@ export default function ProjectDevelopmentView({ agentResults = emptyProjectAgentResults, supervisor, onManifestChange, + onPlay, }: ProjectDevelopmentViewProps) { const professionalDagVisible = orchestrationMode === 'professional-dag'; const [mode, setMode] = useState('resources'); @@ -2995,6 +2997,21 @@ export default function ProjectDevelopmentView({
+ {mode === 'resources' && !focusedResource && !assetCanvasRoute && @@ -3736,7 +3753,7 @@ export default function ProjectDevelopmentView({
)}
diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts index 503ac374c..29ae4274e 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts +++ b/apps/ai-game-creator-shell/tests/agentRuntimeModel.test.ts @@ -956,6 +956,45 @@ describe('Agent Runtime Provider 状态投影', () => { ); }); + test('直连智能创作展示脱敏的阶段诊断与重试建议', () => { + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=true summary=读取陶泥儿画布资源失败:HTTP 503;建议:平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:HTTP 503。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=false summary=陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复;建议:历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:陶泥儿画布存在多个同源核心图集,身份不唯一,已拒绝恢复。历史画布资源不满足安全恢复条件,请先在资源画布确认唯一可用的核心图集', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=false summary=本机开发者凭据存储目录未安全初始化;未创建远端凭据;建议:请检查当前 Windows 用户对本机私有凭据目录的权限后重试;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:本机开发者凭据存储目录未安全初始化;未创建远端凭据。请检查当前 Windows 用户对本机私有凭据目录的权限后重试', + ); + expect( + projectRuntimeVisibleError( + 'direct-codex-failure:v1 stage=art-preparation retryable=true summary=读取陶泥儿画布资源失败: [redacted-secret];建议:平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断;已保存脱敏项目诊断', + '陶泥儿智能创作', + true, + ), + ).toBe( + '陶泥儿智能创作:平台资源准备失败:读取陶泥儿画布资源失败:[已隐藏链接] [已隐藏路径] [已隐藏凭据]。平台资源暂时无法完成准备,请稍后重试;如持续失败请检查项目诊断(可直接重试)', + ); + }); + test('直连 Codex 失败保留安全原因并隐藏链接与路径', () => { expect( projectRuntimeVisibleError( diff --git a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md index 1f628a6a8..d205aa1cc 100644 --- a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md +++ b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md @@ -105,9 +105,9 @@ ### 现行契约 -1. 直连 AGC 项目首次生成、或当前项目还没有完整已登记的陶泥儿美术包时,客户端必须按标准三阶段调用陶泥儿平台:先生成统一规范图 `assets/art-spec.png`,再以该规范图为唯一参考生成 16:9 场景背景图 `assets/direct-game-background.png`,最后以同一规范图生成透明主图集 `assets/art-spritesheet.png`。标准图集必须请求并回执 `sliceLayout=grid-2x2`,由平台按左上、右上、左下、右下四个固定槽位原子产出四份受合同保护的切片:`assets/art-spritesheet-slices/player.png`、`blocks-and-targets.png`、`obstacles-and-scene.png`、`feedback-effects.png`,以及对应切片清单与私有合同回执;不得继续把自由图集的连通域结果、客户端猜测等分裁剪或刚好四个部件冒充这四类素材。三份主图片都必须以 `source.kind=canvas` 登记到 manifest,且规范图、背景、图集必须属于同一 Canvas 项目;任一阶段失败或切片合同不完整都不得启动代码生成回合。 -2. 客户端把规范图、背景图、主图集和四份切片的相对路径、来源和用途作为不可变执行上下文提供给执行引擎。规范图只作为风格根;执行引擎必须将背景图实际用于游戏场景,并在 `game/game.js` 中加载四份切片,分别通过 Canvas `drawImage` 绘制玩家/可操作物、核心目标或道具、障碍或场景元素、反馈或界面装饰等核心画面元素;不能只在回复中声明使用,也不能用 emoji、纯 CSS 或 Canvas 色块替代这些核心视觉。 -3. 回合结束时客户端复核:三份平台主图片、四份切片、切片清单和合同回执均存在;manifest 来源、路由、规范图引用和同 Canvas 项目关系均成立;`game/index.html` 存在;游戏源码引用背景图,并在可执行 Canvas 渲染路径中加载四份切片且至少执行四次 `drawImage`。任一条件不满足就不登记 `code-prototype=completed`、不创建完成版本,并把明确原因返回给用户。 +1. 直连 AGC 项目首次生成、或当前项目还没有已登记陶泥儿美术时,客户端按标准三阶段优先调用陶泥儿平台:先生成统一规范图 `assets/art-spec.png`,再以该规范图为参考生成 16:9 场景背景图 `assets/direct-game-background.png`,最后以同一规范图生成透明主图集 `assets/art-spritesheet.png`。`sliceLayout=grid-2x2` 与四份受合同保护切片继续作为标准美术包的推荐路径;不得把客户端猜测裁剪或自由连通域结果冒充平台切片。平台返回的主图片必须以 `source.kind=canvas` 登记,资源身份、图片可解码性和同 Canvas 项目关系继续失败关闭;图集源图可信可用但透明化或切片后处理缺失时,按第 13 节安全复用源图并继续同一 Codex thread,不因固定切片缺失终止整个游戏。 +2. 客户端把实际可用的规范图、背景图、主图集与切片相对路径、来源和用途作为不可变执行上下文提供给唯一 Codex。规范图作为风格根,其余平台素材应进入核心可玩画面;Codex 可以根据游戏结构选择合理的图集、切片、Canvas 或 WebGL 组织方式,不要求固定文件组合、固定 `drawImage` 次数或单一代码形态。平台素材不能只在回复中声明使用或只显示为旁侧缩略图,核心视觉也不能退化为 emoji、纯 CSS 或纯色占位图。 +3. 回合结束时客户端只确定性复核安全与最低来源边界:可信平台图片、身份与同 Canvas 关系成立,`game/index.html` 存在,游戏源码至少引用一份已登记陶泥儿图片。随后由受限 Chromium 对 desktop/mobile 做真实运行、截图、异常、交互及 Canvas/WebGL 图片渲染观察,并把缺口回灌同一 Codex thread 有界整改。固定四切片、固定文件名和固定 `drawImage` 次数不再阻断完成;最终仍未观察到平台素材进入任一视口核心渲染时才拒绝登记版本。详细契约以第 13 节为准。 4. 资源画布增加“游戏代码”分区。HTML、CSS、JavaScript 和其它可执行源码进入该分区;玩法说明、配置和 Agent 文本回执仍归“文档”。已有资源布局若没有代码分区位置,将由现有布局 reconcile 自动补位。 5. 产品界面只展示“陶泥儿”“智能创作”等产品术语;Codex app-server 仅保留为内部执行实现和开发诊断名称,不在普通项目聊天、首页创建状态或设置说明中直接暴露。 6. 每次直连回合在游戏代码复核与登记成功后,必须在项目写锁内推进一次 durable project revision,并以该 revision 创建首个 `initial-*` 或后续 `agent-*` 正式版本。禁止修改 manifest 后沿用旧 revision;外层工作台会把同 revision 的不同 manifest 判为冲突并拒绝投影,表现为磁盘已有游戏代码和版本、客户端仍显示 0 项。 @@ -115,11 +115,11 @@ ### 验收 -- Rust 单测覆盖:缺规范图、背景、标准图集或任一四类切片时必须请求平台生成或拒绝完成;代码回收必须拒绝未分别引用背景图和四类切片的项目;只有同一平台规范根派生的完整图片合同、文件存在和渲染引用同时满足时才能完成 `code-prototype` 与初始版本登记。 +- Rust 单测覆盖:可信完整图集缺少固定切片仍可进入 Codex;只有规范图和背景图但历史图集不可安全恢复时不得重复发起付费生成;缺可信平台图片、来源身份不成立、PNG 不可解码或源码完全没有平台素材引用时继续拒绝完成。浏览器证据测试必须区分旁侧 `` 与 Canvas/WebGL 实际渲染,并证明 desktop/mobile 任一视口缺少核心素材观察都会回灌同 thread 整改。 - 资源投影与 AppSurface 测试覆盖:`game/index.html`、`game/style.css`、`game/game.js` 显示于“游戏代码”,不再显示于“文档”。 - 连续两次直连回合必须保留同一批游戏代码资产 ID,project revision 单调推进,并形成 `initial-* -> agent-*` 的父子版本链;外层资源管理在新 revision 到达后显示游戏代码和项目版本,不能停留在生成前快照。 - 已有项目修改测试必须证明系统提示词包含有界当前游戏源码、敏感行被过滤,基于该上下文的 file-change 可以命中;Codex 明确未修改文件时,revision 与版本数量保持不变。 -- 真实客户端验收必须在新建空项目中看到规范图、背景图和标准核心图集三张 `source.kind=canvas` PNG 位于“美术资源”,四类切片及其合同真实落盘,代码文件位于“游戏代码”,并在运行画面中实际加载背景图和四类切片。 +- 真实客户端验收必须在新建空项目中看到实际成功生成或安全恢复的 `source.kind=canvas` PNG 位于“美术资源”,代码文件位于“游戏代码”,并在 desktop/mobile 运行画面的核心 Canvas/WebGL 路径中观察到至少一份已登记陶泥儿素材;标准四切片存在时应优先使用,但不存在时不能伪造,也不能据此单独判定游戏失败。 ## 9. 直连创作阶段反馈与资源首屏预览(2026-08-17) @@ -198,24 +198,50 @@ 直连 Runtime 只把安全、权限、来源和不可逆副作用留在确定性门禁内:项目工作区与权限边界、凭据隔离、平台生成请求的幂等账本与未知结果恢复、持久文件事务、HTTP 完成包基本结构、图片下载/PNG 解码/可见像素、`source.kind=canvas`、资源身份与同 Canvas 项目关系。上述条件不满足时必须失败关闭,不能交给模型猜测或覆盖。 -`grid-2x2`、固定四张切片、固定切片文件名、固定 `drawImage` 次数以及某一种 Canvas 代码形态不再是所有游戏的完成阻断合同。它们保留为“陶泥儿标准美术包”的推荐路径:平台返回完整透明主图集但切片后处理缺失时,仍可继续代码生成;已有切片可以被 Codex 优先使用,但客户端不得伪造切片或把缺失切片标记为已存在。只有平台主图集本身无法下载、解码、无可见像素、来源身份不可信或没有任何真实平台素材引用时才阻断。 +`grid-2x2`、固定四张切片、固定切片文件名、固定 `drawImage` 次数以及某一种 Canvas 代码形态不再是所有游戏的完成阻断合同。它们保留为“陶泥儿标准美术包”的推荐路径:平台返回完整透明主图集但切片后处理缺失时,仍可继续代码生成;已有切片可以被 Codex 优先使用,但客户端不得伪造切片或把缺失切片标记为已存在。若项目已有同一画布、身份可信且可解码的规范图和背景图,但历史核心图集无法恢复,客户端必须直接复用这两张素材继续 Codex 代码生成与试玩,不重新发起图集付费生成,也不为只读恢复失败阻断本轮。只有已用平台图片本身无法下载、解码、无可见像素、来源身份不可信或最终没有任何真实平台素材引用时才阻断。 ### 13.2 同一 Codex thread 的有界自主验收 每个直连回合按以下最多三次 Codex turn 执行,不恢复 Supervisor、专业 Agent 或 harness: 1. 首轮由 Codex 生成或修改当前工作区文件。 -2. 客户端启动受限本地预览,使用真实 Chromium 在 desktop 和 mobile 两个固定视口采集页面加载、Canvas、控制台/异常、失败请求、可见文本、截图和有限的真实交互探针;direct 链路不使用 `BrowserPlaytestScenario::GenericV1` 等固定玩法状态机,试玩结果只作为模型判断证据。 -3. 客户端把脱敏、结构化的浏览器证据发送给同一 Codex thread。Codex 必须读取实际文件和证据,发现问题就直接修改并说明修复;若文件发生变化,客户端重新试玩。最新结果仍失败时最多再发送一次明确整改反馈,之后安全失败,不登记完成版本。 +2. 客户端先复核最低完成证明:`game/index.html` 是否存在,以及源码是否引用至少一个已登记的平台图片。该证明不满足时,不在版本登记阶段直接报错;客户端把脱敏的具体缺口回灌给同一 Codex thread,待其修复后才启动受限本地预览。该回灌不允许 Codex 修改 manifest、伪造素材或覆盖来源身份。 +3. 最低证明满足后,客户端启动受限本地预览,使用真实 Chromium 在 desktop 和 mobile 两个固定视口采集页面加载、Canvas、控制台/异常、失败请求、可见文本、截图和有限的真实交互探针;direct 链路不使用 `BrowserPlaytestScenario::GenericV1` 等固定玩法状态机,试玩结果只作为模型判断证据。 +4. 客户端把脱敏、结构化的浏览器证据及仍存在的最低完成证明缺口发送给同一 Codex thread。Codex 必须读取实际文件和证据,发现问题就直接修改并说明修复;若文件发生变化,客户端重新试玩。最新结果仍失败时最多再发送一次明确整改反馈,之后安全失败,不登记完成版本。 最终回复必须包含:检查过的文件、真实启动/试玩动作、desktop/mobile 观察、陶泥儿平台素材如何被实际使用、已修复问题和剩余风险。客户端只展示摘要,不把绝对路径、Token、签名 URL、Provider 原文或内部队列信息传给用户。 ### 13.3 最低使用证明与完成条件 -客户端只要求 `game/index.html` 存在,并能证明至少一个已登记、真实、平台来源的图片路径被游戏源码引用;不统计 `drawImage` 次数,不要求四类切片或固定代码形态。游戏质量、布局、可玩性、素材是否真的出现在画面中由 Codex 结合真实浏览器截图和结果自行判断。浏览器基础设施失败、页面无法加载、Canvas 完全不可见或出现未处理异常仍作为安全失败证据;其它质量问题交给同 thread 有界整改。 +客户端只要求 `game/index.html` 存在,并能证明至少一个已登记、真实、平台来源的图片路径被游戏源码引用;不统计 `drawImage` 次数,不要求四类切片或固定代码形态。该最低证明失败必须先进入同 thread 有界整改,而不是在浏览器成功后才由版本登记阶段突然终止;若最终仍失败,才拒绝登记。 + +受限 Chromium 还会对已登记平台图片采集运行时使用观察:图片是否在真实 Canvas / WebGL 渲染调用中出现,以及是否只作为页面可见图片出现。该观察不依赖固定切片、固定文件名、特定棋盘结构或字符串计数,也不替代 Codex 对截图和玩法质量的判断;但若只观察到旁侧预览、未观察到核心渲染路径,客户端必须把这一明确缺口回灌给同一 Codex thread 要求整改,不能把“源码引用”或“旁侧缩略图”表述为素材已进入玩法。浏览器基础设施失败、页面无法加载、Canvas 完全不可见或出现未处理异常仍作为安全失败证据;其它质量问题交给同 thread 有界整改。 ### 13.4 验收要求 - 定向 Rust 测试覆盖:完整主图集无切片可通过;来源/身份/PNG 解码/同 Canvas 约束仍失败关闭;源码合理引用任一平台素材可登记;无平台素材引用不能完成;系统提示词包含自主试玩与结构化报告要求。 - fake app-server 测试覆盖:连续 direct turn 复用同一 thread,浏览器证据和整改反馈形成后续 `turn/start`,无 Supervisor/child/harness。 - 真实客户端验收覆盖:当前 checkout 的 AGC 新建或打开直连项目,真实本地预览在 desktop/mobile 运行并至少执行一次可见交互;截图与结构化证据写入项目 `.agent/runtime` 证据目录;完成回复展示可读验收摘要。 + +### 13.5 透明后处理失败时的图集源图安全复用(2026-08-18) + +当陶泥儿 `art-spritesheet` 的远端生成已完成、源 PNG 已落在同一画布,但透明化或切片后处理失败时,直连 Runtime 可以在不重新提交、不重复扣费的前提下只读恢复该源图。恢复仅适用于规范图唯一绑定的核心图集:候选必须属于当前 Canvas、生成路由和种类匹配,且要么 `sourceResourceId` 精确指向当前规范图,要么只包含当前规范图的一项不可变参考。多个候选、外部参考、多参考、不可下载、非 PNG、透明或无可见像素一律失败关闭。 + +恢复成功后将完整图集作为可信平台素材继续交给同一 Codex thread;客户端不得伪造切片、不得把未生成的切片登记为存在。源图已确认由平台完成且只读恢复成功后,客户端清理同一 `agentId/runId` 的已完成生成账本;只读恢复失败、身份不唯一或结果仍未知时继续保留账本供对账,绝不删除后重发。若没有可安全恢复的历史图集、但规范图和背景图已经可信可用,直连生成可继续使用这两张素材并完成上述 LLM 自主验收,不再为只读恢复失败重发图集请求。 + +### 13.6 隔离验收进程的陶泥儿开发者凭据(2026-08-17) + +真实 CLI / Chromium 验收进程不能依赖已打开 GUI 的内存登录态,也不能复制 GUI 的 Cookie、access token 或 Runner 会话。直连 Runtime 首先只读取当前用户私有的 `~/.config/genarrative/external-editor-api.json`:其中保存一次性显示过的开发者 API Key,和可选的受信任陶泥儿 API origin;仓库、项目目录、`game-creator.config.json`、诊断、命令行参数和 Codex 系统提示词均不得出现该 Key。 + +当私有 Key 缺失但当前 GUI 已有陶泥儿账号会话时,客户端可按用户授权仅调用受保护的 `/api/profile/api-keys` 创建一个名称固定的本机直连 Key,并以原子写入和当前用户私有权限保存到上述文件。创建后,direct Runtime 的美术准备、只读图集恢复、素材换签、生成提交和轮询统一使用 External v1 路由及该 Key;不得混用账号 JWT 路由。若 Key 缺失且没有 GUI 会话,必须给出“先在客户端登录一次以创建本机开发者 Key”的可行动错误,而不是误报普通试玩失败。 + +开发者 Key 创建是唯一允许借用 GUI 登录态的受控设置动作;平台图像生成继续由既有幂等账本、同一 idempotency key 和未知结果恢复保护。真实验收必须至少用一个不带 GUI 内存登录态的新进程恢复同一项目,以证明该私钥链路可独立生成、下载并完成 Chromium desktop/mobile 试玩。 + +### 13.7 首次私钥引导的本机安全存储前置与可行动失败说明(2026-08-17) + +首次真实客户端验收表明,失败不是 GUI 登录态未同步:首次本机开发者凭据创建已经拿到远端响应,但 Windows 新建的私有目录 owner 是 `Administrators`,随后 TokenUser 私有 DACL 校验拒绝落盘。一次性显示的远端凭据无法恢复,旧顺序会留下孤儿凭据,因此不能用刷新登录态或自动重放掩盖。 + +1. 缺失本机开发者凭据时,客户端必须先准备并验证精确的私有存储目录,再请求远端创建凭据。仅当该精确目录由当前调用以原子创建成功时,才允许初始化 owner 和私有 DACL 为当前 TokenUser;已有目录一律按严格 owner/DACL 校验,owner 不匹配时失败关闭,不自动接管、改 ACL、覆盖或删除。 +2. 若本机目录预检失败,客户端返回稳定的“本机开发者凭据存储目录未安全初始化;未创建远端凭据”分类,不请求远端创建接口、不发起美术生成、不写 operation 或账本,也不刷新登录态或自动重试。 +3. 远端响应后原子写入仍可能因并发或磁盘故障失败;该极窄路径必须单独分类为“凭据已创建但未能安全保存”,提示用户在账户开发者凭据页面撤销后再试,不能自动创建第二把凭据。诊断和正式 UI 只展示上述安全摘要与恢复建议,不包含 access token、开发者凭据、响应正文、绝对路径或签名 URL。 +4. 验收先在当前失败项目上恢复:对遗留的空且 owner 不匹配目录做可恢复隔离后,再复用同一项目发送“继续完成此前三消游戏”。成功标准包括本机私钥存在但不读取其内容、平台素材与版本登记完成、desktop/mobile Chromium 试玩证据和隔离进程恢复;此前已创建但无法恢复的远端孤儿凭据作为明确剩余风险,绝不自动撤销。 diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 0d37ae003..417ddb822 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -14235,3 +14235,17 @@ - 决策:`npm run agc` 及普通 debug 启动只打开标题为“陶泥儿”的 `client` 客户端窗口,不再由 Tauri setup 自动创建 Agent 聊天开发窗口。普通创作、直连 Codex Runtime 和项目工作台行为均不受影响。 - 保留:`?agent-chat` 仅作为显式前端调试路由;`supervisor-chat` 和 `--game-chat` 继续是独立的显式开发验收入口,不会随着普通启动同时弹出。 - 守卫:原生壳配置检查必须拒绝恢复 `open_developer_window(app.handle())?` 自动调用;真实开发 smoke 以普通客户端单窗口为准。 + +## 2026-08-17 直连 Codex 使用 LLM 自主试玩替代固定美术产物门 + +- 直连 Runtime 的确定性门禁只保护工作区权限、凭据隔离、平台生成幂等与账本、文件事务、图片下载/PNG 解码、平台来源身份和同 Canvas 关系。`grid-2x2`、固定四切片、固定文件名及固定 `drawImage` 次数降为陶泥儿标准推荐路径,不再阻断所有合理的游戏产物。 +- Codex 是唯一执行主体。客户端在同一 Codex thread 内最多追加两次结构化浏览器证据/整改 turn;受限 Chromium 负责 desktop/mobile 页面、Canvas、控制台/网络、截图和有限真实交互探针,禁止恢复 Supervisor、专业 Agent 或 harness,也不把固定玩法状态机当成 direct 完成合同。 +- 完成登记前至少要有 `game/index.html` 和一个已登记、真实、平台来源的图片被源码引用;游戏质量与视觉实际使用由 Codex 根据真实试玩证据自行判断,最终回复必须说明检查文件、试玩动作、双视口观察、素材使用、修复和剩余风险。 + +## 2026-08-17 AGC 直连平台资源失败诊断 + +- 普通客户端使用陶泥儿平台账号会话时,直连 Runtime 的只读画布恢复继续以 External Editor 形状构造请求,再统一经 `resolve_platform_editor_api_route` 映射到 `/api/editor/...` 与 `/api/assets/...`;不得把平台 access token 直接送到未映射的 `/api/external/v1/...` 路由,也不得为排障改用或落盘开发者 API Key。 +- 直连回合的请求准备、平台美术准备、Codex 代码生成、真实浏览器试玩和版本登记失败,统一投影为 `direct-codex-failure:v1`:仅含稳定阶段、脱敏摘要、是否可重试和下一步建议。每次失败尽力写入项目 `.agent/runtime/direct-codex-diagnostics//failure.json`,不保存 token、Cookie、完整 URL、绝对路径或原始服务端正文;写诊断失败不得遮蔽原失败。 +- 普通客户端必须展示上述安全摘要与建议,不能把可解释的资源恢复失败降级成“执行失败,请稍后重试”。平台账号失效提示重新登录;资源身份冲突、多个同源图集或透明图集明确要求先在资源画布核对,而非盲目重复生成或扣费。 +- 已有同一画布、身份可信且可解码的规范图与背景图时,历史核心图集的只读恢复只是可选增强:未找到该图集不得阻断直连 Codex 生成、浏览器试玩或版本登记,也不得触发重复付费生成;最终源码仍须实际引用至少一个已登记的平台图片。 +- 透明后处理失败但平台已保留源图时,只有同一画布身份的只读恢复成功后才清理对应 `agentId/runId` 生成账本;恢复失败、身份不唯一或结果未知继续保留 `accepted` / `operationId` 供对账,禁止因清理过早而重复扣费。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 03d646f58..4fce7e011 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -672,7 +672,7 @@ game-project/ - `npm run check:encoding` 与 `git diff --check`:覆盖中文文档、中文命令文案和补丁空白;用于避免乱码、尾随空白和无关格式漂移。 - `npm run ai-game-creator-shell:llm-status`:只检查 LLM 客户端配置是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。发布版启动时会在 Tauri 应用配置目录生成默认 `game-creator.config.json`,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板。 - `npm run agc:config`:提供终端交互式 LLM 配置向导,支持 OpenAI、DeepSeek、Anthropic、火山 Ark 和自定义 Provider,依次采集 Base URL、模型与隐藏输入的 LLM API Key,并写入与 GUI“配置”面板相同的系统 AppData `game-creator.config.json`。向导不采集普通画板 URL/Key;更新 LLM 主配置时必须保留其它现役配置,POSIX 下保持 AppData 目录 `0700`、配置文件 `0600`,通过同目录临时文件原子替换,保存后复用真实 `llm-status` 检查。禁止提供或接受 `--api-key`,避免密钥进入 shell history、进程参数、日志或测试报告。 -- `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 配置放在 Tauri 应用配置目录的 `game-creator.config.json` 中,至少设置 `llm.apiKey`,需要覆盖默认服务时设置 `llm.baseUrl`、`llm.model`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `llm.stream` 为 `true`。 +- `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 配置放在 Tauri 应用配置目录的 `game-creator.config.json` 中,至少设置 `llm.apiKey`,需要覆盖默认服务时设置 `llm.baseUrl`、`llm.model`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;`llm.stream` 默认开启,若某个兼容网关不支持流式响应,可在全局或对应 `agentLlm` 中显式设为 `false`。 ## 当前最小落地 @@ -702,7 +702,7 @@ game-project/ - 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。 - 终端可用 `npm run ai-game-creator-shell:llm-status` 检查 LLM 客户端配置是否就绪;桌面 App 主窗口“配置”面板可读写 Tauri 应用配置目录中的 `game-creator.config.json`,`/llm-status` / 生成入口读取同一份配置,CLI 开发入口无 AppHandle 时才回退读取仓库旁边的配置模板和 gitignored 本机覆盖文件;不请求上游、不显示 API Key,缺配置时以非零状态退出或在聊天里提示未就绪。 - 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-llm` 网关测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke;已退役的 `platform-agent` 不再进入 workspace 或该门禁。 -- 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;发布 App 读取 Tauri 应用配置目录中的 `game-creator.config.json`,开发 CLI 无 AppHandle 时才读取仓库旁边的配置模板和 gitignored 本机覆盖文件,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`;旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`。真实 OpenAI-compatible 网关建议设置 `llm.stream` 为 `true` 跑 Planner 和 Generator,避免长请求非流式空闲断连。 +- 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;发布 App 读取 Tauri 应用配置目录中的 `game-creator.config.json`,开发 CLI 无 AppHandle 时才读取仓库旁边的配置模板和 gitignored 本机覆盖文件,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`,且 `llm.stream` 默认开启;旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`。不支持流式响应的兼容网关可显式设为 `false`。 - 终端可用 `npm run ai-game-creator-shell:agent-run:smoke` 跑一次无密钥本地端到端 smoke:脚本启动本机 OpenAI-compatible SSE 流式测试 provider,预置一个本地上传图片和一个本地上传音频,复用真实 `--agent-run`、Planner / Orchestrator / 角色 agent / Generator / Evaluator loop、本地落盘、`game.static_smoke` 和本地 HTTP 预览,并断言每次 provider 请求都使用 `stream: true`、Planner 与 Generator 分别命中自己的 `agentLlm` provider 配置、provider prompt 收到图片与音频资产上下文以及最近对话上下文、生成 HTML 引用这些资产、预览服务能用 `GET` 读取 `/assets/...`、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、`.agent/run.latest.json` 的 step group 覆盖 design / balance / art / audio / code / publishing 六组、第二轮会重跑 Evaluator 命中任务及其下游影响任务,未受影响角色 carry-over;随后脚本自动给 CLI 发送回车停止预览。该脚本只用于开发验证,不进入产品生成路径。 - `npm run ai-game-creator-shell:dev` 必须经受控启动器解析 AGC Vite 端口;Linux 默认使用用户端口段的 `start + 5`,非 Linux 保留 `3080` 兼容首选。启动器用动态 Tauri `build.devUrl`、`GENARRATIVE_AGC_VITE_PORT` 和 Vite CLI `--port` 保证 WebView、`beforeDevCommand`、配套后端预留和 Vite `strictPort` 对齐;不得复用无法证明 worktree 归属的现有 Vite,最终端口被竞态占用时直接失败。 - AI 游戏创作 App 的本地后端使用 gitignored 的 `server-rs/.spacetimedb/ai-game-creator/data`,不复用主站旧 standalone 数据目录。启动器从本地 `/v1/identity` 获取并持久化 API identity,再通过数据目录内 `0600` 的独立 `dev-cli/cli.toml` 发布模块;不得读取或覆盖开发者全局 SpacetimeDB 登录,也不得回退到每次变化的 `--anonymous` 身份。发布失败时 API 和 Vite 不得继续启动旧 schema,避免 `external_generation_job` 等缺表订阅进入持续重试。 @@ -712,7 +712,7 @@ game-project/ - `.agent/manifest.json` 会保存 6 个专业组下 16 个组内角色任务状态,当前覆盖 `Director`、`Gameplay`、`Difficulty`、`Asset`、`Polish`、`SFX`、`Code`、`Review`、`Preview`、`Playtest`、`Publish`;程序组内显式包含 `quality-review` 质量评审 gate,由 Evaluator trace 标记完成;开发窗口的专业组面板读取 manifest,而不是前端硬编码。 - 主窗口的 agent 状态列表以 manifest 角色任务为底表,再合并最近 run trace 中 `taskGraph.tasks` 的任务状态、同 taskId / group / role 的最新 step 状态、输入输出路径、错误摘要、lifecycleStatus 和 `activeTaskIds` / `carriedTaskIds` / `readyTaskIds` 编排标记;如果 trace 缺失或过期,只展示 manifest 的静态任务状态和“暂无最近运行证据”。 - 共享契约和 `platform-agent` 会按任务依赖与 `completed` 状态计算当前可执行任务,作为 v1 的最小编排选择器;每轮 `Orchestrator` 的 activeTaskIds、carriedTaskIds、repairRoutes 和 dependencyWaves 由 `platform-agent` 纯编排内核产出,`apps/ai-game-creator-shell` 只负责写入 `.agent/passes/pass-N/` 和执行本地工具;`Evaluator` 会在 `.agent/findings.md` 写出 `## Repair Routes` JSON,下一轮编排优先采用该结构化 taskIds,解析不到时才退回关键词路由;返工路由会按任务图自动扩展下游影响任务,例如美术资产变化会继续触发程序预览和运营包装重算。 -- `game.generate_draft` 使用 OpenAI-compatible LLM 配置生成结构化 JSON 草案,发布 App 的配置项来自 Tauri 应用配置目录中的 `game-creator.config.json`:`llm.apiKey`、`llm.baseUrl`、`llm.model`、`llm.apiKind`、`llm.stream`、`llm.requestTimeoutMs`、`llm.maxRetries`、`llm.retryBackoffMs`;默认 API kind 为 `openai_responses`,可设 `llm.apiKind=openai_chat` 切回旧 Chat Completions 兼容网关,或 `llm.apiKind=anthropic` 走 Anthropic Messages;`llm.stream=true` 时 Planner 和 Generator 使用流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 +- `game.generate_draft` 使用 OpenAI-compatible LLM 配置生成结构化 JSON 草案,发布 App 的配置项来自 Tauri 应用配置目录中的 `game-creator.config.json`:`llm.apiKey`、`llm.baseUrl`、`llm.model`、`llm.apiKind`、`llm.stream`、`llm.requestTimeoutMs`、`llm.maxRetries`、`llm.retryBackoffMs`;默认 API kind 为 `openai_responses`,`llm.stream` 默认开启,可设 `llm.apiKind=openai_chat` 切回旧 Chat Completions 兼容网关,或 `llm.apiKind=anthropic` 走 Anthropic Messages;显式 `llm.stream=false` 时 Planner 和 Generator 使用非流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 - 普通主窗口“配置”面板和聊天 `/config` 命令只维护 LLM、Agent LLM 与 MCP 等运行时配置,不展示画板 Base URL 或 Developer API Key;正式平台美术缺少登录态时引导重新登录,不引导填写 `editorApi`。主聊天 Agent、Planner、Orchestrator、Generator、Evaluator 和 16 个角色 agent 可在 `agentLlm` 中单独覆盖 LLM 路由,空项继承全局配置。仅独立 standalone game-chat release/显式高级自定义模式的隔离设置可维护 External v1 URL/Key,且不得被普通 release 自动读取。 - 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认全局 LLM 以及各 agent resolved 后的 base_url、model、API 类型和 API Key 是否已从客户端配置读取;状态消息不会显示或保存 API Key;当前生成链路只要求 Planner / Generator 就绪。`/llm-routes` 复用同一检查结果,但输出按 agent 展开的路由清单和缺口摘要,用于确认哪些 agent 解析后走全局路由、哪些 agent 走单独 provider。 - `game.generate_draft` 的 LLM JSON 必须包含 `handoffs` 数组,覆盖 `design`、`balance`、`art`、`audio`、`code`、`publishing` 6 个专业组;每组必须给出 role、summary、outputs 和 next,缺组或交接内容不完整会判定为模型输出无效并进入返工。 diff --git a/server-rs/crates/api-server/src/editor_generation_queue.rs b/server-rs/crates/api-server/src/editor_generation_queue.rs index 240d85843..70f45dcf0 100644 --- a/server-rs/crates/api-server/src/editor_generation_queue.rs +++ b/server-rs/crates/api-server/src/editor_generation_queue.rs @@ -30,7 +30,8 @@ const EDITOR_GENERATION_QUEUE_PROVIDER: &str = "editor-generation-worker"; const MAX_EDITOR_GENERATION_JOB_PAYLOAD_BYTES: usize = 512 * 1024; const EXTERNAL_API_GENERATION_DEDUPE_PREFIX: &str = "external-api-generation"; const EDITOR_API_REQUEST_GENERATION_DEDUPE_PREFIX: &str = "editor-api-request-generation"; -const GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX: &str = "game-creator-client-generation"; +pub(crate) const GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX: &str = + "game-creator-client-generation"; pub(crate) const GAME_CREATOR_CLIENT_GENERATION_SOURCE: &str = "ai-game-creator-client"; #[derive(Debug, Serialize)] diff --git a/server-rs/crates/api-server/src/external_generation_worker.rs b/server-rs/crates/api-server/src/external_generation_worker.rs index 1f0da6635..b27804a9d 100644 --- a/server-rs/crates/api-server/src/external_generation_worker.rs +++ b/server-rs/crates/api-server/src/external_generation_worker.rs @@ -38,6 +38,7 @@ use crate::{ EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND, EDITOR_IMAGE_EDIT_JOB_KIND, EDITOR_IMAGE_GENERATION_JOB_KIND, EDITOR_SOUND_EFFECT_GENERATION_JOB_KIND, EDITOR_UI_DESIGN_ASSET_EXTRACTION_JOB_KIND, EDITOR_VIDEO_GENERATION_JOB_KIND, + GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX, }, editor_project::{ EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION, @@ -1278,7 +1279,7 @@ fn editor_generation_result_payload_json( compact_editor_generation_result(response.clone()), ); } - if is_external_api_generation_job(job) + if is_result_recovery_generation_job(job) && let Some(object) = payload.as_object_mut() { object.insert( @@ -1294,10 +1295,10 @@ fn editor_generation_result_payload_json( payload.to_string() } -fn is_external_api_generation_job(job: &ExternalGenerationJobRecord) -> bool { - job.dedupe_key - .trim() - .starts_with("external-api-generation:") +fn is_result_recovery_generation_job(job: &ExternalGenerationJobRecord) -> bool { + let dedupe_key = job.dedupe_key.trim(); + dedupe_key.starts_with("external-api-generation:") + || dedupe_key.starts_with(&format!("{GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX}:")) } fn is_editor_agent_generation_job(job: &ExternalGenerationJobRecord) -> bool { @@ -2604,6 +2605,62 @@ mod tests { } } + #[test] + fn game_creator_client_result_keeps_completed_grid_spritesheet_for_recovery() { + let mut job = external_generation_job_record_fixture(Some("lease-1")); + job.dedupe_key = format!( + "{GAME_CREATOR_CLIENT_GENERATION_DEDUPE_PREFIX}:editor_icon_spritesheet_generation:fingerprint" + ); + job.job_kind = EDITOR_ICON_SPRITESHEET_GENERATION_JOB_KIND.to_string(); + let response = json!({ + "data": { + "spritesheetImageSrc": "/api/assets/object/core-sheet.png", + "spritesheetWidth": 1024, + "spritesheetHeight": 1024, + "sliceLayout": "grid-2x2", + "spritesheetResource": { + "resourceId": "sheet-resource-1", + "objectKey": "users/user-1/core-sheet.png", + "imageSrc": "/api/assets/object/core-sheet.png" + }, + "spritesheetAsset": { + "assetId": "sheet-asset-1", + "objectKey": "users/user-1/core-sheet.png", + "imageSrc": "/api/assets/object/core-sheet.png" + }, + "iconImageSrcs": [ + {"name": "玩家", "objectKey": "users/user-1/player.png", "imageSrc": "/api/assets/object/player.png"}, + {"name": "目标", "objectKey": "users/user-1/targets.png", "imageSrc": "/api/assets/object/targets.png"}, + {"name": "场景", "objectKey": "users/user-1/scene.png", "imageSrc": "/api/assets/object/scene.png"}, + {"name": "反馈", "objectKey": "users/user-1/feedback.png", "imageSrc": "/api/assets/object/feedback.png"} + ] + } + }); + + let payload: Value = + serde_json::from_str(&editor_generation_result_payload_json(&job, &response)) + .expect("游戏创作客户端完成结果应持久化为合法 JSON"); + + assert_eq!(payload["result"]["sliceLayout"], json!("grid-2x2")); + assert_eq!( + payload["result"]["iconImageSrcs"].as_array().map(Vec::len), + Some(4) + ); + assert_eq!( + payload["result"]["spritesheetResource"]["resourceId"], + json!("sheet-resource-1") + ); + assert_eq!( + payload["result"]["spritesheetAsset"]["assetId"], + json!("sheet-asset-1") + ); + assert_eq!( + payload["result"]["iconImageSrcs"][0]["objectKey"], + json!("users/user-1/player.png") + ); + assert!(!payload.to_string().contains("prompt")); + } + #[test] fn non_external_job_does_not_publish_query_result() { let job = external_generation_job_record_fixture(Some("lease-1"));