支持 AI 游戏创作打开现有 Godot 项目
新增 Godot 项目选择、校验、导入和项目上下文切换入口。 只在所选项目根保留 .agent 元数据,并让总控使用标准运行档。 修复发布版原生 API 访问和 React runtime 重复加载问题。 收紧 Provider 工具 Schema 子集并增加回归门禁。 补充 Rust、前端测试及 PRD、技术方案和共享决策记录。
This commit is contained in:
+10
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
+163
-4
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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<InitLocalProjectResult, String> {
|
||||
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<bool, String> {
|
||||
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;
|
||||
|
||||
@@ -124,6 +124,7 @@ struct LocalProjectDirectoryStatus {
|
||||
exists: bool,
|
||||
is_directory: bool,
|
||||
is_game_creator_project: bool,
|
||||
is_godot_project: bool,
|
||||
project_name: Option<String>,
|
||||
manifest_error: Option<String>,
|
||||
recent_run_status: Option<String>,
|
||||
@@ -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,
|
||||
|
||||
@@ -138,7 +138,12 @@ fn try_open_manifest_write_lock_file(path: &Path) -> Result<Option<File>, 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<InitLocalProjectResult, String> {
|
||||
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<String>) -> Option<String> {
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod import_tests;
|
||||
#[cfg(test)]
|
||||
mod recovery_tests;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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<LocalProjectKind>(initialProjectKind);
|
||||
const [localProject, setLocalProject] =
|
||||
useState<InitLocalProjectResult | null>(() =>
|
||||
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<InitLocalProjectResult>(
|
||||
'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<GameCreationAppManifest>(
|
||||
'get_local_game_manifest',
|
||||
{ projectPath: trimmedProjectPath },
|
||||
),
|
||||
}
|
||||
: await invoke<InitLocalProjectResult>('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<InitLocalProjectResult>('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;
|
||||
|
||||
@@ -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 (
|
||||
<main className="client-auth-shell" aria-label="客户端页面加载失败">
|
||||
<section className="client-auth-panel">
|
||||
<span className="client-auth-logo">tn</span>
|
||||
<div>
|
||||
<h1>客户端页面加载失败</h1>
|
||||
<p className="client-auth-status">{this.state.errorMessage}</p>
|
||||
</div>
|
||||
<button type="button" onClick={this.props.onLogout}>
|
||||
返回登录
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthenticatedClient({
|
||||
children,
|
||||
}: {
|
||||
@@ -270,5 +326,9 @@ export function AuthenticatedClient({
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children({ user: authUser, logout })}</>;
|
||||
return (
|
||||
<ClientRuntimeErrorBoundary onLogout={logout}>
|
||||
{children({ user: authUser, logout })}
|
||||
</ClientRuntimeErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -63,6 +63,12 @@ export function ProjectsPage({
|
||||
>
|
||||
选择
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void homeProject.openGodotProject()}
|
||||
>
|
||||
打开 Godot 项目
|
||||
</button>
|
||||
<button type="submit">打开</button>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -151,6 +151,7 @@ export function WorkspaceLauncherShell({
|
||||
setProjectPath(path);
|
||||
void openProject(path, 'open');
|
||||
}}
|
||||
onGodotProjectOpen={() => void homeProject.openGodotProject()}
|
||||
/>
|
||||
) : launcherView === 'projects' ? (
|
||||
<ProjectsPage
|
||||
@@ -181,6 +182,7 @@ export function WorkspaceLauncherShell({
|
||||
key={currentProjectContext.projectPath}
|
||||
initialProjectPath={currentProjectContext.projectPath}
|
||||
initialProjectManifest={currentProjectContext.manifest}
|
||||
initialProjectKind={currentProjectContext.projectKind}
|
||||
projectSupervisorOnly
|
||||
onManifestChange={syncActiveProjectManifest}
|
||||
onPreviewChange={setActiveProjectPreview}
|
||||
|
||||
@@ -33,6 +33,7 @@ export type WorkspaceLauncherProps = {
|
||||
export type ProjectSupervisorComponentProps = {
|
||||
initialProjectPath?: string;
|
||||
initialProjectManifest?: GameCreationAppManifest;
|
||||
initialProjectKind?: 'web' | 'godot';
|
||||
projectSupervisorOnly?: boolean;
|
||||
onManifestChange?: (
|
||||
projectPath: string,
|
||||
|
||||
@@ -211,6 +211,7 @@ export function useHomeProjectCreation({
|
||||
projectPath: result.projectPath,
|
||||
projectName:
|
||||
result.manifest.name || projectNameFromPath(result.projectPath),
|
||||
projectKind: 'web',
|
||||
manifest: result.manifest,
|
||||
mode,
|
||||
initialPrompt:
|
||||
@@ -274,6 +275,7 @@ export function useHomeProjectCreation({
|
||||
projectPath: result.projectPath,
|
||||
projectName:
|
||||
result.manifest.name || projectNameFromPath(result.projectPath),
|
||||
projectKind: 'web',
|
||||
manifest: result.manifest,
|
||||
mode: null,
|
||||
initialPrompt: '',
|
||||
@@ -334,6 +336,7 @@ export function useHomeProjectCreation({
|
||||
projectName:
|
||||
directoryStatus.projectName ||
|
||||
projectNameFromPath(trimmedProjectPath),
|
||||
projectKind: directoryStatus.isGodotProject ? 'godot' : 'web',
|
||||
manifest: projectManifest,
|
||||
mode: null,
|
||||
initialPrompt: '',
|
||||
@@ -426,6 +429,70 @@ export function useHomeProjectCreation({
|
||||
}
|
||||
}
|
||||
|
||||
async function openGodotProject() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setStatus('需要在 Tauri App 内运行');
|
||||
return;
|
||||
}
|
||||
setStatus('正在选择 Godot 项目');
|
||||
try {
|
||||
const selectedPath = await invoke<string | null>(
|
||||
'pick_local_project_directory',
|
||||
);
|
||||
if (!selectedPath) {
|
||||
setStatus('已取消');
|
||||
return;
|
||||
}
|
||||
const trimmedProjectPath = validateProjectPath(selectedPath);
|
||||
if (!trimmedProjectPath) {
|
||||
return;
|
||||
}
|
||||
const directoryStatus = await invoke<LocalProjectDirectoryStatus>(
|
||||
'inspect_local_project_directory',
|
||||
{ projectPath: trimmedProjectPath },
|
||||
);
|
||||
if (!directoryStatus.exists || !directoryStatus.isDirectory) {
|
||||
setStatus('Godot 项目目录不存在或不是文件夹');
|
||||
return;
|
||||
}
|
||||
if (!directoryStatus.isGodotProject) {
|
||||
setStatus('所选文件夹不是 Godot 项目');
|
||||
return;
|
||||
}
|
||||
const result = directoryStatus.isGameCreatorProject
|
||||
? {
|
||||
projectPath: trimmedProjectPath,
|
||||
manifestPath: `${trimmedProjectPath.replace(/[\\/]+$/, '')}/.agent/manifest.json`,
|
||||
manifest: await invoke<GameCreationAppManifest>(
|
||||
'get_local_game_manifest',
|
||||
{ projectPath: trimmedProjectPath },
|
||||
),
|
||||
}
|
||||
: await invoke<InitLocalProjectResult>('import_local_godot_project', {
|
||||
projectPath: trimmedProjectPath,
|
||||
projectId: seedManifest.projectId,
|
||||
name: projectNameFromPath(trimmedProjectPath),
|
||||
});
|
||||
setStatus('已打开 Godot 项目');
|
||||
enterProjectDevelopment({
|
||||
projectPath: result.projectPath,
|
||||
projectName:
|
||||
result.manifest.name || projectNameFromPath(result.projectPath),
|
||||
projectKind: 'godot',
|
||||
manifest: result.manifest,
|
||||
mode: null,
|
||||
initialPrompt: '',
|
||||
attachments: [],
|
||||
recentRunStatus: directoryStatus.recentRunStatus,
|
||||
recentRunStopReason: directoryStatus.recentRunStopReason,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectPath,
|
||||
setProjectPath,
|
||||
@@ -441,6 +508,7 @@ export function useHomeProjectCreation({
|
||||
resetLauncherHomeDraft,
|
||||
createHomeDraft,
|
||||
openProject,
|
||||
openGodotProject,
|
||||
handleSubmit,
|
||||
handlePickProjectDirectory,
|
||||
confirmCreateInNonEmptyFolder,
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
ProfileWalletLedgerResponse,
|
||||
unwrapApiResponse,
|
||||
} from '../../../../packages/shared/src';
|
||||
import { fetchClientHttp } from './clientHttp';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
const DEFAULT_CLIENT_AUTH_API_BASE_URL = 'http://127.0.0.1:8082';
|
||||
|
||||
export type EditorShowcaseResource = {
|
||||
resourceId: string;
|
||||
@@ -63,22 +63,6 @@ export function clearStoredAuthAccessToken() {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function resolveClientApiUrl(url: string) {
|
||||
if (/^https?:\/\//iu.test(url)) {
|
||||
return url;
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
return url;
|
||||
}
|
||||
const isHttpPage =
|
||||
window.location.protocol === 'http:' ||
|
||||
window.location.protocol === 'https:';
|
||||
if (!window.__TAURI__ && isHttpPage) {
|
||||
return url;
|
||||
}
|
||||
return `${DEFAULT_CLIENT_AUTH_API_BASE_URL}${url}`;
|
||||
}
|
||||
|
||||
async function readApiErrorMessage(response: Response, fallback: string) {
|
||||
const text = await response.text();
|
||||
if (!text.trim()) {
|
||||
@@ -108,7 +92,7 @@ export async function requestClientApi<T>(
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(resolveClientApiUrl(url), {
|
||||
response = await fetchClientHttp(url, {
|
||||
...init,
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
|
||||
@@ -15,9 +15,9 @@ import {
|
||||
API_RESPONSE_ENVELOPE_VERSION,
|
||||
unwrapApiResponse,
|
||||
} from '../../../../packages/shared/src/http';
|
||||
import { fetchClientHttp } from './clientHttp';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
const DEFAULT_CLIENT_AUTH_API_BASE_URL = 'http://127.0.0.1:8082';
|
||||
|
||||
export function normalizeAuthPhoneInput(phone: string) {
|
||||
const compactPhone = phone.replace(/[^\d+]/gu, '').trim();
|
||||
@@ -50,22 +50,6 @@ export function clearStoredAuthAccessToken() {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
function resolveClientAuthApiUrl(url: string) {
|
||||
if (/^https?:\/\//iu.test(url)) {
|
||||
return url;
|
||||
}
|
||||
if (import.meta.env.DEV) {
|
||||
return url;
|
||||
}
|
||||
const isHttpPage =
|
||||
window.location.protocol === 'http:' ||
|
||||
window.location.protocol === 'https:';
|
||||
if (!window.__TAURI__ && isHttpPage) {
|
||||
return url;
|
||||
}
|
||||
return `${DEFAULT_CLIENT_AUTH_API_BASE_URL}${url}`;
|
||||
}
|
||||
|
||||
let clientAuthRefreshPromise: Promise<string> | null = null;
|
||||
|
||||
class ClientAuthRequestError extends Error {
|
||||
@@ -133,7 +117,7 @@ async function requestAuthJson<T>(
|
||||
}
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(resolveClientAuthApiUrl(url), {
|
||||
response = await fetchClientHttp(url, {
|
||||
...init,
|
||||
credentials: 'same-origin',
|
||||
headers,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
|
||||
|
||||
type ClientHttpContext = {
|
||||
isDevelopment: boolean;
|
||||
isTauri: boolean;
|
||||
pageProtocol: string;
|
||||
};
|
||||
|
||||
type ClientHttpTarget = {
|
||||
transport: 'web' | 'tauri-http';
|
||||
url: string;
|
||||
};
|
||||
|
||||
function currentClientHttpContext(): ClientHttpContext {
|
||||
return {
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__),
|
||||
pageProtocol: typeof window === 'undefined' ? '' : window.location.protocol,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveClientHttpTarget(
|
||||
url: string,
|
||||
context: ClientHttpContext = currentClientHttpContext(),
|
||||
): ClientHttpTarget {
|
||||
if (context.isDevelopment) {
|
||||
return { transport: 'web', url };
|
||||
}
|
||||
|
||||
const isHttpPage =
|
||||
context.pageProtocol === 'http:' || context.pageProtocol === 'https:';
|
||||
if (!context.isTauri && isHttpPage) {
|
||||
return { transport: 'web', url };
|
||||
}
|
||||
|
||||
if (!context.isTauri) {
|
||||
return { transport: 'web', url };
|
||||
}
|
||||
|
||||
const target = new URL(url, `${AGC_DEVELOPMENT_API_BASE_URL}/`);
|
||||
if (target.origin !== AGC_DEVELOPMENT_API_BASE_URL) {
|
||||
throw new Error('AGC release API 请求目标不在允许的开发服务器范围内');
|
||||
}
|
||||
return { transport: 'tauri-http', url: target.toString() };
|
||||
}
|
||||
|
||||
export async function fetchClientHttp(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
): Promise<Response> {
|
||||
const target = resolveClientHttpTarget(url);
|
||||
if (target.transport === 'tauri-http') {
|
||||
return tauriHttpFetch(target.url, init);
|
||||
}
|
||||
return fetch(target.url, init);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
FolderKanban,
|
||||
FolderOpen,
|
||||
type LucideIcon,
|
||||
Plus,
|
||||
Sparkles,
|
||||
@@ -51,6 +52,7 @@ type HomeViewProps = {
|
||||
onCreateDraft: (draft: HomeDraft) => Promise<string>;
|
||||
onProjectsOpen: () => void;
|
||||
onProjectOpen: (path: string) => void;
|
||||
onGodotProjectOpen: () => void;
|
||||
};
|
||||
|
||||
export default function HomeView({
|
||||
@@ -62,6 +64,7 @@ export default function HomeView({
|
||||
onCreateDraft,
|
||||
onProjectsOpen,
|
||||
onProjectOpen,
|
||||
onGodotProjectOpen,
|
||||
}: HomeViewProps) {
|
||||
const homeAgentMode = useLauncherHomeDraftStore((state) => state.mode);
|
||||
const homeRichText = useLauncherHomeDraftStore((state) => state.draft);
|
||||
@@ -194,6 +197,14 @@ export default function HomeView({
|
||||
</p>
|
||||
</div>
|
||||
<div className="launcher-project-list-actions">
|
||||
<button
|
||||
className="inline-flex cursor-pointer items-center gap-1.5 border-0 bg-transparent p-0 text-[12px] text-(--platform-warm-text)"
|
||||
type="button"
|
||||
onClick={onGodotProjectOpen}
|
||||
>
|
||||
<FolderOpen size={14} aria-hidden="true" />
|
||||
打开 Godot 项目
|
||||
</button>
|
||||
<button
|
||||
className="cursor-pointer border-0 bg-transparent p-0 text-[12px] text-(--platform-warm-text)"
|
||||
type="button"
|
||||
|
||||
@@ -6880,14 +6880,17 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
|
||||
await driver.submit('生成后验证 revision 漂移恢复');
|
||||
await driver.emitValidation(1, 1000);
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
driver.invoke.mock.calls.filter(
|
||||
([command]) => command === 'start_local_game_preview',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(driver.readAuthorization()).not.toBeNull();
|
||||
});
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(
|
||||
driver.invoke.mock.calls.filter(
|
||||
([command]) => command === 'start_local_game_preview',
|
||||
),
|
||||
).toHaveLength(1);
|
||||
expect(driver.readAuthorization()).not.toBeNull();
|
||||
},
|
||||
{ timeout: 3000 },
|
||||
);
|
||||
await act(async () => {
|
||||
driver.harness.emitAgentRuntime(
|
||||
gameChatPreviewPlaytestRuntime({
|
||||
@@ -8572,6 +8575,78 @@ export function registerProjectSupervisorSurfaceTests() {
|
||||
expect(screen.getByText('项目目录不能包含控制字符')).not.toBeNull();
|
||||
expect(invoke).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens a selected Godot project as the active root without initializing the web layout', async () => {
|
||||
const projectPath = '/tmp/existing-godot-project';
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'local-project-draft',
|
||||
'existing-godot-project',
|
||||
);
|
||||
const supervisorHarness = createProjectSupervisorRuntimeHarness({
|
||||
projectPath,
|
||||
expectedRunProfile: 'standard',
|
||||
});
|
||||
const invoke = vi.fn(
|
||||
async (command: string, args?: Record<string, unknown>) => {
|
||||
if (command === 'pick_local_project_directory') {
|
||||
return projectPath;
|
||||
}
|
||||
if (command === 'inspect_local_project_directory') {
|
||||
return {
|
||||
projectPath,
|
||||
exists: true,
|
||||
isDirectory: true,
|
||||
isGameCreatorProject: false,
|
||||
isGodotProject: true,
|
||||
projectName: null,
|
||||
recentRunStatus: null,
|
||||
recentRunStopReason: null,
|
||||
};
|
||||
}
|
||||
if (command === 'import_local_godot_project') {
|
||||
return {
|
||||
projectPath,
|
||||
manifestPath: `${projectPath}/.agent/manifest.json`,
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
return supervisorHarness.invoke(command, args);
|
||||
},
|
||||
);
|
||||
window.__TAURI__ = {
|
||||
core: { invoke },
|
||||
event: { listen: supervisorHarness.listen },
|
||||
};
|
||||
renderLauncherProjectsAt('/?launcher');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开 Godot 项目' }));
|
||||
|
||||
const surface = await screen.findByLabelText('项目总控对话');
|
||||
expect(invoke).toHaveBeenCalledWith('import_local_godot_project', {
|
||||
projectPath,
|
||||
projectId: 'local-project-draft',
|
||||
name: 'existing-godot-project',
|
||||
});
|
||||
expect(
|
||||
invoke.mock.calls.some(
|
||||
([command]) => command === 'init_local_game_project',
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
const composer = within(surface).getByLabelText('项目需求');
|
||||
fireEvent.change(composer, { target: { value: '修改玩家移动脚本' } });
|
||||
fireEvent.click(within(surface).getByRole('button', { name: '发送' }));
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith(
|
||||
'start_game_creator_supervisor_runtime_task',
|
||||
expect.objectContaining({
|
||||
projectPath,
|
||||
runProfile: 'standard',
|
||||
task: '修改玩家移动脚本',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function registerProjectWorkbenchNavigationTests() {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
AGC_DEVELOPMENT_API_BASE_URL,
|
||||
resolveClientHttpTarget,
|
||||
} from '../src/services/clientHttp';
|
||||
|
||||
describe('AGC client HTTP transport', () => {
|
||||
it('keeps local development requests on the Vite API proxy', () => {
|
||||
expect(
|
||||
resolveClientHttpTarget('/api/auth/me', {
|
||||
isDevelopment: true,
|
||||
isTauri: true,
|
||||
pageProtocol: 'http:',
|
||||
}),
|
||||
).toEqual({ transport: 'web', url: '/api/auth/me' });
|
||||
});
|
||||
|
||||
it('routes release Tauri requests through the scoped dev API transport', () => {
|
||||
expect(
|
||||
resolveClientHttpTarget('/api/auth/me', {
|
||||
isDevelopment: false,
|
||||
isTauri: true,
|
||||
pageProtocol: 'tauri:',
|
||||
}),
|
||||
).toEqual({
|
||||
transport: 'tauri-http',
|
||||
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps ordinary web releases on same-origin relative requests', () => {
|
||||
expect(
|
||||
resolveClientHttpTarget('/api/auth/me', {
|
||||
isDevelopment: false,
|
||||
isTauri: false,
|
||||
pageProtocol: 'https:',
|
||||
}),
|
||||
).toEqual({ transport: 'web', url: '/api/auth/me' });
|
||||
});
|
||||
|
||||
it('rejects release Tauri requests outside the fixed dev API origin', () => {
|
||||
expect(() =>
|
||||
resolveClientHttpTarget('https://example.com/api/auth/me', {
|
||||
isDevelopment: false,
|
||||
isTauri: true,
|
||||
pageProtocol: 'tauri:',
|
||||
}),
|
||||
).toThrow('不在允许的开发服务器范围内');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
/** @vitest-environment jsdom */
|
||||
|
||||
import { createElement } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { ClientRuntimeErrorBoundary } from '../src/app/AuthenticatedClient';
|
||||
|
||||
function BrokenAuthenticatedContent() {
|
||||
throw new Error('首页渲染失败');
|
||||
}
|
||||
|
||||
describe('AGC authenticated client error boundary', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('replaces a render crash with a recoverable login action', () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
||||
const onLogout = vi.fn();
|
||||
|
||||
render(
|
||||
createElement(
|
||||
ClientRuntimeErrorBoundary,
|
||||
{ onLogout },
|
||||
createElement(BrokenAuthenticatedContent),
|
||||
),
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('main', { name: '客户端页面加载失败' }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText('首页渲染失败')).toBeTruthy();
|
||||
fireEvent.click(screen.getByRole('button', { name: '返回登录' }));
|
||||
expect(onLogout).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user