修复游戏聊天启动白屏
在创建 WebView 前写入 game-chat 初始 URL 增加启动顺序与禁止二次导航门禁 补齐跨平台回归测试和项目文档
This commit is contained in:
@@ -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-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 队列。
|
||||
|
||||
@@ -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 数量,也不逐条复制原始事件或内部正文。
|
||||
|
||||
Reference in New Issue
Block a user