融合最新主线并同步项目导入能力

合并 master 的现有 Godot 项目导入与客户端 HTTP 调整。

保留无限画布和非破坏性资源编辑分支行为。

# Conflicts:
#	apps/ai-game-creator-shell/src-tauri/Cargo.lock
#	docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md
#	docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md
This commit is contained in:
2026-08-11 14:39:47 +08:00
31 changed files with 933 additions and 79 deletions
+10
View File
@@ -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",
+1
View File
@@ -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
View File
@@ -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"
@@ -1617,6 +1670,7 @@ dependencies = [
"tauri-build",
"tauri-plugin-clipboard-manager",
"tauri-plugin-dialog",
"tauri-plugin-http",
"tauri-plugin-opener",
"tempfile",
"tokio",
@@ -1836,6 +1890,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"
@@ -1956,6 +2029,7 @@ dependencies = [
"bytes",
"futures-channel",
"futures-core",
"h2",
"http",
"http-body",
"httparse",
@@ -1980,6 +2054,7 @@ dependencies = [
"tokio",
"tokio-rustls",
"tower-service",
"webpki-roots",
]
[[package]]
@@ -2016,9 +2091,11 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"socket2",
"system-configuration",
"tokio",
"tower-service",
"tracing",
"windows-registry",
]
[[package]]
@@ -2502,6 +2579,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"
@@ -3758,6 +3841,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"
@@ -4011,8 +4110,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",
@@ -4022,6 +4125,7 @@ dependencies = [
"hyper-util",
"js-sys",
"log",
"mime",
"mime_guess",
"native-tls",
"percent-encoding",
@@ -4046,6 +4150,7 @@ dependencies = [
"wasm-bindgen-futures",
"wasm-streams 0.4.2",
"web-sys",
"webpki-roots",
]
[[package]]
@@ -4316,7 +4421,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",
@@ -4872,6 +4977,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"
@@ -4893,7 +5019,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
dependencies = [
"bitflags 2.13.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dbus",
@@ -5128,6 +5254,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"
@@ -6207,6 +6357,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"
@@ -42,6 +42,7 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "mul
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,
+49 -15
View File
@@ -77,6 +77,7 @@ import type {
LocalProjectFileMutationResult,
LocalProjectFileResult,
LocalProjectIndexResult,
LocalProjectKind,
LocalProjectRestoreResult,
MemoryScope,
MemoryWriteMode,
@@ -566,6 +567,7 @@ export function GameChatReleaseApp({
type AppProps = {
initialProjectPath?: string;
initialProjectManifest?: GameCreationAppManifest;
initialProjectKind?: LocalProjectKind;
projectSupervisorOnly?: boolean;
supervisorChatOnly?: boolean;
gameChatOnly?: boolean;
@@ -585,6 +587,7 @@ type AppProps = {
export function App({
initialProjectPath: initialProjectPathOverride = '',
initialProjectManifest,
initialProjectKind = 'web',
projectSupervisorOnly = false,
supervisorChatOnly = false,
gameChatOnly = false,
@@ -605,6 +608,8 @@ export function App({
const [projectPath, setProjectPath] = useState(
initialProjectPath || defaultProjectPath,
);
const [workspaceProjectKind, setWorkspaceProjectKind] =
useState<LocalProjectKind>(initialProjectKind);
const [localProject, setLocalProject] =
useState<InitLocalProjectResult | null>(() =>
eagerSupervisorProject
@@ -1328,7 +1333,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]);
@@ -3062,6 +3072,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)) {
@@ -3116,16 +3128,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;
}
@@ -3157,6 +3176,7 @@ export function App({
}
localProjectPathRef.current = openedProject.projectPath;
setProjectPath(openedProject.projectPath);
setWorkspaceProjectKind(projectKind);
setLocalProject(openedProject);
if (gameChatOnly) {
setChatInput(readSupervisorChatDraft(openedProject.projectPath));
@@ -3173,11 +3193,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) => [
@@ -3243,7 +3263,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);
@@ -5749,7 +5782,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;
projectRevision: number | null;
mode: HomeAgentMode | null;
@@ -92,6 +95,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"
@@ -264,6 +264,7 @@ export function WorkspaceLauncherShell({
setProjectPath(path);
void openProject(path, 'open');
}}
onGodotProjectOpen={() => void homeProject.openGodotProject()}
/>
) : launcherView === 'projects' ? (
<ProjectsPage
@@ -295,6 +296,7 @@ export function WorkspaceLauncherShell({
key={currentProjectContext.projectPath}
initialProjectPath={currentProjectContext.projectPath}
initialProjectManifest={currentProjectContext.manifest}
initialProjectKind={currentProjectContext.projectKind}
projectSupervisorOnly
onManifestChange={syncActiveProjectManifest}
onPreviewChange={setActiveProjectPreview}
@@ -34,6 +34,7 @@ export type WorkspaceLauncherProps = {
export type ProjectSupervisorComponentProps = {
initialProjectPath?: string;
initialProjectManifest?: GameCreationAppManifest;
initialProjectKind?: 'web' | 'godot';
projectSupervisorOnly?: boolean;
onManifestChange?: (
projectPath: string,
@@ -229,6 +229,7 @@ export function useHomeProjectCreation({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
@@ -296,6 +297,7 @@ export function useHomeProjectCreation({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
@@ -360,6 +362,7 @@ export function useHomeProjectCreation({
projectName:
directoryStatus.projectName ||
projectNameFromPath(trimmedProjectPath),
projectKind: directoryStatus.isGodotProject ? 'godot' : 'web',
manifest: projectManifest,
projectRevision: await readCurrentProjectRevision(
invoke,
@@ -456,6 +459,74 @@ 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,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
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,
@@ -471,6 +542,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"
@@ -6889,14 +6889,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({
@@ -8581,6 +8584,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