修复游戏聊天启动白屏
Project CI / Repository checks (push) Failing after 52s
Project CI / Backend tests (push) Successful in 3m10s
Project CI / Frontend tests (push) Successful in 3m19s
Project CI / Native shell tests (push) Failing after 10m3s

在创建 WebView 前写入 game-chat 初始 URL

增加启动顺序与禁止二次导航门禁

补齐跨平台回归测试和项目文档
This commit is contained in:
2026-07-29 17:59:10 +08:00
parent 349be22800
commit 5479559c86
7 changed files with 204 additions and 30 deletions
@@ -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')
) {
@@ -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");
}
@@ -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::Wry> = 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());
@@ -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(())
}
@@ -16,6 +16,14 @@
---
## 2026-07-29 game-chat 在创建 WebView 前确定初始 URL
- 背景:`agc:game-chat` 曾在 Tauri `.setup()` 中读取仍可能是 `about:blank` 或配置期地址的 `client.url()`,再导航到 game-chatWindows 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 队列。
@@ -238,7 +238,7 @@ npm run ai-game-creator-shell:agent-runtime:real-e2e -- --config-dir <AppData> -
定向命令必须实际匹配到 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 <name>`
`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 <absolute-path>]`只需要浏览器预览同一客户端时可用 `npm run agc:serve`;只启动配套后端和数据库时可用 `npm run agc:backend -- --database <name>`
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 把第五个服务纳入原有统一端口探测与漂移逻辑。
@@ -25,7 +25,7 @@
## 2026-07-27 开发态“游戏运行 + 聊天”临时入口
- 入口例外:只有 debug 构建接受 CLI `--game-chat`,并可选接受 `--project-path <absolute-path>` 与内部验收用 `--initial-message <首条消息>`;宿主内部统一映射为 `index.html?game-chat&projectPath=...`。页面首屏读取初始消息后必须立即从当前 URL 删除 `initialMessage`,并以页面级闩锁绑定启动项目;React StrictMode、Runtime 终态、HMR、整页重载或项目切换都不得再次提交同一启动消息。release 构建不注册这些参数或窗口,无参数的正常启动流程不变。未传入项目路径时由页面选择目录:已有 AI 游戏项目直接打开,空目录按目录名初始化,非空且未初始化目录继续复用现有二次确认。
- 入口例外:只有 debug 构建接受 CLI `--game-chat`,并可选接受 `--project-path <absolute-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 数量,也不逐条复制原始事件或内部正文。