支持 AI 游戏创作打开现有 Godot 项目
Project CI / Repository checks (push) Failing after 44s
Project CI / Native shell tests (push) Failing after 1m42s
Project CI / Frontend tests (push) Successful in 2m50s
Project CI / Backend tests (push) Successful in 3m41s

新增 Godot 项目选择、校验、导入和项目上下文切换入口。

只在所选项目根保留 .agent 元数据,并让总控使用标准运行档。

修复发布版原生 API 访问和 React runtime 重复加载问题。

收紧 Provider 工具 Schema 子集并增加回归门禁。

补充 Rust、前端测试及 PRD、技术方案和共享决策记录。
This commit is contained in:
kdletters
2026-08-11 13:25:02 +08:00
parent 4c0fdf1806
commit 755bf80858
32 changed files with 932 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"
@@ -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,
+49 -15
View File
@@ -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