From 755bf808587a7cb663c458160e1f102a4a676ff8 Mon Sep 17 00:00:00 2001 From: kdletters <61648117+kdletters@users.noreply.github.com> Date: Tue, 11 Aug 2026 13:25:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=20AI=20=E6=B8=B8=E6=88=8F?= =?UTF-8?q?=E5=88=9B=E4=BD=9C=E6=89=93=E5=BC=80=E7=8E=B0=E6=9C=89=20Godot?= =?UTF-8?q?=20=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 Godot 项目选择、校验、导入和项目上下文切换入口。 只在所选项目根保留 .agent 元数据,并让总控使用标准运行档。 修复发布版原生 API 访问和 React runtime 重复加载问题。 收紧 Provider 工具 Schema 子集并增加回归门禁。 补充 Rust、前端测试及 PRD、技术方案和共享决策记录。 --- apps/ai-game-creator-shell/package-lock.json | 10 ++ apps/ai-game-creator-shell/package.json | 1 + .../src-tauri/Cargo.lock | 167 +++++++++++++++++- .../src-tauri/Cargo.toml | 1 + .../src-tauri/capabilities/main.json | 4 + .../src-tauri/src/agent_native_tools.rs | 3 +- .../src-tauri/src/commands.rs | 23 +++ .../src-tauri/src/main.rs | 3 + .../src-tauri/src/project/manifest.rs | 78 +++++++- .../src/project/manifest/import_tests.rs | 67 +++++++ .../src-tauri/src/tests/project.rs | 1 + apps/ai-game-creator-shell/src/App.tsx | 64 +++++-- .../src/app/AuthenticatedClient.tsx | 64 ++++++- apps/ai-game-creator-shell/src/app/types.ts | 4 + .../features/app-shell/ProjectCreation.tsx | 6 + .../features/app-shell/WorkspaceLauncher.tsx | 2 + .../src/features/app-shell/model.ts | 1 + .../app-shell/useHomeProjectCreation.ts | 68 +++++++ .../src/services/clientApi.ts | 20 +-- .../src/services/clientAuth.ts | 20 +-- .../src/services/clientHttp.ts | 58 ++++++ .../src/view/home/index.tsx | 11 ++ .../appSurface/project-development.suite.ts | 91 +++++++++- .../tests/clientHttp.test.ts | 51 ++++++ .../tests/clientRuntimeErrorBoundary.test.ts | 37 ++++ apps/ai-game-creator-shell/vite.config.ts | 3 + ...AI游戏创作】项目开发工作台PRD-2026-07-20.md | 9 +- .../shared-memory/decision-log.md | 19 ++ ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 +- package-lock.json | 33 ++++ package.json | 1 + scripts/check-native-shells.mjs | 83 ++++++++- 32 files changed, 932 insertions(+), 79 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs create mode 100644 apps/ai-game-creator-shell/src/services/clientHttp.ts create mode 100644 apps/ai-game-creator-shell/tests/clientHttp.test.ts create mode 100644 apps/ai-game-creator-shell/tests/clientRuntimeErrorBoundary.test.ts diff --git a/apps/ai-game-creator-shell/package-lock.json b/apps/ai-game-creator-shell/package-lock.json index 048c92537..f0fb8cac3 100644 --- a/apps/ai-game-creator-shell/package-lock.json +++ b/apps/ai-game-creator-shell/package-lock.json @@ -11,6 +11,7 @@ "@lexical/react": "^0.47.0", "@lexical/utils": "^0.47.0", "@tauri-apps/plugin-clipboard-manager": "2.3.2", + "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", "@vitejs/plugin-react": "^5.0.4", "lexical": "^0.47.0", @@ -2208,6 +2209,15 @@ "@tauri-apps/api": "^2.8.0" } }, + "node_modules/@tauri-apps/plugin-http": { + "version": "2.5.9", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.9.tgz", + "integrity": "sha512-lCiY0+vs4HvIUSvZrBs8TC3TiCB0MOPRmiUjTq4prW7SlcJE2jdLeT6KBsJrT9Tlplufl7W1pY6SFAO3gCWxDA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.11.0" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz", diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index e2982bbc3..dfb31f3c2 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -37,6 +37,7 @@ "@lexical/react": "^0.47.0", "@lexical/utils": "^0.47.0", "@tauri-apps/plugin-clipboard-manager": "2.3.2", + "@tauri-apps/plugin-http": "^2.5.9", "@tauri-apps/plugin-opener": "~2", "@vitejs/plugin-react": "^5.0.4", "lexical": "^0.47.0", diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index ec50f66c7..89a23f4f5 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -702,10 +702,39 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" dependencies = [ + "percent-encoding", "time", "version_check", ] +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -729,7 +758,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", "foreign-types 0.5.0", "libc", @@ -742,7 +771,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -889,6 +918,12 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "data-url" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" + [[package]] name = "dbus" version = "0.9.11" @@ -1018,6 +1053,15 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1131,6 +1175,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -1616,6 +1669,7 @@ dependencies = [ "tauri-build", "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", + "tauri-plugin-http", "tauri-plugin-opener", "tempfile", "tokio", @@ -1835,6 +1889,25 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.14.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1955,6 +2028,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1979,6 +2053,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2015,9 +2090,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -2488,6 +2565,12 @@ version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + [[package]] name = "lock_api" version = "0.4.14" @@ -3734,6 +3817,22 @@ dependencies = [ "windows 0.62.2", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "pxfm" version = "0.1.30" @@ -3987,8 +4086,12 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ "base64 0.22.1", "bytes", + "cookie", + "cookie_store", + "encoding_rs", "futures-core", "futures-util", + "h2", "http", "http-body", "http-body-util", @@ -3998,6 +4101,7 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime", "native-tls", "percent-encoding", "pin-project-lite", @@ -4021,6 +4125,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", + "webpki-roots", ] [[package]] @@ -4291,7 +4396,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags 2.13.0", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -4847,6 +4952,27 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -4868,7 +4994,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" dependencies = [ "bitflags 2.13.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dbus", @@ -5103,6 +5229,30 @@ dependencies = [ "url", ] +[[package]] +name = "tauri-plugin-http" +version = "2.5.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067" +dependencies = [ + "bytes", + "cookie_store", + "data-url", + "http", + "regex", + "reqwest 0.12.28", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "tokio", + "url", + "urlpattern", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -6176,6 +6326,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 59f16c386..06aad7379 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -41,6 +41,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "nat shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false } tauri = { version = "2.11.2", features = [] } tauri-plugin-dialog = "2.7.1" +tauri-plugin-http = "2.5.9" tauri-plugin-opener = "2" tempfile = "3" tokio = { version = "1", features = ["io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time"] } diff --git a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json index 7f4653d2f..50d312f47 100644 --- a/apps/ai-game-creator-shell/src-tauri/capabilities/main.json +++ b/apps/ai-game-creator-shell/src-tauri/capabilities/main.json @@ -9,6 +9,10 @@ "core:image:allow-rgba", "core:image:allow-size", "core:resources:allow-close", + { + "identifier": "http:default", + "allow": [{ "url": "https://dev.genarrative.world/api/*" }] + }, "opener:default" ] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index b1fab7688..51c9d2e1e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1262,7 +1262,6 @@ fn runtime_tool_input_schema(tool: &str) -> Value { "missingAssetSlots": { "type": "array", "maxItems": 2, - "uniqueItems": true, "items": { "type": "string", "enum": ["art-spec", "core-spritesheet"] } } } @@ -1386,7 +1385,7 @@ mod tests { let Some(object) = schema.as_object() else { return; }; - for keyword in ["oneOf", "anyOf", "allOf", "not"] { + for keyword in ["oneOf", "anyOf", "allOf", "not", "uniqueItems"] { if object.contains_key(keyword) { issues.push(format!( "strict schema contains unsupported {keyword} at {path}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index 7e1fb6bc0..4f3af8430 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -12,6 +12,18 @@ pub(crate) fn init_local_game_project( init_local_game_project_at(root, project_id.trim(), name.trim()) } +#[tauri::command] +pub(crate) fn import_local_godot_project( + project_path: String, + project_id: String, + name: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + let _lock = acquire_project_write_lock(root, "project.create")?; + import_local_godot_project_at(root, project_id.trim(), name.trim()) +} + #[tauri::command] pub(crate) fn is_local_project_directory_non_empty(project_path: String) -> Result { let root = Path::new(project_path.trim()); @@ -58,6 +70,7 @@ pub(crate) fn inspect_local_project_directory( exists: root.exists(), is_directory: root.is_dir(), is_game_creator_project: is_game_creator_project_directory(root), + is_godot_project: is_godot_project_directory(root), project_name: game_creator_project_name(root), manifest_error: game_creator_project_manifest_error(root), recent_run_status: recent_run_trace.as_ref().map(|trace| trace.status.clone()), @@ -65,6 +78,16 @@ pub(crate) fn inspect_local_project_directory( }) } +pub(crate) fn is_godot_project_directory(root: &Path) -> bool { + if !root.is_dir() { + return false; + } + let project_file = root.join("project.godot"); + fs::symlink_metadata(project_file) + .map(|metadata| metadata.is_file() && !metadata.file_type().is_symlink()) + .unwrap_or(false) +} + pub(crate) fn is_game_creator_project_directory(root: &Path) -> bool { if !root.is_dir() { return false; 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 81ee7ca50..2428dabf6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -124,6 +124,7 @@ struct LocalProjectDirectoryStatus { exists: bool, is_directory: bool, is_game_creator_project: bool, + is_godot_project: bool, project_name: Option, manifest_error: Option, recent_run_status: Option, @@ -2036,6 +2037,7 @@ fn main() { let app = tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_http::init()) .plugin(tauri_plugin_clipboard_manager::init()) .manage(game_creator_preview_registry()) .setup(move |app| { @@ -2169,6 +2171,7 @@ fn main() { }) .invoke_handler(tauri::generate_handler![ init_local_game_project, + import_local_godot_project, is_local_project_directory_non_empty, inspect_local_project_directory, pick_local_project_directory, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs index 80216aa36..4f1e01b7e 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs @@ -138,7 +138,12 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result, String { Ok(file) => { validate_windows_regular_file_handle(&file, "manifest 锁")?; - crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, true)?; + // 提升权限运行时,Windows 可能用 TokenOwner=Administrators 创建新文件。 + // 独占句柄与普通文件检查通过后,将这个固定锁文件收归当前 TokenUser, + // 再复核句柄并按既有规则验证 owner/DACL;不放宽旧文件的安全门禁。 + crate::initialize_windows_game_creator_file_owner_for_current_user(&lock_path)?; + validate_windows_regular_file_handle(&file, "manifest 锁")?; + crate::secure_windows_game_creator_path_for_current_user(&lock_path, false, false)?; Ok(Some(file)) } Err(error) @@ -235,6 +240,75 @@ pub(crate) fn init_local_game_project_at( }) } +pub(crate) fn import_local_godot_project_at( + root: &Path, + project_id: &str, + name: &str, +) -> Result { + if root.as_os_str().is_empty() { + return Err("项目目录不能为空".to_string()); + } + if !root.is_absolute() { + return Err("项目目录必须是绝对路径".to_string()); + } + if project_path_has_control_chars(root) { + return Err("项目目录不能包含控制字符".to_string()); + } + if !root.is_dir() { + return Err("Godot 项目目录不存在或不是文件夹".to_string()); + } + if !is_godot_project_directory(root) { + return Err("所选文件夹不是有效的 Godot 项目:缺少普通文件 project.godot".to_string()); + } + if project_id.is_empty() { + return Err("项目 ID 不能为空".to_string()); + } + if name.is_empty() { + return Err("项目名称不能为空".to_string()); + } + + let manifest_path = root.join(".agent/manifest.json"); + if manifest_storage_exists(&manifest_path)? { + let manifest = read_manifest(&manifest_path)?; + return Ok(InitLocalProjectResult { + project_path: root.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + manifest, + }); + } + + let agent_db_path = root.join(".agent/agent.db"); + if !agent_db_path.exists() { + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.import", + "projectId": project_id, + "name": name, + "projectKind": "godot", + }), + )?; + } + + for relative in [".agent/logs", ".agent/runtime"] { + fs::create_dir_all(root.join(relative)).map_err(|error| { + format!( + "创建 Godot 项目 Agent 目录失败:{}: {error}", + root.join(relative).display() + ) + })?; + } + + let manifest = new_game_creation_app_manifest(project_id, name); + write_manifest(&manifest_path, &manifest)?; + + Ok(InitLocalProjectResult { + project_path: root.to_string_lossy().into_owned(), + manifest_path: manifest_path.to_string_lossy().into_owned(), + manifest, + }) +} + pub(crate) fn record_preview_state( root: &Path, status: GameCreationAppPreviewStatus, @@ -984,5 +1058,7 @@ pub(crate) fn trim_optional_string(value: Option) -> Option { .filter(|value| !value.is_empty()) } +#[cfg(test)] +mod import_tests; #[cfg(test)] mod recovery_tests; diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs new file mode 100644 index 000000000..367e46815 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/project/manifest/import_tests.rs @@ -0,0 +1,67 @@ +use super::*; + +fn godot_import_test_path(label: &str) -> PathBuf { + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("system clock") + .as_nanos(); + let temp_root = std::env::temp_dir() + .canonicalize() + .unwrap_or_else(|_| std::env::temp_dir()); + temp_root.join(format!( + "genarrative-godot-import-{label}-{}-{nanos}", + std::process::id() + )) +} + +#[test] +fn imports_godot_project_without_creating_parallel_game_directories() { + let root = godot_import_test_path("valid"); + fs::create_dir_all(&root).expect("create Godot project root"); + fs::write( + root.join("project.godot"), + "[application]\nconfig/name=\"Existing\"\n", + ) + .expect("write project.godot"); + fs::create_dir(root.join("scenes")).expect("create existing scenes"); + fs::write(root.join("scenes/main.tscn"), "[gd_scene format=3]\n") + .expect("write existing scene"); + + let result = import_local_godot_project_at(&root, "godot-project", "Existing") + .expect("import Godot project"); + + assert_eq!(result.project_path, root.to_string_lossy()); + assert_eq!(result.manifest.project_id, "godot-project"); + assert!(root.join(".agent/manifest.json").is_file()); + assert!(root.join(".agent/agent.db").is_file()); + assert_eq!( + fs::read_to_string(root.join("scenes/main.tscn")).expect("read preserved scene"), + "[gd_scene format=3]\n" + ); + for unexpected in ["game", "assets", "memory", "exports"] { + assert!( + !root.join(unexpected).exists(), + "Godot import must not create {unexpected}" + ); + } + + let reopened = import_local_godot_project_at(&root, "ignored", "Ignored") + .expect("reopen imported Godot project"); + assert_eq!(reopened.manifest.project_id, "godot-project"); + assert_eq!(reopened.manifest.name, "Existing"); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn rejects_non_godot_directory_without_writing_agent_metadata() { + let root = godot_import_test_path("invalid"); + fs::create_dir_all(&root).expect("create non-Godot project root"); + fs::write(root.join("README.md"), "not Godot").expect("write fixture"); + + let error = import_local_godot_project_at(&root, "not-godot", "Not Godot") + .expect_err("non-Godot directory must fail"); + + assert!(error.contains("project.godot")); + assert!(!root.join(".agent").exists()); + fs::remove_dir_all(root).ok(); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index b3413364a..bed788c5a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -1302,6 +1302,7 @@ fn project_directory_status_distinguishes_missing_file_and_dir() { exists: false, is_directory: false, is_game_creator_project: false, + is_godot_project: false, project_name: None, manifest_error: None, recent_run_status: None, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 266eb823e..bba00b47c 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -77,6 +77,7 @@ import type { LocalProjectFileMutationResult, LocalProjectFileResult, LocalProjectIndexResult, + LocalProjectKind, LocalProjectRestoreResult, MemoryScope, MemoryWriteMode, @@ -565,6 +566,7 @@ export function GameChatReleaseApp({ type AppProps = { initialProjectPath?: string; initialProjectManifest?: GameCreationAppManifest; + initialProjectKind?: LocalProjectKind; projectSupervisorOnly?: boolean; supervisorChatOnly?: boolean; gameChatOnly?: boolean; @@ -583,6 +585,7 @@ type AppProps = { export function App({ initialProjectPath: initialProjectPathOverride = '', initialProjectManifest, + initialProjectKind = 'web', projectSupervisorOnly = false, supervisorChatOnly = false, gameChatOnly = false, @@ -603,6 +606,8 @@ export function App({ const [projectPath, setProjectPath] = useState( initialProjectPath || defaultProjectPath, ); + const [workspaceProjectKind, setWorkspaceProjectKind] = + useState(initialProjectKind); const [localProject, setLocalProject] = useState(() => eagerSupervisorProject @@ -1326,7 +1331,12 @@ export function App({ }); return; } - void openWorkspace(initialProjectPath, false); + void openWorkspace( + initialProjectPath, + false, + initialProjectKind === 'godot' ? 'open' : 'create', + initialProjectKind, + ); // Initial project opening is guarded by initialProjectOpenedRef. // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameChatOnly, initialProjectPath, projectSupervisorOnly]); @@ -3060,6 +3070,8 @@ export function App({ async function openWorkspace( nextProjectPath: string, announceToChat: boolean, + mode: 'create' | 'open' = 'create', + projectKind: LocalProjectKind = 'web', ) { const trimmedProjectPath = nextProjectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { @@ -3114,16 +3126,23 @@ export function App({ agentConversationSavingRef.current = false; agentConversationLoadVersionRef.current += 1; try { - const result = await invoke( - 'init_local_game_project', - { - projectPath: trimmedProjectPath, - projectId: seedManifest.projectId, - name: gameChatOnly - ? projectNameFromPath(trimmedProjectPath) - : seedManifest.name, - }, - ); + const result = + mode === 'open' + ? { + projectPath: trimmedProjectPath, + manifestPath: `${trimmedProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`, + manifest: await invoke( + 'get_local_game_manifest', + { projectPath: trimmedProjectPath }, + ), + } + : await invoke('init_local_game_project', { + projectPath: trimmedProjectPath, + projectId: seedManifest.projectId, + name: gameChatOnly + ? projectNameFromPath(trimmedProjectPath) + : seedManifest.name, + }); if (projectScopeVersionRef.current !== projectScopeVersion) { return; } @@ -3155,6 +3174,7 @@ export function App({ } localProjectPathRef.current = openedProject.projectPath; setProjectPath(openedProject.projectPath); + setWorkspaceProjectKind(projectKind); setLocalProject(openedProject); if (gameChatOnly) { setChatInput(readSupervisorChatDraft(openedProject.projectPath)); @@ -3171,11 +3191,11 @@ export function App({ savedConversationCountRef.current = conversationMessages.length; writeRecentWorkspace(openedProject.projectPath); setWorkspaceStatus(`已打开:${openedProject.projectPath}`); - setProjectStatus('已初始化'); + setProjectStatus(mode === 'open' ? '已打开' : '已初始化'); appendLocalPermissionLog( openedProject.projectPath, 'permission.confirm', - 'project.create', + mode === 'open' ? 'project.status' : 'project.create', ); if (announceToChat) { setMessages((current) => [ @@ -3241,7 +3261,20 @@ export function App({ return; } if (directoryStatus.isGameCreatorProject) { - await openWorkspace(trimmedProjectPath, false); + if (directoryStatus.isGodotProject) { + await openWorkspace(trimmedProjectPath, false, 'open', 'godot'); + } else { + await openWorkspace(trimmedProjectPath, false); + } + return; + } + if (directoryStatus.isGodotProject) { + await invoke('import_local_godot_project', { + projectPath: trimmedProjectPath, + projectId: seedManifest.projectId, + name: projectNameFromPath(trimmedProjectPath), + }); + await openWorkspace(trimmedProjectPath, false, 'open', 'godot'); return; } await executeProjectCreate(trimmedProjectPath, false, selectionVersion); @@ -5747,7 +5780,8 @@ export function App({ return; } const submissionRunProfile = - supervisorChatOnly && !gameChatOnly + workspaceProjectKind === 'godot' || + (supervisorChatOnly && !gameChatOnly) ? 'standard' : 'autonomous-game-build'; const runtimeAtSubmission = projectSupervisorRuntimeRef.current; diff --git a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx index abdbae613..74db6f576 100644 --- a/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx +++ b/apps/ai-game-creator-shell/src/app/AuthenticatedClient.tsx @@ -1,4 +1,11 @@ -import { type FormEvent, type ReactNode, useEffect, useState } from 'react'; +import { + Component, + type ErrorInfo, + type FormEvent, + type ReactNode, + useEffect, + useState, +} from 'react'; import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import { @@ -15,6 +22,55 @@ import { sendClientPhoneLoginCode, } from '../services/clientAuth'; +type ClientRuntimeErrorBoundaryProps = { + children: ReactNode; + onLogout: () => void; +}; + +type ClientRuntimeErrorBoundaryState = { + errorMessage: string; +}; + +export class ClientRuntimeErrorBoundary extends Component< + ClientRuntimeErrorBoundaryProps, + ClientRuntimeErrorBoundaryState +> { + state: ClientRuntimeErrorBoundaryState = { errorMessage: '' }; + + static getDerivedStateFromError(error: unknown) { + return { + errorMessage: + error instanceof Error && error.message.trim() + ? error.message + : '登录后页面发生未知错误', + }; + } + + componentDidCatch(error: unknown, errorInfo: ErrorInfo) { + console.error('AGC authenticated client render failed', error, errorInfo); + } + + render() { + if (!this.state.errorMessage) { + return this.props.children; + } + return ( +
+
+ tn +
+

客户端页面加载失败

+

{this.state.errorMessage}

+
+ +
+
+ ); + } +} + export function AuthenticatedClient({ children, }: { @@ -270,5 +326,9 @@ export function AuthenticatedClient({ ); } - return <>{children({ user: authUser, logout })}; + return ( + + {children({ user: authUser, logout })} + + ); } diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 7a1a8887b..37b3e92a1 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -50,9 +50,12 @@ export type LauncherImportedAttachment = { error?: string; }; +export type LocalProjectKind = 'web' | 'godot'; + export type LauncherProjectContext = { projectPath: string; projectName: string; + projectKind: LocalProjectKind; manifest: GameCreationAppManifest; mode: HomeAgentMode | null; initialPrompt: string; @@ -91,6 +94,7 @@ export interface LocalProjectDirectoryStatus { exists: boolean; isDirectory: boolean; isGameCreatorProject: boolean; + isGodotProject: boolean; projectName: string | null; manifestError?: string | null; recentRunStatus: string | null; diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx index 85b68ba94..42baedf55 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ProjectCreation.tsx @@ -63,6 +63,12 @@ export function ProjectsPage({ > 选择 +