diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index ec67dde0b..cf220b3eb 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -61,6 +61,10 @@ const tauriHandlerSource = fs.readFileSync( new URL('../src-tauri/src/main.rs', import.meta.url), 'utf8', ); +const tauriWindowSource = fs.readFileSync( + new URL('../src-tauri/src/windows.rs', import.meta.url), + 'utf8', +); const tauriRustSource = readSourceTree( new URL('../src-tauri/src/', import.meta.url), '.rs', @@ -1189,6 +1193,95 @@ if ( ); } +const gameChatInitialUrlApply = + 'apply_game_chat_initial_window_url(tauri_context.config_mut(), options)'; +const gameChatInitialUrlApplyIndexes = Array.from( + tauriHandlerSource.matchAll( + /apply_game_chat_initial_window_url\(tauri_context\.config_mut\(\), options\)/gu, + ), + (match) => match.index, +); +const tauriContextIndex = tauriHandlerSource.indexOf( + 'let mut tauri_context = tauri::generate_context!()', +); +const tauriBuilderIndex = tauriHandlerSource.indexOf( + 'tauri::Builder::default()', +); +if ( + gameChatInitialUrlApplyIndexes.length !== 1 || + tauriContextIndex === -1 || + tauriBuilderIndex === -1 || + gameChatInitialUrlApplyIndexes[0] < tauriContextIndex || + gameChatInitialUrlApplyIndexes[0] > tauriBuilderIndex +) { + throw new Error( + `AI game creator game-chat URL must be applied exactly once between Context creation and Tauri Builder creation: ${gameChatInitialUrlApply}`, + ); +} + +const tauriSetupStartIndex = tauriHandlerSource.indexOf('.setup(move |app| {'); +const tauriSetupEndIndex = tauriHandlerSource.indexOf( + '.invoke_handler(', + tauriSetupStartIndex, +); +if (tauriSetupStartIndex === -1 || tauriSetupEndIndex === -1) { + throw new Error('AI game creator Tauri setup block is missing'); +} +const tauriSetupSource = tauriHandlerSource.slice( + tauriSetupStartIndex, + tauriSetupEndIndex, +); +for (const forbiddenSetupSnippet of [ + 'game_chat_launch.as_ref()', + 'navigate_client_to_game_chat', + '.navigate(', +]) { + if (tauriSetupSource.includes(forbiddenSetupSnippet)) { + throw new Error( + `AI game creator setup must not perform game-chat runtime navigation: ${forbiddenSetupSnippet}`, + ); + } +} + +for (const forbiddenSnippet of [ + 'navigate_client_to_game_chat', + 'client.url()', + 'client.navigate(', +]) { + if ( + `${tauriHandlerSource}\n${tauriWindowSource}`.includes(forbiddenSnippet) + ) { + throw new Error( + `AI game creator game-chat startup must not navigate an initialized client WebView: ${forbiddenSnippet}`, + ); + } +} + +if ( + /get_webview_window\s*\(\s*['"]client['"]\s*\)/u.test( + `${tauriHandlerSource}\n${tauriWindowSource}`, + ) +) { + throw new Error( + 'AI game creator game-chat startup must not look up the runtime client WebView', + ); +} + +for (const requiredSnippet of [ + 'fn apply_game_chat_initial_window_url(', + '.find(|window| window.label == "client")', + 'client.url = game_chat_window_url(', + '.run(tauri_context)', +]) { + if ( + !`${tauriHandlerSource}\n${tauriWindowSource}`.includes(requiredSnippet) + ) { + throw new Error( + `AI game creator game-chat initial WindowConfig guardrail drifted: ${requiredSnippet}`, + ); + } +} + if ( !tauriConfig.build?.beforeBuildCommand?.includes('--config vite.config.ts') ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 5b06bb10d..6eec140ad 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1689,6 +1689,16 @@ fn main() { } } + let mut tauri_context = tauri::generate_context!(); + #[cfg(debug_assertions)] + if let Some(options) = game_chat_launch.as_ref() { + if let Err(error) = apply_game_chat_initial_window_url(tauri_context.config_mut(), options) + { + eprintln!("{error}"); + std::process::exit(1); + } + } + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_dialog::init()) @@ -1716,9 +1726,7 @@ fn main() { })?; set_game_creator_agent_runtime_update_app_handle(app.handle().clone()); #[cfg(all(debug_assertions, not(test)))] - if let Some(options) = game_chat_launch.as_ref() { - navigate_client_to_game_chat(app.handle(), options)?; - } else { + if game_chat_launch.is_none() { open_developer_window(app.handle())?; } Ok(()) @@ -1805,7 +1813,7 @@ fn main() { get_local_game_preview_status, get_local_game_manifest ]) - .run(tauri::generate_context!()) + .run(tauri_context) .expect("failed to run Genarrative AI Game Creator shell"); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs index af06a337c..6b9d5a842 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/project.rs @@ -2744,6 +2744,10 @@ fn workspace_window_url_carries_encoded_project_path() { #[test] fn game_chat_launch_args_are_strict_and_keep_normal_start_compatible() { + let absolute_project_path = unique_project_path() + .join("AI Game 项目") + .to_string_lossy() + .into_owned(); assert_eq!( parse_game_chat_launch_args(&[]).expect("parse normal GUI start"), None @@ -2766,12 +2770,12 @@ fn game_chat_launch_args_are_strict_and_keep_normal_start_compatible() { parse_game_chat_launch_args(&[ "--game-chat".to_string(), "--project-path".to_string(), - " /tmp/AI Game 项目 ".to_string(), + format!(" {absolute_project_path} "), ]) .expect("parse game chat project") .expect("game chat project options"), GameChatLaunchOptions { - project_path: Some("/tmp/AI Game 项目".to_string()), + project_path: Some(absolute_project_path.clone()), initial_message: None, } ); @@ -2779,14 +2783,14 @@ fn game_chat_launch_args_are_strict_and_keep_normal_start_compatible() { parse_game_chat_launch_args(&[ "--game-chat".to_string(), "--project-path".to_string(), - "/tmp/game".to_string(), + absolute_project_path.clone(), "--initial-message".to_string(), "继续完成贪吃蛇".to_string(), ]) .expect("parse game chat initial message") .expect("game chat initial message options"), GameChatLaunchOptions { - project_path: Some("/tmp/game".to_string()), + project_path: Some(absolute_project_path), initial_message: Some("继续完成贪吃蛇".to_string()), } ); @@ -2845,6 +2849,68 @@ fn game_chat_window_url_encodes_optional_project_path() { ); } +#[test] +fn game_chat_initial_window_url_is_applied_before_tauri_creates_the_client() { + let mut context: tauri::Context = tauri::generate_context!(); + let mut secondary_window = context + .config() + .app + .windows + .first() + .expect("window config fixture") + .clone(); + secondary_window.label = "fixture-secondary".to_string(); + secondary_window.url = tauri::WebviewUrl::App(PathBuf::from("index.html?fixture-secondary")); + context + .config_mut() + .app + .windows + .push(secondary_window.clone()); + let mut expected_windows = context.config().app.windows.clone(); + let absolute_project_path = unique_project_path() + .join("AI Game 项目") + .to_string_lossy() + .into_owned(); + let options = parse_game_chat_launch_args(&[ + "--game-chat".to_string(), + "--project-path".to_string(), + absolute_project_path, + "--initial-message".to_string(), + "继续 & 验证".to_string(), + ]) + .expect("parse game-chat launch arguments") + .expect("game-chat launch options"); + let expected_url = game_chat_window_url( + options.project_path.as_deref(), + options.initial_message.as_deref(), + ); + expected_windows + .iter_mut() + .find(|window| window.label == "client") + .expect("expected client window config") + .url = expected_url; + + apply_game_chat_initial_window_url(context.config_mut(), &options) + .expect("apply game chat initial window URL"); + + assert_eq!( + context.config().app.windows, + expected_windows, + "game-chat launch must only rewrite the client initial URL" + ); + assert_eq!( + context + .config() + .app + .windows + .iter() + .find(|window| window.label == secondary_window.label) + .expect("secondary window config"), + &secondary_window, + "game-chat launch must leave other windows untouched" + ); +} + #[test] fn workspace_window_project_path_requires_absolute_path() { assert!(validate_workspace_window_project_path(" /tmp/game ").is_ok()); diff --git a/apps/ai-game-creator-shell/src-tauri/src/windows.rs b/apps/ai-game-creator-shell/src-tauri/src/windows.rs index 70428d311..b0135a60f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/windows.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/windows.rs @@ -78,7 +78,7 @@ pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUr ))) } -#[cfg(test)] +#[cfg(any(debug_assertions, test))] pub(crate) fn game_chat_window_url( project_path: Option<&str>, initial_message: Option<&str>, @@ -87,6 +87,24 @@ pub(crate) fn game_chat_window_url( tauri::WebviewUrl::App(PathBuf::from(format!("index.html?{query}"))) } +#[cfg(any(debug_assertions, test))] +pub(crate) fn apply_game_chat_initial_window_url( + config: &mut tauri::Config, + options: &GameChatLaunchOptions, +) -> Result<(), String> { + let client = config + .app + .windows + .iter_mut() + .find(|window| window.label == "client") + .ok_or_else(|| "找不到 AI 游戏创作客户端窗口配置".to_string())?; + client.url = game_chat_window_url( + options.project_path.as_deref(), + options.initial_message.as_deref(), + ); + Ok(()) +} + #[cfg(any(debug_assertions, test))] fn game_chat_window_query(project_path: Option<&str>, initial_message: Option<&str>) -> String { let mut query = "game-chat".to_string(); @@ -229,22 +247,3 @@ pub(crate) fn open_developer_window(app: &tauri::AppHandle) -> Result<(), String .map_err(|error| error.to_string())?; Ok(()) } - -#[cfg(all(debug_assertions, not(test)))] -pub(crate) fn navigate_client_to_game_chat( - app: &tauri::AppHandle, - options: &GameChatLaunchOptions, -) -> Result<(), String> { - let client = app - .get_webview_window("client") - .ok_or_else(|| "找不到 AI 游戏创作客户端窗口".to_string())?; - let mut url = client.url().map_err(|error| error.to_string())?; - url.set_path("/index.html"); - url.set_query(Some(&game_chat_window_query( - options.project_path.as_deref(), - options.initial_message.as_deref(), - ))); - url.set_fragment(None); - client.navigate(url).map_err(|error| error.to_string())?; - Ok(()) -} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index ac6afae0a..8725eeee5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-29 game-chat 在创建 WebView 前确定初始 URL + +- 背景:`agc:game-chat` 曾在 Tauri `.setup()` 中读取仍可能是 `about:blank` 或配置期地址的 `client.url()`,再导航到 game-chat;Windows WebView2 首航被覆盖后只剩黑边白块或全白原生窗口,刷新无法恢复。 +- 决策:debug game-chat 启动在 `.run(context)` 前修改 `Context` 中 `client` 的 `WindowConfig.url`,由 Tauri 首次创建 WebView 时直接解析 `index.html?game-chat...`。禁止从运行期未就绪 WebView URL 推导首次入口,也不通过 page-load 回调制造第二次首屏导航。 +- 影响范围:仅 AI 游戏创作壳的 debug game-chat 启动入口;普通 GUI、CLI、release、Runner 与前端路由合同不变。 +- 验证方式:Rust 回归断言启动前只改写 `client` 初始 URL;真实 Windows 启动 `npm run agc:game-chat` 后必须看到“未选择项目”空态或登录界面,WebView 尺寸随客户端窗口更新且 renderer 保持存活。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-07-28 AI 游戏临时页面启动消息采用 URL 消费加页面闩锁 - 背景:开发态 `--game-chat --initial-message` 在长任务期间发生 WebView 重载时,组件内或模块内闩锁会随页面环境重建,曾把同一自主任务重复排入 Supervisor 队列。 diff --git a/docs/project-memory/shared-memory/development-workflow.md b/docs/project-memory/shared-memory/development-workflow.md index 6e1ba6665..7436b0648 100644 --- a/docs/project-memory/shared-memory/development-workflow.md +++ b/docs/project-memory/shared-memory/development-workflow.md @@ -238,7 +238,7 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir - 定向命令必须实际匹配到 V1.10 用例,`0 tests` 不算通过。真实 Provider fixture 不得把工具顺序、processId、readiness 文本所在 chunk 或 OS PID 写进任务提示;验收器只按持久 action identity、fixture 计数、私有输出和公共泄漏扫描判定。三项门禁实际通过后才能把日期、Provider、数量和 PASS 结果写入技术方案或 decision log;未运行或被外部配置阻断时只记录 `BLOCKED` / 未验收事实。 -`npm run agc` 会启动 Tauri 开发客户端;其 `beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 和固定 `127.0.0.1:3080` Vite。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database `。 +`npm run agc` 会启动 Tauri 开发客户端;其 `beforeDevCommand` 通过 `npm run agc:serve` 先完成壳 typecheck,再启动或复用配套 SpacetimeDB、`api-server` 和固定 `127.0.0.1:3080` Vite。开发态只打开游戏创作聊天入口使用 `npm run agc:game-chat -- [--project-path ]`。只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database `。 Linux 多用户共享同一台机器开发时,本地 dev 脚本会为当前 Linux 用户分配一个固定端口段并写入系统级注册表 `/var/tmp/genarrative-dev-port-ranges/registry.json`,自动分配从 `10000-10099` 开始,每段 100 个端口,五个 dev 服务依次使用 `start` 到 `start + 4`,其中 BgFilter worker 固定为 `start + 4`。可用 `GENARRATIVE_DEV_PORT_RANGE` 或 `npm run dev -- --port-range` 手动指定端口段用于特殊场景;注册表会阻止不同用户使用相同或重叠段,并让同一用户后续启动继续复用自己已占用的固定段。该机制只在 Linux 生效,Windows 把第五个服务纳入原有统一端口探测与漂移逻辑。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 8ef459e64..deaa45464 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -25,7 +25,7 @@ ## 2026-07-27 开发态“游戏运行 + 聊天”临时入口 -- 入口例外:只有 debug 构建接受 CLI `--game-chat`,并可选接受 `--project-path ` 与内部验收用 `--initial-message <首条消息>`;宿主内部统一映射为 `index.html?game-chat&projectPath=...`。页面首屏读取初始消息后必须立即从当前 URL 删除 `initialMessage`,并以页面级闩锁绑定启动项目;React StrictMode、Runtime 终态、HMR、整页重载或项目切换都不得再次提交同一启动消息。release 构建不注册这些参数或窗口,无参数的正常启动流程不变。未传入项目路径时由页面选择目录:已有 AI 游戏项目直接打开,空目录按目录名初始化,非空且未初始化目录继续复用现有二次确认。 +- 入口例外:只有 debug 构建接受 CLI `--game-chat`,并可选接受 `--project-path ` 与内部验收用 `--initial-message <首条消息>`;宿主内部统一映射为 `index.html?game-chat&projectPath=...`。该 URL 必须在 Tauri 创建 `client` WebView 前写入初始 `WindowConfig`,禁止在 `.setup()` 阶段读取尚未完成首航的 `client.url()` 后二次导航。页面首屏读取初始消息后必须立即从当前 URL 删除 `initialMessage`,并以页面级闩锁绑定启动项目;React StrictMode、Runtime 终态、HMR、整页重载或项目切换都不得再次提交同一启动消息。release 构建不注册这些参数或窗口,无参数的正常启动流程不变。未传入项目路径时由页面选择目录:已有 AI 游戏项目直接打开,空目录按目录名初始化,非空且未初始化目录继续复用现有二次确认。 - 对话与事件:窗口固定使用 `project-supervisor + autonomous-game-build`,继续复用 active Session、External Runner、持久 conversation、流式回复、same-run steer、工具确认与用户追问。以 `/` 开头的输入必须继续走现有内置命令解析,例如 `/preview` 只能生成 `preview.start` 确认卡,不得作为自主构建任务投递给 Supervisor。界面聚合当前 Supervisor 父 run 及其直接委派专业 Agent 的最新事件,按时间倒序稳定去重并标注 Agent;默认显示 4 条,可展开至最新 20 条。这些原始事件只是 Runtime 状态投影,不写入 conversation,不伪装成用户或 assistant 消息。 - Supervisor 进度播报:聊天消息流内保留且只保留一条当前 run 的 Runtime-owned 播报卡,由客户端从 manifest 任务图、Supervisor 结构化计划、`loopIteration`、当前动作、直接委派专业 Agent 及其持久事件确定性整理;显示当前轮次、任务 / 计划进度、活跃 Agent、最近试玩与静态检查、返工决定、代码修改和截图检查证据。同一 run 原位更新,切换 run 时替换,不调用额外模型、不追加持久 conversation,也不改变最终 assistant 回复的唯一性;任意详情必须有界且不展示绝对路径、Provider 元数据或内部指纹。 - 跨轮阶段记录:game-chat 确实观察过活跃态的父 run 进入 completed / failed / cancelled 等终态,且正式 Supervisor conversation 已刷新后,客户端把本轮轮次、任务 / 计划完成度、最新试玩 / 静态检查、最近返工决定和已登记成果图片路径整理成一条 `【Supervisor 阶段记录】` 项目 assistant 消息。每个“项目 + 父 run”最多追加一次,进入现有 `conversation.write` 权限与项目 conversation 持久化链路,下一轮及重载后继续保留;加载时已经终态但本窗口未观察其活跃过程的旧 run 不补写,防止每次启动重复归档。阶段记录不是 Supervisor Runtime 正式回复,不写入 Agent Session、不增加 final assistant 数量,也不逐条复制原始事件或内部正文。