diff --git a/apps/ai-game-creator-shell/src-tauri/src/preview.rs b/apps/ai-game-creator-shell/src-tauri/src/preview.rs index 528b3a386..4e9d00bda 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/preview.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/preview.rs @@ -758,7 +758,14 @@ pub(crate) fn start_local_game_preview_at_revision( } let (preview, previous_preview) = registry.set_running(preview, stop); if let Some(previous_preview) = previous_preview.as_ref() { - record_replaced_preview_stop(previous_preview); + // 同一个项目重启预览(换监听线程、换端口)时,旧的 registry 身份与新预览共享 + // 同一份落盘记录:上面刚写进去的是 running,这里若再用旧预览收尾,就会把它覆盖 + // 成 stopped,下次进项目便丢掉"预览在跑"这条事实。只有旧预览属于**另一个项目** + // 时才替它收尾(那才是真的换项目)。 + let previous_status = local_preview_status_from_result(previous_preview); + if ensure_preview_belongs_to_project(&previous_status, root).is_err() { + record_replaced_preview_stop(previous_preview); + } } if let Err(error) = append_preview_start_trace_step(root, &preview) { let _ = registry.stop(); @@ -792,11 +799,23 @@ pub(crate) fn stop_local_game_preview_for_root( registry: &PreviewRegistry, ) -> Result { let (status, stopped) = registry.stop_for_project(root); - if let Some(root) = root.filter(|_| stopped) { - record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?; - append_preview_log(root, "stopped", None)?; - append_preview_stop_trace_step(root)?; + let Some(root) = root else { + return Ok(status); + }; + // 没有活体预览可停时,落盘记录仍可能是陈旧的 running:预览服务器是进程内线程、 + // 非 Linux 端口还是随机临时端口,客户端退出后记录会一直写着 running。这时把记录 + // 对齐成 stopped 才算真的"停"——否则下次进项目会照着陈旧记录渲染打不开的运行 + // 界面(进入项目时的活体核对是第二道保险,记录本身也该在自己的生命周期里结束)。 + let stale_record = !stopped + && read_manifest_for_project(root)? + .preview + .is_some_and(|preview| preview.status == GameCreationAppPreviewStatus::Running); + if !stopped && !stale_record { + return Ok(status); } + record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?; + append_preview_log(root, "stopped", None)?; + append_preview_stop_trace_step(root)?; Ok(status) } 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 048a20a18..ab4e1025f 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 @@ -4735,6 +4735,87 @@ fn stale_preview_cleanup_cannot_be_blocked_by_project_stop_policy() { fs::remove_dir_all(root).ok(); } +#[test] +fn restarting_preview_for_the_same_project_keeps_the_running_record() { + let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "同项目重启预览记录测试") + .expect("project init"); + let registry = PreviewRegistry::default(); + + let first = start_local_game_preview_at(&root, ®istry).expect("first preview start"); + let second = start_local_game_preview_at(&root, ®istry).expect("second preview start"); + assert_ne!(first.url, second.url, "重启必须换一个监听端口"); + + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + assert_eq!( + manifest["preview"]["status"], "running", + "同一个项目重启预览不能让旧预览的收尾把刚写进去的 running 覆盖成 stopped" + ); + assert_eq!(manifest["preview"]["port"], second.port); + + let _ = registry.stop(); + fs::remove_dir_all(root).ok(); +} + +#[test] +fn replacing_preview_for_another_project_closes_the_previous_project_record() { + let first_root = unique_project_path(); + let second_root = unique_project_path(); + init_existing_html_project_at(&first_root, "project-1", "旧项目预览记录测试") + .expect("first project init"); + init_existing_html_project_at(&second_root, "project-2", "新项目预览记录测试") + .expect("second project init"); + let registry = PreviewRegistry::default(); + + let _first = start_local_game_preview_at(&first_root, ®istry).expect("first preview"); + let second = start_local_game_preview_at(&second_root, ®istry).expect("second preview"); + + let previous_manifest: Value = + serde_json::from_str(&fs::read_to_string(first_root.join(".agent/manifest.json")).unwrap()) + .expect("previous manifest json"); + assert_eq!( + previous_manifest["preview"]["status"], "stopped", + "换项目时旧项目的落盘记录必须收尾" + ); + let next_manifest: Value = serde_json::from_str( + &fs::read_to_string(second_root.join(".agent/manifest.json")).unwrap(), + ) + .expect("next manifest json"); + assert_eq!(next_manifest["preview"]["status"], "running"); + assert_eq!(next_manifest["preview"]["port"], second.port); + + let _ = registry.stop(); + fs::remove_dir_all(first_root).ok(); + fs::remove_dir_all(second_root).ok(); +} + +#[test] +fn stopping_without_a_live_preview_still_reconciles_a_stale_running_record() { + let root = unique_project_path(); + init_existing_html_project_at(&root, "project-1", "陈旧记录对齐测试").expect("project init"); + // 模拟"上次留下的 running 记录":直接写记录,registry 里没有任何活体预览。 + record_preview_state( + &root, + GameCreationAppPreviewStatus::Running, + Some("http://127.0.0.1:3210/".to_string()), + Some(3210), + ) + .expect("stale record"); + let registry = PreviewRegistry::default(); + + let status = stop_local_game_preview_for_root(Some(&root), ®istry).expect("reconcile"); + + assert_eq!(status, stopped_preview_status()); + let manifest: Value = + serde_json::from_str(&fs::read_to_string(root.join(".agent/manifest.json")).unwrap()) + .expect("manifest json"); + assert_eq!(manifest["preview"]["status"], "stopped"); + + fs::remove_dir_all(root).ok(); +} + #[test] fn local_preview_serves_generated_playable_game() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts b/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts new file mode 100644 index 000000000..a9c37a2fc --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/app-shell/sessionPreview.ts @@ -0,0 +1,102 @@ +import type { GameCreationAppPreviewState } from '../../../../../packages/shared/src/contracts/gameCreationApp'; + +/** + * 进入项目时的预览活体核验。 + * + * 为什么不能直接信项目 manifest 里的 `preview`:那是"上次留下的一条记录",不是"现在 + * 有没有在跑"。本地预览服务器是**进程内线程**(`src-tauri/src/preview.rs` 的 + * `thread::spawn` + loopback listener),非 Linux 平台端口还是**随机临时端口** + * (`bind_loopback_listener_with_linux_fallback` 里的 `TcpListener::bind(..., 0)`), + * 客户端一退 URL 就永久失效;而落盘记录会一直写着 `running`。照它渲染,用户就会被直接 + * 丢进一个打不开的运行界面——这正是"有时候进项目看到的是运行界面"的成因。 + * + * 所以进项目时问一次内存 registry(`get_local_game_preview_status`): + * - 确实在跑 → 作为**本次会话的预览**交给工作台(工作台据此自动进运行视图); + * - 没在跑但记录写着 running → 记录陈旧:把落盘记录对齐成 stopped(复用现役 + * `stop_local_game_preview`,它现在会把陈旧记录一并收尾),本次会话不带预览。 + */ +export type SessionPreviewResolution = { + /** 本次会话可用的预览:只有内存 registry 里真的在跑才有值。 */ + sessionPreview: GameCreationAppPreviewState | null; + /** + * 会话 manifest 里该投影成的预览状态;`null` 表示落盘记录与真相一致,不用改。 + * + * 判定陈旧的同一刻就按真相投影(而不是等落盘写成功):否则一份写不进去的陈旧记录 + * (例如项目策略把 `preview.stop` 设成需确认)会让运行入口继续拿着一个打不开的 URL + * 渲染运行画面。落盘那份由这次写入、退出收尾或下次进入时的核对负责收敛。 + */ + manifestPreviewPatch: GameCreationAppPreviewState | null; +}; + +/** 预览命令的最小 invoke 形状(与 App 里 `window.__TAURI__.core.invoke` 一致)。 */ +export type PreviewCommandInvoke = ( + command: string, + args?: Record, +) => Promise; + +type LocalPreviewStatusLike = { + status?: string | null; + url?: string | null; + port?: number | null; +}; + +function livePreviewFromStatus( + status: LocalPreviewStatusLike | null, +): GameCreationAppPreviewState | null { + const url = typeof status?.url === 'string' ? status.url.trim() : ''; + if (status?.status !== 'running' || !url) { + return null; + } + return { + status: 'running', + url, + port: typeof status.port === 'number' ? status.port : null, + }; +} + +export async function resolveSessionPreviewOnProjectOpen({ + invoke, + projectPath, + recordedPreview, +}: { + invoke: PreviewCommandInvoke | null | undefined; + projectPath: string; + recordedPreview: GameCreationAppPreviewState | null; +}): Promise { + const trimmedProjectPath = projectPath.trim(); + if (!invoke || !trimmedProjectPath) { + return { sessionPreview: null, manifestPreviewPatch: null }; + } + + let status: LocalPreviewStatusLike | null = null; + try { + status = await invoke( + 'get_local_game_preview_status', + { projectPath: trimmedProjectPath }, + ); + } catch { + // 读不到 registry 就按"没有在跑"处理:宁可让用户点一下「运行」,也不要进一个 + // 打不开的运行界面。 + status = null; + } + const sessionPreview = livePreviewFromStatus(status); + if (sessionPreview) { + return { sessionPreview, manifestPreviewPatch: null }; + } + + if (recordedPreview?.status !== 'running') { + return { sessionPreview: null, manifestPreviewPatch: null }; + } + + try { + await invoke('stop_local_game_preview', { + projectPath: trimmedProjectPath, + }); + } catch { + // 对齐落盘记录失败不改判:本次会话照样不把陈旧记录当活体用。 + } + return { + sessionPreview: null, + manifestPreviewPatch: { status: 'stopped' }, + }; +} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 854e8193b..f6f1ecdec 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -42,6 +42,7 @@ import { isAbsoluteProjectPath, projectPathHasControlCharacter, } from '../project-summary/projectSummary'; +import { resolveSessionPreviewOnProjectOpen } from './sessionPreview'; type UseHomeProjectCreationOptions = { setStatus: Dispatch>; @@ -175,15 +176,30 @@ export function useHomeProjectCreation({ } } - function enterProjectDevelopment(context: LauncherProjectContext) { - setCurrentProjectContext(context); + async function enterProjectDevelopment(context: LauncherProjectContext) { /** - * 进项目时**不**把 manifest 里的 `preview` 当成本次会话的预览:那是"上次留下的一条 - * 落盘记录",预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后 - * 记录仍会写着 running。会话预览只由活体确认(下一步的 registry 核对)或本次会话里 - * 的 preview.start / preview.status 写入,见 `activeProjectPreview` 的其它赋值点。 + * 会话预览只认"内存 registry 里真的还在跑"的那一个(见 + * `resolveSessionPreviewOnProjectOpen`)。项目 manifest 里的 `preview` 是**落盘记录**、 + * 不是活体:预览服务器是进程内线程、非 Linux 端口还是随机临时端口,客户端退出后记录 + * 仍会写着 running,照它自动进运行视图就是给用户一个打不开的运行界面。 */ - setActiveProjectPreview(null); + const session = await resolveSessionPreviewOnProjectOpen({ + invoke: resolveTauriInvoke(), + projectPath: context.projectPath, + recordedPreview: context.manifest.preview ?? null, + }); + setCurrentProjectContext( + session.manifestPreviewPatch + ? { + ...context, + manifest: { + ...context.manifest, + preview: session.manifestPreviewPatch, + }, + } + : context, + ); + setActiveProjectPreview(session.sessionPreview); setAgentRuntimeSummaries([]); setAgentResults([]); setProjectPath(context.projectPath); @@ -263,7 +279,7 @@ export function useHomeProjectCreation({ result.projectPath, attachments, ); - enterProjectDevelopment({ + await enterProjectDevelopment({ projectPath: result.projectPath, projectName: result.manifest.name || projectNameFromPath(result.projectPath), @@ -399,7 +415,7 @@ export function useHomeProjectCreation({ }, ); setStatus('已创建项目'); - enterProjectDevelopment({ + await enterProjectDevelopment({ projectPath: result.projectPath, projectName: result.manifest.name || projectNameFromPath(result.projectPath), @@ -484,7 +500,7 @@ export function useHomeProjectCreation({ return; } setStatus('已打开项目'); - enterProjectDevelopment({ + await enterProjectDevelopment({ projectPath: trimmedProjectPath, projectName: directoryStatus.projectName || diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index 8a074e1ba..4c291695d 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -6993,11 +6993,13 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'chat_with_game_creator_direct_codex', ), ).toHaveLength(2); + // 进入项目时按内存 registry 核对一次预览活体(有活体才作为会话预览)。 + // 这里只核验、不启动:开始预览仍然只能由用户动作或 Agent 触发。 expect( invoke.mock.calls.filter( ([command]) => command === 'get_local_game_preview_status', ), - ).toHaveLength(0); + ).toHaveLength(1); expect( invoke.mock.calls.filter( ([command]) => command === 'start_local_game_preview', diff --git a/apps/ai-game-creator-shell/tests/sessionPreview.test.ts b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts new file mode 100644 index 000000000..5ec837e16 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/sessionPreview.test.ts @@ -0,0 +1,158 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { GameCreationAppPreviewState } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { resolveSessionPreviewOnProjectOpen } from '../src/features/app-shell/sessionPreview'; + +const projectPath = '/tmp/session-preview-project'; + +function createInvoke( + responses: Record, + calls: Array<[string, Record | undefined]> = [], +) { + return vi.fn(async (command: string, args?: Record) => { + calls.push([command, args]); + if (!(command in responses)) { + throw new Error(`unexpected invoke ${command}`); + } + return responses[command]; + }); +} + +describe('进入项目时的预览活体核验', () => { + it('registry 里没有活体时不给会话预览,并把陈旧的 running 记录对齐成 stopped', async () => { + const calls: Array<[string, Record | undefined]> = []; + const invoke = createInvoke( + { + get_local_game_preview_status: { + status: 'stopped', + url: null, + port: null, + root: null, + }, + stop_local_game_preview: { + status: 'stopped', + url: null, + port: null, + root: null, + }, + }, + calls, + ); + + const resolution = await resolveSessionPreviewOnProjectOpen({ + invoke, + projectPath, + recordedPreview: { + status: 'running', + url: 'http://127.0.0.1:4173/', + port: 4173, + }, + }); + + // 没有活体预览:本次会话不带预览(工作台因此不会自动进运行视图)。 + expect(resolution.sessionPreview).toBeNull(); + // 落盘记录被判陈旧,会话 manifest 立刻按真相投影。 + expect(resolution.manifestPreviewPatch).toEqual({ status: 'stopped' }); + expect(calls).toEqual([ + ['get_local_game_preview_status', { projectPath }], + ['stop_local_game_preview', { projectPath }], + ]); + }); + + it('registry 确实在跑时只认活体,且不写落盘记录', async () => { + const calls: Array<[string, Record | undefined]> = []; + const invoke = createInvoke( + { + get_local_game_preview_status: { + status: 'running', + url: 'http://127.0.0.1:5210/', + port: 5210, + root: projectPath, + }, + }, + calls, + ); + + const resolution = await resolveSessionPreviewOnProjectOpen({ + invoke, + projectPath, + // 落盘记录落后于真相(例如同项目重启后记录没跟上)也不影响:活体说了算。 + recordedPreview: { status: 'stopped' }, + }); + + expect(resolution.sessionPreview).toEqual({ + status: 'running', + url: 'http://127.0.0.1:5210/', + port: 5210, + }); + expect(resolution.manifestPreviewPatch).toBeNull(); + expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]); + }); + + it('registry 不可读时按没有在跑处理,不把落盘记录当活体', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'get_local_game_preview_status') { + throw new Error('需要在陶泥儿客户端内运行'); + } + return { status: 'stopped', url: null, port: null, root: null }; + }); + + const resolution = await resolveSessionPreviewOnProjectOpen({ + invoke, + projectPath, + recordedPreview: { + status: 'running', + url: 'http://127.0.0.1:4173/', + port: 4173, + }, + }); + + expect(resolution.sessionPreview).toBeNull(); + expect(resolution.manifestPreviewPatch).toEqual({ status: 'stopped' }); + }); + + it('记录本来就不是 running 时只读活体,不发多余的停止命令', async () => { + const calls: Array<[string, Record | undefined]> = []; + const invoke = createInvoke( + { + get_local_game_preview_status: { + status: 'stopped', + url: null, + port: null, + root: null, + }, + }, + calls, + ); + + const stoppedRecord: GameCreationAppPreviewState = { status: 'stopped' }; + const resolution = await resolveSessionPreviewOnProjectOpen({ + invoke, + projectPath, + recordedPreview: stoppedRecord, + }); + + expect(resolution).toEqual({ + sessionPreview: null, + manifestPreviewPatch: null, + }); + expect(calls).toEqual([['get_local_game_preview_status', { projectPath }]]); + }); + + it('没有 invoke(浏览器里跑)时不做任何请求', async () => { + const resolution = await resolveSessionPreviewOnProjectOpen({ + invoke: null, + projectPath, + recordedPreview: { + status: 'running', + url: 'http://127.0.0.1:4173/', + port: 4173, + }, + }); + + expect(resolution).toEqual({ + sessionPreview: null, + manifestPreviewPatch: null, + }); + }); +}); diff --git a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md index 82223966f..eb792cb71 100644 --- a/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md +++ b/docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md @@ -143,11 +143,11 @@ ### 4.1 主视窗 ```text -resource-overview +resource-overview(进入项目的初始态) -> resource-editor.derive(静态图片、SVG、视频、音频、文档/代码、Agent 回执“编辑资源”) -> resource-editor.version-branch(项目版本“编辑资源”) -> ui-editor(UI 原型与 UI 资源“编辑资源”) - -> run(存在 runnableVersion 且 loopback preview 可启动) + -> run(用户点运行入口/播放,或进入项目时内存 registry 确认预览仍在运行) resource-editor.* | ui-editor -> resource-overview(取消或保存投影完成) @@ -162,7 +162,7 @@ run.paused -> resource-overview ``` -运行入口不可用时仍允许点击,显示“当前无可运行版本”,但不切换状态。素材创作的完整 opening/editing/generating/saving/cancelling/failed/recovering 状态、草稿身份和迟到结果门禁以 [`【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`](../technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md) 为准。 +进入项目时一律先落到 `resource-overview`。`run` 的自动进入只认内存 registry 的活体确认:只有本地预览服务器确实还在跑(`get_local_game_preview_status` 返回 `running`)才直接切过去。项目 manifest 里的 `preview` 是**落盘记录**而不是活体(预览服务器是进程内线程,非 Linux 平台端口还是随机临时端口),客户端退出后它会停留在 `running`;陈旧记录既不触发自动切换,也不允许被当成可运行预览渲染——进入项目时核对到记录陈旧就把它对齐成 `stopped`,客户端退出时也会统一停掉预览并收尾记录。运行入口不可用时仍允许点击,显示“当前无可运行版本”,但不切换状态。素材创作的完整 opening/editing/generating/saving/cancelling/failed/recovering 状态、草稿身份和迟到结果门禁以 [`【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md`](../technical/【技术方案】客户端素材创作无限画布阶段一合同-2026-08-05.md) 为准。 ### 4.2 测试切片