From 998e33ca3de44a239cf39c2660e331c1aa8f1508 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 3 Jul 2026 01:32:51 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84AI=E6=B8=B8=E6=88=8F=E5=88=9B?= =?UTF-8?q?=E4=BD=9CApp=E6=9C=AC=E5=9C=B0=E5=B7=A5=E4=BD=9C=E5=8C=BA?= =?UTF-8?q?=E4=B8=8EAgent=E5=8D=8F=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 补齐工作区启动器、非空目录确认和主窗口切换入口 改为运行时配置文件并支持全局与单Agent LLM Provider 接入平台 External API 生成和同步美术资产 补齐本地对话记录、Agent私有记忆和项目黑板 完善Agent状态、历史Run、快照、文件和资产快捷入口 同步AI游戏创作App实施计划和共享决策记录 --- .../game-creator.config.json | 1 + .../scripts/check-config.mjs | 319 +- .../smoke-agent-run-local-provider.mjs | 93 +- .../src-tauri/Cargo.lock | 144 +- .../src-tauri/Cargo.toml | 1 + .../src-tauri/src/debug.rs | 2 +- .../src-tauri/src/debug/debug_drafts.rs | 2 +- .../src-tauri/src/main.rs | 3039 +++- .../src-tauri/tauri.conf.json | 11 +- apps/ai-game-creator-shell/src/App.tsx | 7085 +++++++- apps/ai-game-creator-shell/src/main.tsx | 11 +- apps/ai-game-creator-shell/src/styles.css | 540 +- .../tests/agentTraceSummary.test.ts | 124 +- .../tests/appSurface.test.ts | 13854 +++++++++++++++- .../tests/rememberCommand.test.ts | 148 +- .../shared-memory/decision-log.md | 19 +- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 76 +- .../src/contracts/gameCreationApp.test.ts | 26 + .../shared/src/contracts/gameCreationApp.ts | 10 +- scripts/check-native-shells.mjs | 35 +- .../shared-contracts/src/game_creation_app.rs | 47 +- 21 files changed, 24613 insertions(+), 974 deletions(-) diff --git a/apps/ai-game-creator-shell/game-creator.config.json b/apps/ai-game-creator-shell/game-creator.config.json index 57df7143c..dfd8dab81 100644 --- a/apps/ai-game-creator-shell/game-creator.config.json +++ b/apps/ai-game-creator-shell/game-creator.config.json @@ -9,6 +9,7 @@ "maxRetries": 0, "retryBackoffMs": 500 }, + "agentLlm": {}, "editorApi": { "baseUrl": "http://127.0.0.1:8082", "apiKey": "" diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 89f505d1e..0e393c7ce 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -9,6 +9,12 @@ const tauriConfig = JSON.parse( 'utf8', ), ); +const defaultAppConfig = JSON.parse( + fs.readFileSync( + new URL('../game-creator.config.json', import.meta.url), + 'utf8', + ), +); const rootPackageConfig = JSON.parse( fs.readFileSync(new URL('../../../package.json', import.meta.url), 'utf8'), ); @@ -16,6 +22,29 @@ const viteConfigSource = fs.readFileSync( new URL('../vite.config.ts', import.meta.url), 'utf8', ); +const appInvokeSource = fs.readFileSync( + new URL('../src/App.tsx', import.meta.url), + 'utf8', +); +const tauriHandlerSource = fs.readFileSync( + new URL('../src-tauri/src/main.rs', import.meta.url), + 'utf8', +); +const sharedContractSource = fs.readFileSync( + new URL( + '../../../packages/shared/src/contracts/gameCreationApp.ts', + import.meta.url, + ), + 'utf8', +); +const rustSharedContractSource = fs.readFileSync( + new URL( + '../../../server-rs/crates/shared-contracts/src/game_creation_app.rs', + import.meta.url, + ), + 'utf8', +); +const allowedUncalledTauriCommands = []; const sourceExtensions = new Set([ '.json', '.md', @@ -61,6 +90,149 @@ function assertNoOpenAiApiKeys(paths) { } } +function assertNoEnvironmentConfigFallbacks(paths) { + const allowedDevCheck = 'import.meta.env.DEV'; + for (const path of paths.flatMap((entry) => collectFiles(entry))) { + const source = fs + .readFileSync(path, 'utf8') + .replaceAll(allowedDevCheck, '') + .replaceAll('game-creator.config.local.json', ''); + if (/\bprocess\.env\b|\bdotenv\b/.test(source)) { + throw new Error( + `AI game creator shell must use runtime config, not environment config: ${path.pathname}`, + ); + } + } +} + +function assertNoNativeBrowserConfirm(paths) { + for (const path of paths.flatMap((entry) => collectFiles(entry))) { + const source = fs.readFileSync(path, 'utf8'); + if (/\bwindow\.confirm\b/.test(source)) { + throw new Error( + `AI game creator shell confirmations must use in-app UI: ${path.pathname}`, + ); + } + } +} + +function extractConstArrayBlock(source, name) { + const start = source.indexOf(`const ${name}`); + if (start === -1) { + throw new Error(`Missing contract array: ${name}`); + } + const end = source.indexOf('];', start); + if (end === -1) { + throw new Error(`Missing contract array end: ${name}`); + } + return source.slice(start, end + 2); +} + +function parseTsCommands(source) { + const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS'); + return Array.from( + block.matchAll( + /\{\s*id:\s*'([^']+)',\s*permission:\s*'([^']+)'\s*\}/g, + ), + ([, id, permission]) => ({ id, permission }), + ); +} + +function parseRustCommands(source) { + const block = extractConstArrayBlock(source, 'GAME_CREATION_APP_COMMANDS'); + const permissionNames = { + Auto: 'auto', + Confirm: 'confirm', + Deny: 'deny', + }; + return Array.from( + block.matchAll( + /command\(\s*"([^"]+)",\s*GameCreationAppPermission::(Auto|Confirm|Deny)\s*\)/g, + ), + ([, id, permission]) => ({ id, permission: permissionNames[permission] }), + ); +} + +function parseTsCapabilities(source) { + const block = extractConstArrayBlock( + source, + 'GAME_CREATION_AGENT_CAPABILITIES', + ); + return Array.from( + block.matchAll( + /\{\s*id:\s*'([^']+)',\s*area:\s*'([^']+)',\s*title:\s*'([^']+)',?\s*\}/g, + ), + ([, id, area, title]) => ({ id, area, title }), + ); +} + +function parseRustCapabilities(source) { + const block = extractConstArrayBlock( + source, + 'GAME_CREATION_AGENT_CAPABILITIES', + ); + return Array.from( + block.matchAll( + /capability\(\s*"([^"]+)",\s*"([^"]+)",\s*"([^"]+)",?\s*\)/g, + ), + ([, id, area, title]) => ({ id, area, title }), + ); +} + +function assertContractRecordsMatch(label, leftRecords, rightRecords) { + const normalize = (records) => + records + .map((record) => JSON.stringify(record)) + .sort((left, right) => left.localeCompare(right)); + const left = normalize(leftRecords); + const right = normalize(rightRecords); + if (left.length === 0 || right.length === 0) { + throw new Error(`${label} parser returned no records`); + } + if (JSON.stringify(left) !== JSON.stringify(right)) { + throw new Error( + `${label} drifted between TypeScript and Rust contracts\nTS=${left.join( + '\n', + )}\nRust=${right.join('\n')}`, + ); + } +} + +function parseAppInvokeCommandNames(source) { + return Array.from( + source.matchAll(/invoke(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g), + ([, command]) => command, + ); +} + +function parseTauriHandlerCommandNames(source) { + const match = source.match(/tauri::generate_handler!\[([\s\S]*?)\]/); + if (!match) { + throw new Error('AI game creator shell Tauri handler list is missing'); + } + return Array.from( + match[1].matchAll(/\b([a-z][a-z0-9_]+)\b/g), + ([, command]) => command, + ); +} + +function parseRustFunctionNames(source) { + return Array.from( + source.matchAll(/\b(?:async\s+)?fn\s+([a-z][a-z0-9_]*)\s*\(/g), + ([, name]) => name, + ); +} + +function assertCommandNamesSubset(label, leftNames, rightNames) { + const right = new Set(rightNames); + const missing = Array.from(new Set(leftNames)) + .filter((name) => !right.has(name)) + .sort((left, rightName) => left.localeCompare(rightName)); + if (missing.length > 0) { + throw new Error(`${label} missing commands: ${missing.join(', ')}`); + } +} + assertNoOpenAiApiKeys([ new URL('../src/', import.meta.url), new URL('../scripts/', import.meta.url), @@ -92,6 +264,59 @@ assertNoOpenAiApiKeys([ ), ]); +assertNoEnvironmentConfigFallbacks([ + new URL('../src/', import.meta.url), + new URL('../scripts/run-cli-with-config.mjs', import.meta.url), + new URL('../scripts/smoke-agent-run-local-provider.mjs', import.meta.url), + new URL('../scripts/start-dev-server.mjs', import.meta.url), + new URL('../src-tauri/src/', import.meta.url), + new URL('../package.json', import.meta.url), + new URL('../vite.config.ts', import.meta.url), + new URL('../src-tauri/Cargo.toml', import.meta.url), + new URL('../src-tauri/tauri.conf.json', import.meta.url), +]); + +assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]); + +assertContractRecordsMatch( + 'AI game creator shell command contract', + parseTsCommands(sharedContractSource), + parseRustCommands(rustSharedContractSource), +); + +assertContractRecordsMatch( + 'AI game creator shell capability contract', + parseTsCapabilities(sharedContractSource), + parseRustCapabilities(rustSharedContractSource), +); + +assertCommandNamesSubset( + 'AI game creator shell Tauri handler', + parseAppInvokeCommandNames(appInvokeSource), + parseTauriHandlerCommandNames(tauriHandlerSource), +); + +assertCommandNamesSubset( + 'AI game creator shell Tauri command implementation', + parseTauriHandlerCommandNames(tauriHandlerSource), + parseRustFunctionNames(tauriHandlerSource), +); + +assertCommandNamesSubset( + 'AI game creator shell App invoke or explicit native-only allowlist', + parseTauriHandlerCommandNames(tauriHandlerSource), + [ + ...parseAppInvokeCommandNames(appInvokeSource), + ...allowedUncalledTauriCommands, + ], +); + +assertCommandNamesSubset( + 'AI game creator shell explicit native-only allowlist', + allowedUncalledTauriCommands, + parseTauriHandlerCommandNames(tauriHandlerSource), +); + if (packageConfig.name !== '@genarrative/ai-game-creator-shell') { throw new Error('AI game creator shell package name drifted'); } @@ -128,21 +353,54 @@ if (tauriConfig.app?.withGlobalTauri !== true) { ); } -const windows = tauriConfig.app?.windows ?? []; -if (windows.length !== 1 || windows[0]?.label !== 'main') { +if ( + !Array.isArray(tauriConfig.app?.windows) || + tauriConfig.app.windows.length !== 1 || + tauriConfig.app.windows[0]?.label !== 'launcher' || + tauriConfig.app.windows[0]?.url !== 'index.html?launcher' +) { throw new Error( - 'AI game creator shell release config must expose only the chat main window', + 'AI game creator shell must start with only the launcher window', + ); +} + +if (defaultAppConfig.llm?.apiKey !== '') { + throw new Error('AI game creator shell default llm.apiKey must stay empty'); +} + +if (defaultAppConfig.editorApi?.apiKey !== '') { + throw new Error( + 'AI game creator shell default editorApi.apiKey must stay empty', ); } -const mainWindow = windows[0]; if ( - mainWindow.width !== 760 || - mainWindow.height !== 820 || - mainWindow.minWidth !== 420 || - mainWindow.minHeight !== 560 + defaultAppConfig.llm?.requestTimeoutMs < 1000 || + defaultAppConfig.llm?.maxRetries < 0 || + defaultAppConfig.llm?.retryBackoffMs < 1 ) { - throw new Error('AI game creator shell main window must stay chat-sized'); + throw new Error('AI game creator shell default LLM timing config is invalid'); +} + +const windows = tauriConfig.app?.windows ?? []; +if ( + windows.length !== 1 || + windows[0]?.label !== 'launcher' || + windows[0]?.url !== 'index.html?launcher' +) { + throw new Error( + 'AI game creator shell release config must expose only the launcher window', + ); +} + +const launcherWindow = windows[0]; +if ( + launcherWindow.width !== 820 || + launcherWindow.height !== 640 || + launcherWindow.minWidth !== 720 || + launcherWindow.minHeight !== 520 +) { + throw new Error('AI game creator shell launcher window must stay compact'); } if (tauriConfig.build?.devUrl !== 'http://127.0.0.1:3080/') { @@ -245,13 +503,12 @@ for (const snippet of [ 'configure_game_creator_runtime_config_dir(app.handle())?', 'read_game_creator_app_config,', 'write_game_creator_app_config,', - 'build_game_creator_llm_client_from_config()?', + 'resolve_game_creator_llm_config_for_agent(app_config, "planner")', + 'resolve_game_creator_llm_config_for_agent(app_config, "generator")', + 'build_game_creator_llm_client_from_llm_config(&planner_llm, "agentLlm.planner")?', + 'build_game_creator_llm_client_from_llm_config(&generator_llm, "agentLlm.generator")?', + 'agentLlm.{agent_id}', 'let app_config = match load_game_creator_app_config()', - '#[cfg(debug_assertions)]\nfn developer_window_url()', - 'tauri::WebviewUrl::App(PathBuf::from("index.html?dev"))', - '#[cfg(debug_assertions)]\nfn open_developer_window(app: &tauri::App)', - 'tauri::WebviewWindowBuilder::new(app, "developer", developer_window_url())', - 'open_developer_window(app)?;', 'fn append_local_permission_log_at(', '"command.auto"', 'GameCreationAppPermission::Auto', @@ -263,6 +520,34 @@ for (const snippet of [ } } +for (const snippet of [ + 'open_developer_window(app)?;', + 'tauri::WebviewWindowBuilder::new(app, "developer"', +]) { + if (tauriMainSource.includes(snippet)) { + throw new Error( + `AI game creator shell must not auto-open developer windows: ${snippet}`, + ); + } +} + +const smokeAgentRunSource = fs.readFileSync( + new URL('./smoke-agent-run-local-provider.mjs', import.meta.url), + 'utf8', +); +for (const snippet of [ + 'agentLlm', + 'planner-smoke-model', + 'generator-smoke-model', + 'global-smoke-model-unused', +]) { + if (!smokeAgentRunSource.includes(snippet)) { + throw new Error( + `AI game creator local-provider smoke lost per-agent LLM coverage: ${snippet}`, + ); + } +} + const appSource = fs.readFileSync( new URL('../src/App.tsx', import.meta.url), 'utf8', @@ -284,8 +569,8 @@ for (const snippet of [ 'function resolvePendingCommandProjectPath', 'resolveChatProjectPath(localProject) ?? draftProjectPath', '`permission.cancel ${command.id} missing-project`', - "'/remember [short|long] 内容:追加短期或长期记忆'", - "'/memory-set [short|long] 内容:覆盖保存短期或长期记忆'", + "'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'", + "'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'", 'function parseRememberInput', "'/trace 或 /loop:查看最近一次 Agent loop trace'", 'async function executeAgentTraceChat', diff --git a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs index f59bb8eac..4a5491a6c 100644 --- a/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs +++ b/apps/ai-game-creator-shell/scripts/smoke-agent-run-local-provider.mjs @@ -23,6 +23,10 @@ const smokeAssetBytes = Buffer.concat([ ]); const smokeAudioAssetPath = 'assets/uploads/smoke-bounce.mp3'; const smokeAudioAssetBytes = 'SMOKE_LOCAL_AUDIO:bounce'; +const smokeProjectConversationMarker = + 'SMOKE_CONVERSATION_CONTEXT:moonlight-wok'; +const smokeAgentConversationMarker = + 'SMOKE_AGENT_CONVERSATION_CONTEXT:neon-kitchen'; function handoffs() { return [ @@ -348,6 +352,18 @@ try { requestBodies.every((body) => body.includes('"stream":true')), 'provider requests did not use streaming LLM mode', ); + assert( + requestBodies.some((body) => body.includes('"model":"planner-smoke-model"')), + 'provider requests did not use planner agent LLM override', + ); + assert( + requestBodies.some((body) => body.includes('"model":"generator-smoke-model"')), + 'provider requests did not use generator agent LLM override', + ); + assert( + requestBodies.every((body) => !body.includes('global-smoke-model-unused')), + 'provider requests unexpectedly used global LLM config', + ); assert( requestBodies.some( (body) => @@ -357,6 +373,15 @@ try { ), 'provider requests missing local asset prompt context', ); + assert( + requestBodies.some((body) => body.includes(smokeProjectConversationMarker)) && + requestBodies.some((body) => body.includes(smokeAgentConversationMarker)), + 'provider requests missing recent conversation prompt context', + ); + assert( + requestBodies.every((body) => !body.includes('sk-smoke-secret')), + 'provider requests leaked sensitive conversation context', + ); assert(trace.status === 'preview-stopped', `trace status ${trace.status}`); assert(trace.passes === 2, `trace passes ${trace.passes}`); const expectedToolCallCount = trace.steps.reduce( @@ -500,9 +525,11 @@ try { step.inputPaths?.includes('memory/session.md') && step.inputPaths?.includes('memory/project.md') && step.inputPaths?.includes('memory/blackboard.md') && + step.inputPaths?.includes('.agent/conversations/project.jsonl') && + step.inputPaths?.includes('.agent/conversations/agents/') && step.inputPaths?.includes('.agent/manifest.json'), ), - 'trace missing planner memory or manifest inputs', + 'trace missing planner memory, conversation or manifest inputs', ); assert( trace.steps.some( @@ -510,11 +537,13 @@ try { step.agent === '策划组 / Director' && step.pass === 1 && step.inputPaths?.includes('memory/blackboard.md') && + step.inputPaths?.includes('.agent/conversations/project.jsonl') && + step.inputPaths?.includes('.agent/conversations/agents/') && step.inputPaths?.includes('memory/agents/design/director.md') && step.inputPaths?.includes('.agent/manifest.json') && step.inputPaths?.includes('.agent/passes/pass-1/agenda.md'), ), - 'trace missing role brief manifest or agenda inputs', + 'trace missing role brief conversation, manifest or agenda inputs', ); assert( trace.steps.some( @@ -522,11 +551,13 @@ try { step.agent === 'Generator' && step.pass === 2 && step.inputPaths?.includes('memory/blackboard.md') && + step.inputPaths?.includes('.agent/conversations/project.jsonl') && + step.inputPaths?.includes('.agent/conversations/agents/') && step.inputPaths?.includes('.agent/manifest.json') && step.inputPaths?.includes('.agent/passes/pass-2/agenda.md') && step.inputPaths?.includes('.agent/passes/pass-2/task-graph.json'), ), - 'trace missing generator manifest, agenda or task graph inputs', + 'trace missing generator conversation, manifest, agenda or task graph inputs', ); assert( gameHtml.includes('LOCAL_E2E_MECHANIC:reflect-kitchen'), @@ -578,12 +609,28 @@ async function writeSmokeLocalConfig(baseUrl) { `${JSON.stringify( { llm: { - apiKey: 'local-provider-key', - baseUrl, - model: 'local-game-creator-smoke', + apiKey: 'global-smoke-key-unused', + baseUrl: 'http://127.0.0.1:1/v1', + model: 'global-smoke-model-unused', apiKind: 'openai_chat', stream: true, }, + agentLlm: { + planner: { + apiKey: 'planner-smoke-key', + baseUrl, + model: 'planner-smoke-model', + apiKind: 'openai_chat', + stream: true, + }, + generator: { + apiKey: 'generator-smoke-key', + baseUrl, + model: 'generator-smoke-model', + apiKind: 'openai_chat', + stream: true, + }, + }, }, null, 2, @@ -687,6 +734,7 @@ function runAgent() { async function seedLocalAsset() { await fs.mkdir(path.join(projectRoot, 'assets/uploads'), { recursive: true }); await fs.mkdir(path.join(projectRoot, '.agent'), { recursive: true }); + await seedConversationContext(); await fs.writeFile(path.join(projectRoot, smokeAssetPath), smokeAssetBytes); await fs.writeFile( path.join(projectRoot, smokeAudioAssetPath), @@ -722,6 +770,39 @@ async function seedLocalAsset() { ); } +async function seedConversationContext() { + const projectConversationPath = path.join( + projectRoot, + '.agent/conversations/project.jsonl', + ); + const agentConversationPath = path.join( + projectRoot, + '.agent/conversations/agents/art-asset-plan.jsonl', + ); + await fs.mkdir(path.dirname(projectConversationPath), { recursive: true }); + await fs.mkdir(path.dirname(agentConversationPath), { recursive: true }); + await fs.writeFile( + projectConversationPath, + `${JSON.stringify({ + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: `玩家坚持使用 ${smokeProjectConversationMarker}`, + agentId: null, + updatedAt: 1, + })}\n`, + ); + await fs.writeFile( + agentConversationPath, + `${JSON.stringify({ + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: `API Key sk-smoke-secret\n美术方向 ${smokeAgentConversationMarker}`, + agentId: 'art-asset-plan', + updatedAt: 2, + })}\n`, + ); +} + function readHttpText(url) { return new Promise((resolve, reject) => { http diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.lock b/apps/ai-game-creator-shell/src-tauri/Cargo.lock index 7a35bd5b7..d71f6667e 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.lock +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.lock @@ -1317,6 +1317,7 @@ dependencies = [ "shared-contracts", "tauri", "tauri-build", + "tauri-plugin-dialog", "tauri-plugin-opener", "tokio", "zip", @@ -2501,6 +2502,7 @@ checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ "bitflags 2.13.0", "block2", + "libc", "objc2", "objc2-core-foundation", ] @@ -3205,6 +3207,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -4001,6 +4027,48 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + +[[package]] +name = "tauri-plugin-fs" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ecc274121aca0c036a2b42d1cbe83d368d348f54e0bb8a735c2b1548e8f371" +dependencies = [ + "anyhow", + "dunce", + "glob", + "log", + "objc2-foundation", + "percent-encoding", + "schemars 0.8.22", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "toml 1.1.2+spec-1.1.0", + "url", +] + [[package]] name = "tauri-plugin-opener" version = "2.5.4" @@ -5147,6 +5215,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + [[package]] name = "windows-sys" version = "0.61.2" @@ -5195,13 +5272,30 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm", + "windows_i686_gnullvm 0.52.6", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + [[package]] name = "windows-threading" version = "0.1.0" @@ -5238,6 +5332,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + [[package]] name = "windows_aarch64_msvc" version = "0.42.2" @@ -5256,6 +5356,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + [[package]] name = "windows_i686_gnu" version = "0.42.2" @@ -5274,12 +5380,24 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + [[package]] name = "windows_i686_msvc" version = "0.42.2" @@ -5298,6 +5416,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + [[package]] name = "windows_x86_64_gnu" version = "0.42.2" @@ -5316,6 +5440,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + [[package]] name = "windows_x86_64_gnullvm" version = "0.42.2" @@ -5334,6 +5464,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + [[package]] name = "windows_x86_64_msvc" version = "0.42.2" @@ -5352,6 +5488,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + [[package]] name = "winnow" version = "0.5.40" diff --git a/apps/ai-game-creator-shell/src-tauri/Cargo.toml b/apps/ai-game-creator-shell/src-tauri/Cargo.toml index 43af73677..77de932fa 100644 --- a/apps/ai-game-creator-shell/src-tauri/Cargo.toml +++ b/apps/ai-game-creator-shell/src-tauri/Cargo.toml @@ -15,6 +15,7 @@ platform-agent = { path = "../../../server-rs/crates/platform-agent" } reqwest = { version = "0.12", default-features = false, features = ["json", "native-tls"] } 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-opener = "2.5.4" tokio = { version = "1", features = ["macros", "rt-multi-thread"] } zip = { version = "2", default-features = false, features = ["deflate"] } diff --git a/apps/ai-game-creator-shell/src-tauri/src/debug.rs b/apps/ai-game-creator-shell/src-tauri/src/debug.rs index 8172834ff..61aeb07c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/debug.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/debug.rs @@ -1,3 +1,3 @@ pub(crate) mod debug_drafts; -pub(crate) use debug_drafts::*; \ No newline at end of file +pub(crate) use debug_drafts::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs b/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs index 8e0d606a3..65a9b9c69 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/debug/debug_drafts.rs @@ -4,9 +4,9 @@ //! 且非测试构建中编入二进制;生产 release 与 cargo test 下整个模块与其调用处一并被剔除, //! 不会往仓库写任何文件。 +use crate::unix_millis; use std::fs; use std::path::PathBuf; -use crate::unix_millis; // 找到仓库根(包含 apps/ai-game-creator-shell/src-tauri/Cargo.toml 的目录), // 以便把草案落到仓库内而非 tmp 项目目录。 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 b5d274220..c1c368916 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1,5 +1,6 @@ #![cfg_attr(all(not(dev), target_os = "windows"), windows_subsystem = "windows")] +use std::collections::BTreeMap; use std::fs; use std::fs::File; use std::io::{BufRead, BufReader, Read, Write}; @@ -34,6 +35,7 @@ use shared_contracts::game_creation_app::{ GAME_CREATION_APP_LIMITED_RUN_COMMANDS, }; use tauri::{Emitter, Manager}; +use tauri_plugin_dialog::DialogExt; use tauri_plugin_opener::OpenerExt; // 调试落盘模块(保存 LLM 原始输出 / 失败输入,排查截断、空返回等)放在 debug_drafts.rs。 @@ -49,6 +51,19 @@ struct InitLocalProjectResult { manifest: GameCreationAppManifest, } +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalProjectDirectoryStatus { + project_path: String, + exists: bool, + is_directory: bool, + is_game_creator_project: bool, + project_name: Option, + manifest_error: Option, + recent_run_status: Option, + recent_run_stop_reason: Option, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LocalPreviewResult { @@ -93,6 +108,22 @@ struct GameCreatorLlmConfigStatus { base_url: Option, model: Option, api_kind: String, + stream: bool, + error: Option, + agents: Vec, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorAgentLlmConfigStatus { + agent_id: String, + label: String, + configured: bool, + api_key_present: bool, + base_url: Option, + model: Option, + api_kind: String, + stream: bool, error: Option, } @@ -100,19 +131,28 @@ struct GameCreatorLlmConfigStatus { #[serde(rename_all = "camelCase")] struct GameCreatorAppConfigFile { llm: Option, + agent_llm: Option>, editor_api: Option, } #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigFile { + #[serde(skip_serializing_if = "Option::is_none")] api_key: Option, + #[serde(skip_serializing_if = "Option::is_none")] base_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] model: Option, + #[serde(skip_serializing_if = "Option::is_none")] api_kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] stream: Option, + #[serde(skip_serializing_if = "Option::is_none")] request_timeout_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] max_retries: Option, + #[serde(skip_serializing_if = "Option::is_none")] retry_backoff_ms: Option, } @@ -127,6 +167,8 @@ struct GameCreatorEditorApiConfigFile { #[serde(rename_all = "camelCase")] struct GameCreatorAppConfig { llm: GameCreatorLlmConfig, + #[serde(default)] + agent_llm: BTreeMap, editor_api: GameCreatorEditorApiConfig, } @@ -248,6 +290,15 @@ struct LocalGameMemoryResult { exists: bool, } +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalAgentMemoryResult { + task_id: String, + path: String, + content: String, + exists: bool, +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LimitedLocalCommandResult { @@ -264,6 +315,7 @@ struct LocalProjectFileEntry { path: String, kind: String, size: u64, + modified_at: u64, } #[derive(Debug, Eq, PartialEq, Serialize)] @@ -289,6 +341,32 @@ struct LocalProjectFileMutationResult { deleted: bool, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalConversationMessage { + role: String, + content: String, + agent_id: Option, +} + +#[derive(Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalConversationMessageRecord { + schema_version: String, + role: String, + content: String, + agent_id: Option, + updated_at: u64, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalConversationResult { + path: String, + agent_id: Option, + messages: Vec, +} + #[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct ProjectPermissionPolicy { @@ -351,6 +429,7 @@ struct LocalProjectDiffResult { struct LocalProjectRestoreResult { checkpoint_id: String, restored_count: usize, + deleted_count: usize, } #[derive(Default)] @@ -449,6 +528,18 @@ fn preview_open_url(status: &LocalPreviewStatus) -> Result { Err("preview is not running".to_string()) } +fn validate_preview_open_project( + status: &LocalPreviewStatus, + project_path: Option<&str>, +) -> Result<(), String> { + let Some(project_path) = project_path.map(str::trim).filter(|path| !path.is_empty()) else { + return Ok(()); + }; + let root = Path::new(project_path); + enforce_project_permission_policy(root, "preview.open")?; + ensure_preview_belongs_to_project(status, root) +} + fn ensure_preview_belongs_to_project( status: &LocalPreviewStatus, root: &Path, @@ -515,14 +606,20 @@ const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082"; const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json"); const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000; const GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS: u32 = 900; +const GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS: u32 = 1200; +const GAME_CREATOR_REQUIRED_LLM_AGENT_IDS: [&str; 2] = ["planner", "generator"]; +const MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 1_000; const GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS: u64 = 180_000; const GAME_CREATOR_AGENT_LOOP_MAX_PASSES: u8 = 3; const GAME_CREATOR_AGENT_TOOL_CALL_MAX: u16 = GAME_CREATION_AGENT_TOOL_CALL_MAX; +const GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT: usize = 100; const GAME_CREATOR_AGENT_DB_SCHEMA_VERSION: &str = "game-creator-agent-db.v1"; const PROJECT_BLACKBOARD_MEMORY_PATH: &str = "memory/blackboard.md"; const PROJECT_PERMISSION_POLICY_PATH: &str = ".agent/policy.json"; const PROJECT_INDEX_PATH: &str = ".agent/project.index.json"; const PROJECT_WRITE_LOCK_PATH: &str = ".agent/project.lock"; +const LOCAL_CONVERSATION_SCHEMA_VERSION: &str = "game-creator-conversation.v1"; +const GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES: usize = 12; const MAX_CANVAS_EXPORT_FILES: usize = 500; const MAX_CANVAS_EXPORT_BYTES: u64 = 512 * 1024 * 1024; const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [ @@ -548,6 +645,7 @@ impl Default for GameCreatorAppConfig { fn default() -> Self { Self { llm: GameCreatorLlmConfig::default(), + agent_llm: BTreeMap::new(), editor_api: GameCreatorEditorApiConfig::default(), } } @@ -770,6 +868,44 @@ const GAME_CREATOR_AGENT_GROUP_DEFINITIONS: [AgentGroupDefinition; 6] = [ }, ]; +struct GameCreatorLlmAgentStatusDefinition { + agent_id: String, + label: String, +} + +fn game_creator_llm_agent_status_definitions() -> Vec { + let mut agents = vec![ + GameCreatorLlmAgentStatusDefinition { + agent_id: "planner".to_string(), + label: "Planner".to_string(), + }, + GameCreatorLlmAgentStatusDefinition { + agent_id: "orchestrator".to_string(), + label: "Orchestrator".to_string(), + }, + GameCreatorLlmAgentStatusDefinition { + agent_id: "generator".to_string(), + label: "Generator".to_string(), + }, + GameCreatorLlmAgentStatusDefinition { + agent_id: "evaluator".to_string(), + label: "Evaluator".to_string(), + }, + ]; + for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + for role in group.roles { + if agents.iter().any(|agent| agent.agent_id == role.task_id) { + continue; + } + agents.push(GameCreatorLlmAgentStatusDefinition { + agent_id: role.task_id.to_string(), + label: format!("{} / {}", group.label, role.role), + }); + } + } + agents +} + #[derive(Clone, Debug)] struct AgentPassArtifactPaths { draft_json: String, @@ -870,6 +1006,140 @@ fn init_local_game_project( init_local_game_project_at(root, project_id.trim(), name.trim()) } +#[tauri::command] +fn is_local_project_directory_non_empty(project_path: String) -> Result { + let root = Path::new(project_path.trim()); + 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.exists() { + return Ok(false); + } + if !root.is_dir() { + return Err("项目目录已存在但不是文件夹".to_string()); + } + fs::read_dir(root) + .map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))? + .next() + .transpose() + .map_err(|error| format!("读取项目目录失败:{}: {error}", root.display())) + .map(|entry| entry.is_some()) +} + +#[tauri::command] +fn inspect_local_project_directory( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + 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()); + } + let recent_run_trace = recent_game_creator_run_trace(root); + Ok(LocalProjectDirectoryStatus { + project_path: root.to_string_lossy().into_owned(), + exists: root.exists(), + is_directory: root.is_dir(), + is_game_creator_project: is_game_creator_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()), + recent_run_stop_reason: recent_run_trace.map(|trace| trace.stop_reason), + }) +} + +fn is_game_creator_project_directory(root: &Path) -> bool { + if !root.is_dir() { + return false; + } + let manifest_path = root.join(".agent/manifest.json"); + manifest_path.is_file() && read_manifest(&manifest_path).is_ok() +} + +fn game_creator_project_name(root: &Path) -> Option { + let manifest_path = root.join(".agent/manifest.json"); + let manifest = read_manifest(&manifest_path).ok()?; + let name = manifest.name.trim(); + if name.is_empty() { + None + } else { + Some(name.to_string()) + } +} + +fn game_creator_project_manifest_error(root: &Path) -> Option { + if !root.is_dir() { + return None; + } + let manifest_path = root.join(".agent/manifest.json"); + if !manifest_path.is_file() { + return None; + } + read_manifest(&manifest_path).err() +} + +fn recent_game_creator_run_trace(root: &Path) -> Option { + let trace_path = root.join(".agent/run.latest.json"); + let content = fs::read_to_string(trace_path).ok()?; + serde_json::from_str::(&content).ok() +} + +#[tauri::command] +fn pick_local_project_directory(app: tauri::AppHandle) -> Result, String> { + let Some(path) = app.dialog().file().blocking_pick_folder() else { + return Ok(None); + }; + path.into_path() + .map(|path| Some(path.to_string_lossy().into_owned())) + .map_err(|error| format!("读取项目目录失败:{error}")) +} + +#[tauri::command] +fn pick_local_file(app: tauri::AppHandle) -> Result, String> { + let Some(path) = app.dialog().file().blocking_pick_file() else { + return Ok(None); + }; + path.into_path() + .map(|path| Some(path.to_string_lossy().into_owned())) + .map_err(|error| format!("读取本地文件失败:{error}")) +} + +#[tauri::command] +fn open_local_project_directory(app: tauri::AppHandle, project_path: String) -> Result<(), String> { + let path = validated_local_project_directory_path(project_path.trim())?; + app.opener() + .open_path(path.to_string_lossy().into_owned(), None::<&str>) + .map_err(|error| format!("打开项目目录失败:{error}")) +} + +fn validated_local_project_directory_path(project_path: &str) -> Result { + let path = Path::new(project_path); + if path.as_os_str().is_empty() { + return Err("项目目录不能为空".to_string()); + } + if !path.is_absolute() { + return Err("项目目录必须是绝对路径".to_string()); + } + if !path.exists() { + return Err("项目目录不存在".to_string()); + } + if !path.is_dir() { + return Err("项目路径不是文件夹".to_string()); + } + Ok(path.to_path_buf()) +} + #[tauri::command] fn start_local_game_preview( project_path: String, @@ -921,8 +1191,15 @@ fn stop_local_game_preview( } else { None }; - let (status, _) = registry.stop_for_project(root); - if let Some(root) = root { + stop_local_game_preview_for_root(root, ®istry) +} + +fn stop_local_game_preview_for_root( + root: Option<&Path>, + 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)?; @@ -934,8 +1211,22 @@ fn stop_local_game_preview( fn get_local_game_preview_status( registry: tauri::State<'_, PreviewRegistry>, project_path: Option, -) -> LocalPreviewStatus { - filter_preview_status_for_project(registry.status(), project_path.as_deref()) +) -> Result { + get_local_game_preview_status_at(®istry, project_path.as_deref()) +} + +fn get_local_game_preview_status_at( + registry: &PreviewRegistry, + project_path: Option<&str>, +) -> Result { + let project_path = project_path.map(str::trim).filter(|path| !path.is_empty()); + if let Some(project_path) = project_path { + enforce_project_permission_policy(Path::new(project_path), "preview.status")?; + } + Ok(filter_preview_status_for_project( + registry.status(), + project_path, + )) } #[tauri::command] @@ -945,13 +1236,8 @@ fn open_local_game_preview( project_path: Option, ) -> Result { let status = registry.status(); + validate_preview_open_project(&status, project_path.as_deref())?; let url = preview_open_url(&status)?; - if let Some(project_path) = project_path { - let project_path = project_path.trim(); - if !project_path.is_empty() { - ensure_preview_belongs_to_project(&status, Path::new(project_path))?; - } - } app.opener() .open_url(&url, None::<&str>) .map_err(|error| format!("preview open failed: {error}"))?; @@ -959,12 +1245,29 @@ fn open_local_game_preview( } #[tauri::command] -fn get_local_game_manifest(project_path: String) -> Result { - read_manifest_for_project(Path::new(project_path.trim())) +fn get_local_game_manifest( + project_path: String, + command_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + let command_id = command_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("project.status"); + if !matches!( + command_id, + "project.status" | "asset.list" | "task.list" | "agent.audit" + ) { + return Err(format!("不支持通过 manifest 执行命令:{command_id}")); + } + enforce_project_permission_policy(root, command_id)?; + read_manifest_for_project(root) } #[tauri::command] -fn control_agent_run( +async fn control_agent_run( + app: tauri::AppHandle, project_path: String, action: String, detail: Option, @@ -978,19 +1281,24 @@ fn control_agent_run( _ => "agent.run_status", }; enforce_project_permission_policy(root, command_id)?; + if matches!(action.trim(), "retry" | "resume") { + enforce_project_permission_policy(root, "game.generate_draft")?; + } let _lock = if command_id == "agent.run_status" { None } else { Some(acquire_project_write_lock(root, command_id)?) }; - update_agent_run_lifecycle( + control_agent_run_at( root, action.trim(), detail .as_deref() .map(str::trim) .filter(|value| !value.is_empty()), + Some(&AgentProgressEmitter::new(&app, project_path.trim())), ) + .await } #[tauri::command] @@ -1144,6 +1452,18 @@ async fn sync_canvas_project_assets( sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await } +#[tauri::command] +async fn generate_platform_art_asset( + project_path: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.asset_generate")?; + let _lock = acquire_project_write_lock(root, "canvas.asset_generate")?; + let generated = generate_platform_art_asset_at(root, prompt.trim(), &[]).await?; + Ok(generated.asset) +} + #[tauri::command] fn open_canvas_project( app: tauri::AppHandle, @@ -1198,15 +1518,33 @@ fn append_local_permission_log( #[tauri::command] fn list_local_project_files(project_path: String) -> Result { - list_local_project_files_at(Path::new(project_path.trim())) + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "file.list")?; + list_local_project_files_at(root) } #[tauri::command] fn read_local_project_file( project_path: String, relative_path: String, + command_id: Option, ) -> Result { - read_local_project_file_at(Path::new(project_path.trim()), relative_path.trim()) + let root = Path::new(project_path.trim()); + let normalized_path = normalize_relative_path(relative_path.trim())?; + let command_id = command_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("file.read"); + if command_id == "agent.trace_read" { + if !is_agent_trace_read_path(&normalized_path) { + return Err("agent.trace_read 只能读取 Agent run trace".to_string()); + } + } else if command_id != "file.read" { + return Err(format!("不支持通过文件读取执行命令:{command_id}")); + } + enforce_project_permission_policy(root, command_id)?; + read_local_project_file_at(root, &normalized_path) } #[tauri::command] @@ -1237,7 +1575,19 @@ fn read_local_game_memory( project_path: String, scope: String, ) -> Result { - read_local_game_memory_at(Path::new(project_path.trim()), scope.trim()) + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "memory.read")?; + read_local_game_memory_at(root, scope.trim()) +} + +#[tauri::command] +fn read_local_agent_memory( + project_path: String, + task_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "memory.read")?; + read_local_agent_memory_at(root, task_id.trim()) } #[tauri::command] @@ -1263,6 +1613,28 @@ fn delete_local_game_memory( delete_local_game_memory_at(root, scope.trim()) } +#[tauri::command] +fn read_local_conversation( + project_path: String, + agent_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_local_conversation_at(root, agent_id.as_deref()) +} + +#[tauri::command] +fn append_local_conversation_message( + project_path: String, + agent_id: Option, + message: LocalConversationMessage, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.write")?; + let _lock = acquire_project_write_lock(root, "conversation.write")?; + append_local_conversation_message_at(root, agent_id.as_deref(), message) +} + #[tauri::command] fn build_local_project_index(project_path: String) -> Result { let root = Path::new(project_path.trim()); @@ -1306,7 +1678,9 @@ fn restore_local_project_checkpoint( fn read_project_permission_policy( project_path: String, ) -> Result { - read_project_permission_policy_at(Path::new(project_path.trim())) + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.policy_read")?; + read_project_permission_policy_at(root) } #[tauri::command] @@ -1331,6 +1705,9 @@ fn init_local_game_project_at( if !root.is_absolute() { return Err("项目目录必须是绝对路径".to_string()); } + if project_path_has_control_chars(root) { + return Err("项目目录不能包含控制字符".to_string()); + } if project_id.is_empty() { return Err("项目 ID 不能为空".to_string()); } @@ -1466,6 +1843,8 @@ async fn generate_local_game_draft_at( let short_memory = read_optional_text(&root.join("memory/session.md"))?; let long_memory = read_optional_text(&root.join("memory/project.md"))?; let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?; + let conversation_context = render_local_conversation_prompt_context(root, None)?; + let short_memory = append_prompt_context(&conversation_context, &short_memory); let asset_context = render_local_asset_prompt_context(root)?; let long_memory = append_prompt_context(&asset_context, &long_memory); emit_agent_progress( @@ -1473,10 +1852,10 @@ async fn generate_local_game_draft_at( "llm.planner", "Planner 正在调用 LLM 整理规格和专业组分工", ); - let client = build_game_creator_llm_client_from_config()?; + let app_config = load_game_creator_app_config()?; let loop_result = run_game_creator_agent_loop_at( root, - &client, + &app_config, prompt, &short_memory, &long_memory, @@ -1643,18 +2022,17 @@ fn write_local_game_draft_at( }) } -fn build_game_creator_llm_client_from_config() -> Result { - let app_config = load_game_creator_app_config()?; - let llm = &app_config.llm; - let api_key = trim_config_string(&llm.api_key).ok_or_else(llm_api_key_config_error)?; - let base_url = trim_config_string(&llm.base_url).ok_or_else(llm_base_url_config_error)?; - let model = trim_config_string(&llm.model).ok_or_else(llm_model_config_error)?; - if llm.request_timeout_ms == 0 { - return Err("配置项 llm.requestTimeoutMs 必须大于 0".to_string()); - } - if llm.retry_backoff_ms == 0 { - return Err("配置项 llm.retryBackoffMs 必须大于 0".to_string()); - } +fn build_game_creator_llm_client_from_llm_config( + llm: &GameCreatorLlmConfig, + config_path: &str, +) -> Result { + let api_key = + trim_config_string(&llm.api_key).ok_or_else(|| llm_api_key_config_error(config_path))?; + let base_url = + trim_config_string(&llm.base_url).ok_or_else(|| llm_base_url_config_error(config_path))?; + let model = + trim_config_string(&llm.model).ok_or_else(|| llm_model_config_error(config_path))?; + validate_game_creator_llm_timing_config(llm, config_path)?; let config = LlmConfig::new( LlmProvider::OpenAiCompatible, base_url, @@ -1669,9 +2047,9 @@ fn build_game_creator_llm_client_from_config() -> Result { LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}")) } -fn read_game_creator_llm_api_kind_from_config() -> Result { +fn build_game_creator_llm_client_from_config() -> Result { let app_config = load_game_creator_app_config()?; - parse_game_creator_llm_api_kind(&app_config.llm.api_kind) + build_game_creator_llm_client_from_llm_config(&app_config.llm, "llm") } fn parse_game_creator_llm_api_kind(value: &str) -> Result { @@ -1701,11 +2079,13 @@ fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfigStatus { base_url: None, model: None, api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), + stream: false, error: Some(error), + agents: Vec::new(), } } }; - let mut status = check_game_creator_llm_config_values(&app_config.llm); + let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm"); status.api_kind = parse_game_creator_llm_api_kind(&app_config.llm.api_kind) .map(game_creator_llm_api_kind_name) .unwrap_or_else(|error| { @@ -1719,11 +2099,42 @@ fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfigStatus { status.error = Some(error); } } + status.agents = game_creator_llm_agent_status_definitions() + .iter() + .map(|definition| { + let llm = resolve_game_creator_llm_config_for_agent(&app_config, &definition.agent_id); + check_game_creator_agent_llm_config_values( + &definition.agent_id, + &definition.label, + &llm, + ) + }) + .collect(); + let agent_errors = status + .agents + .iter() + .filter(|agent| GAME_CREATOR_REQUIRED_LLM_AGENT_IDS.contains(&agent.agent_id.as_str())) + .filter(|agent| !agent.configured) + .map(|agent| { + format!( + "{}:{}", + agent.label, + agent.error.as_deref().unwrap_or("配置不完整") + ) + }) + .collect::>(); + status.configured = agent_errors.is_empty(); + status.error = if agent_errors.is_empty() { + None + } else { + Some(agent_errors.join(";")) + }; status } fn check_game_creator_llm_config_values( config: &GameCreatorLlmConfig, + config_path: &str, ) -> GameCreatorLlmConfigStatus { let api_key = trim_config_string(&config.api_key); let base_url = trim_config_string(&config.base_url); @@ -1732,21 +2143,27 @@ fn check_game_creator_llm_config_values( .as_ref() .is_some_and(|value| !value.trim().is_empty()); let error = match (api_key.as_deref(), base_url.as_deref(), model.as_deref()) { - (None, _, _) => Some(llm_api_key_config_error()), - (_, None, _) => Some(llm_base_url_config_error()), - (_, _, None) => Some(llm_model_config_error()), - (Some(api_key), Some(base_url), Some(model)) => LlmConfig::new( - LlmProvider::OpenAiCompatible, - base_url.to_string(), - api_key.to_string(), - model.to_string(), - config.request_timeout_ms, - config.max_retries, - config.retry_backoff_ms, - ) - .and_then(LlmClient::new) - .err() - .map(|error| format!("LLM 配置无效:{error}")), + (None, _, _) => Some(llm_api_key_config_error(config_path)), + (_, None, _) => Some(llm_base_url_config_error(config_path)), + (_, _, None) => Some(llm_model_config_error(config_path)), + (Some(api_key), Some(base_url), Some(model)) => { + validate_game_creator_llm_timing_config(config, config_path) + .err() + .or_else(|| { + LlmConfig::new( + LlmProvider::OpenAiCompatible, + base_url.to_string(), + api_key.to_string(), + model.to_string(), + config.request_timeout_ms, + config.max_retries, + config.retry_backoff_ms, + ) + .and_then(LlmClient::new) + .err() + .map(|error| format!("LLM 配置无效:{error}")) + }) + } }; GameCreatorLlmConfigStatus { @@ -1755,10 +2172,60 @@ fn check_game_creator_llm_config_values( base_url, model, api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(), + stream: config.stream, error, + agents: Vec::new(), } } +fn check_game_creator_agent_llm_config_values( + agent_id: &str, + label: &str, + config: &GameCreatorLlmConfig, +) -> GameCreatorAgentLlmConfigStatus { + let config_path = format!("agentLlm.{agent_id}"); + let mut status = check_game_creator_llm_config_values(config, &config_path); + status.api_kind = parse_game_creator_llm_api_kind(&config.api_kind) + .map(game_creator_llm_api_kind_name) + .unwrap_or_else(|error| { + status.configured = false; + status.error = Some(error); + DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string() + }); + if status.configured { + if let Err(error) = build_game_creator_llm_client_from_llm_config(config, &config_path) { + status.configured = false; + status.error = Some(error); + } + } + GameCreatorAgentLlmConfigStatus { + agent_id: agent_id.to_string(), + label: label.to_string(), + configured: status.configured, + api_key_present: status.api_key_present, + base_url: status.base_url, + model: status.model, + api_kind: status.api_kind, + stream: config.stream, + error: status.error, + } +} + +fn validate_game_creator_llm_timing_config( + config: &GameCreatorLlmConfig, + config_path: &str, +) -> Result<(), String> { + if config.request_timeout_ms < MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS { + return Err(format!( + "配置项 {config_path}.requestTimeoutMs 必须至少为 {MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS}" + )); + } + if config.retry_backoff_ms == 0 { + return Err(format!("配置项 {config_path}.retryBackoffMs 必须大于 0")); + } + Ok(()) +} + fn game_creator_llm_api_kind_name(api_kind: LlmApiKind) -> String { match api_kind { LlmApiKind::OpenAiChat => "openai_chat", @@ -1806,8 +2273,10 @@ async fn request_llm_game_draft_with_client( short_memory: &str, long_memory: &str, ) -> Result { + let llm = GameCreatorLlmConfig::default(); request_generator_game_draft_with_client( client, + &llm, prompt, short_memory, long_memory, @@ -1823,7 +2292,7 @@ async fn request_llm_game_draft_with_client( async fn run_game_creator_agent_loop_at( root: &Path, - client: &LlmClient, + app_config: &GameCreatorAppConfig, prompt: &str, short_memory: &str, long_memory: &str, @@ -1834,8 +2303,12 @@ async fn run_game_creator_agent_loop_at( let findings_path = root.join(".agent/findings.md"); let run_id = format!("game-generate-draft-{}", unix_millis()); let mut steps = Vec::new(); + let planner_llm = resolve_game_creator_llm_config_for_agent(app_config, "planner"); + let planner_client = + build_game_creator_llm_client_from_llm_config(&planner_llm, "agentLlm.planner")?; let planner_spec = request_planner_spec_with_client( - client, + &planner_client, + &planner_llm, prompt, short_memory, long_memory, @@ -1855,6 +2328,8 @@ async fn run_game_creator_agent_loop_at( "memory/session.md", "memory/project.md", PROJECT_BLACKBOARD_MEMORY_PATH, + ".agent/conversations/project.jsonl", + ".agent/conversations/agents/", ".agent/manifest.json", ], &[".agent/spec.md"], @@ -1923,7 +2398,7 @@ async fn run_game_creator_agent_loop_at( )); let group_briefs = match request_agent_group_briefs_with_client( root, - client, + app_config, prompt, short_memory, long_memory, @@ -1987,7 +2462,11 @@ async fn run_game_creator_agent_loop_at( steps.push(step); } let group_briefs_markdown = append_prompt_context( + &render_local_conversation_prompt_context(root, Some("*"))?, &render_agent_group_briefs_context(&group_briefs), + ); + let group_briefs_markdown = append_prompt_context( + &group_briefs_markdown, &render_local_asset_prompt_context(root)?, ); emit_agent_progress( @@ -1995,8 +2474,12 @@ async fn run_game_creator_agent_loop_at( "llm.generator", &format!("Generator 正在调用 LLM 生成第 {pass} 轮可运行草案"), ); + let generator_llm = resolve_game_creator_llm_config_for_agent(app_config, "generator"); + let generator_client = + build_game_creator_llm_client_from_llm_config(&generator_llm, "agentLlm.generator")?; match request_generator_game_draft_with_client( - client, + &generator_client, + &generator_llm, prompt, short_memory, long_memory, @@ -2020,6 +2503,8 @@ async fn run_game_creator_agent_loop_at( "memory/session.md".to_string(), "memory/project.md".to_string(), PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + ".agent/conversations/project.jsonl".to_string(), + ".agent/conversations/agents/".to_string(), ".agent/manifest.json".to_string(), ".agent/spec.md".to_string(), ".agent/findings.md".to_string(), @@ -2157,6 +2642,7 @@ async fn run_game_creator_agent_loop_at( async fn request_planner_spec_with_client( client: &LlmClient, + llm: &GameCreatorLlmConfig, prompt: &str, short_memory: &str, long_memory: &str, @@ -2171,9 +2657,9 @@ async fn request_planner_spec_with_client( project_blackboard, )), ]) - .with_api_kind(read_game_creator_llm_api_kind_from_config()?) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) .with_max_output_tokens(GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS); - let response = request_game_creator_llm_text(client, request) + let response = request_game_creator_llm_text(client, llm, request) .await .map_err(|error| format!("Planner 生成失败:{error}"))?; let spec = strip_llm_thinking_blocks(response.text.as_str()); @@ -2186,6 +2672,7 @@ async fn request_planner_spec_with_client( async fn request_generator_game_draft_with_client( client: &LlmClient, + llm: &GameCreatorLlmConfig, prompt: &str, short_memory: &str, long_memory: &str, @@ -2217,9 +2704,9 @@ async fn request_generator_game_draft_with_client( LlmMessage::system(system_prompt), LlmMessage::user(user_prompt.clone()), ]) - .with_api_kind(read_game_creator_llm_api_kind_from_config()?) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) .with_max_output_tokens(GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS); - match request_game_creator_llm_text(client, request).await { + match request_game_creator_llm_text(client, llm, request).await { Ok(response) => break response, Err(platform_llm::LlmError::EmptyResponse) if empty_retries < MAX_EMPTY_RETRIES => { empty_retries += 1; @@ -2253,24 +2740,19 @@ async fn request_generator_game_draft_with_client( async fn request_game_creator_llm_text( client: &LlmClient, + llm: &GameCreatorLlmConfig, request: LlmRunRequest, ) -> Result { - if game_creator_llm_stream_enabled() { + if llm.stream { client.stream_run(request, |_| {}).await } else { client.run(request).await } } -fn game_creator_llm_stream_enabled() -> bool { - load_game_creator_app_config() - .map(|config| config.llm.stream) - .unwrap_or(false) -} - async fn request_agent_group_briefs_with_client( root: &Path, - _client: &LlmClient, + app_config: &GameCreatorAppConfig, prompt: &str, short_memory: &str, long_memory: &str, @@ -2336,11 +2818,15 @@ async fn request_agent_group_briefs_with_client( continue; } let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?; - let markdown = render_local_agent_role_brief( + let agent_conversation_context = + render_local_conversation_prompt_context(root, Some(role_definition.task_id))?; + let role_short_memory = + append_prompt_context(&agent_conversation_context, short_memory); + let local_markdown = render_local_agent_role_brief( definition, *role_definition, prompt, - short_memory, + &role_short_memory, long_memory, project_blackboard, &agent_memory, @@ -2351,6 +2837,32 @@ async fn request_agent_group_briefs_with_client( &completed_role_context, pass, ); + let (markdown, tool_id, summary) = + if has_game_creator_agent_llm_override(app_config, role_definition.task_id) { + let markdown = request_agent_role_brief_with_config( + app_config, + role_definition.task_id, + &local_markdown, + ) + .await?; + ( + markdown, + format!("llm.chat.{}", role_definition.task_id), + format!( + "{} / {} 使用 agentLlm.{} 生成 brief", + definition.label, role_definition.role, role_definition.task_id + ), + ) + } else { + ( + local_markdown, + role_definition.tool_id.to_string(), + format!( + "本地编排生成 {} / {} brief", + definition.label, role_definition.role + ), + ) + }; let relative_path = write_agent_role_brief(root, pass, definition, *role_definition, &markdown)?; let role_brief = AgentRoleBrief { @@ -2360,11 +2872,8 @@ async fn request_agent_group_briefs_with_client( relative_path, memory_relative_path: agent_memory_relative_path, status: "completed".to_string(), - tool_id: role_definition.tool_id.to_string(), - summary: format!( - "本地编排生成 {} / {} brief", - definition.label, role_definition.role - ), + tool_id, + summary, }; completed_role_context.push_str(&render_agent_role_brief_context(&role_brief)); role_briefs.push(role_brief); @@ -2387,6 +2896,45 @@ async fn request_agent_group_briefs_with_client( Ok(briefs) } +fn has_game_creator_agent_llm_override(config: &GameCreatorAppConfig, agent_id: &str) -> bool { + config + .agent_llm + .get(agent_id) + .is_some_and(|patch| !is_empty_game_creator_llm_patch(patch)) +} + +async fn request_agent_role_brief_with_config( + config: &GameCreatorAppConfig, + agent_id: &str, + local_markdown: &str, +) -> Result { + let llm = resolve_game_creator_llm_config_for_agent(config, agent_id); + let config_path = format!("agentLlm.{agent_id}"); + let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let request = LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_role_agent_system_prompt()), + LlmMessage::user(format!( + "请基于下面的本地上下文生成本角色的 Markdown brief。只返回 brief 正文,不要代码块。\n\n{}", + truncate_prompt_context(local_markdown) + )), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS); + let response = request_game_creator_llm_text(&client, &llm, request) + .await + .map_err(|error| format!("{config_path} 生成角色 brief 失败:{error}"))?; + let brief = strip_llm_thinking_blocks(response.text.as_str()); + if brief.is_empty() { + Err(format!("{config_path} 未返回角色 brief")) + } else { + Ok(brief) + } +} + +fn game_creator_role_agent_system_prompt() -> &'static str { + "你是 AI 游戏创作多智能体中的一个专业角色 agent。输出必须是简洁、可执行的 Markdown brief,服务于后续 Generator 生成可试玩 Web 小游戏原型。不要泄露密钥,不要输出 JSON,不要包裹代码块。" +} + fn render_local_agent_role_brief( group_definition: AgentGroupDefinition, role_definition: AgentRoleDefinition, @@ -2414,7 +2962,7 @@ fn render_local_agent_role_brief( truncate_prompt_context(spec_markdown), truncate_prompt_context(findings_markdown), truncate_prompt_context(agenda_markdown), - truncate_inline(short_memory, 120), + truncate_inline(short_memory, 360), truncate_inline(long_memory, 120), truncate_inline(project_blackboard, 160), truncate_inline(agent_memory, 160), @@ -2859,6 +3407,17 @@ fn agent_role_memory_relative_path( ) } +fn agent_role_memory_relative_path_for_task(task_id: &str) -> Result { + for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + for role in group.roles { + if role.task_id == task_id { + return Ok(agent_role_memory_relative_path(group, *role)); + } + } + } + Err(format!("未知 Agent 任务:{task_id}")) +} + fn append_agent_success_memories( root: &Path, pass: u8, @@ -2922,6 +3481,8 @@ fn append_group_brief_steps( "memory/session.md".to_string(), "memory/project.md".to_string(), PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + ".agent/conversations/project.jsonl".to_string(), + ".agent/conversations/agents/".to_string(), role_brief.memory_relative_path.clone(), ".agent/manifest.json".to_string(), ".agent/spec.md".to_string(), @@ -3113,6 +3674,7 @@ async fn generate_platform_art_asset_at( prompt: &str, briefs: &[AgentGroupBrief], ) -> Result { + enforce_project_permission_policy(root, "canvas.asset_generate")?; init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; let api_base_url = resolve_canvas_sync_api_base_url(None)?; let api_key = resolve_canvas_sync_api_key(None)?; @@ -4381,20 +4943,7 @@ fn update_agent_run_lifecycle( action: &str, detail: Option<&str>, ) -> Result { - let trace_path = root.join(".agent/run.latest.json"); - let content = fs::read_to_string(&trace_path).map_err(|error| { - format!( - "读取 Agent run trace 失败:{}: {error}", - trace_path.display() - ) - })?; - let mut trace = - serde_json::from_str::(&content).map_err(|error| { - format!( - "解析 Agent run trace 失败:{}: {error}", - trace_path.display() - ) - })?; + let mut trace = read_latest_agent_run_trace(root)?; let (status, lifecycle_status, next_step, event, message) = match action { "status" => { let lifecycle = trace @@ -4422,19 +4971,19 @@ fn update_agent_run_lifecycle( "retry" => ( "pending".to_string(), "pending".to_string(), - "runner-claim".to_string(), + "rerun-now".to_string(), "agent.retry", - format!("run {} 已重试,等待下一次 claim", trace.run_id), + format!("run {} 已请求重试", trace.run_id), ), "resume" => ( "pending".to_string(), "pending".to_string(), - "runner-claim".to_string(), + "rerun-now".to_string(), "agent.resume", format!( "run {} 已恢复:{}", trace.run_id, - detail.unwrap_or("等待下一次 claim") + detail.unwrap_or("继续运行最近目标") ), ), _ => return Err("未知 Agent run 控制动作".to_string()), @@ -4464,12 +5013,76 @@ fn update_agent_run_lifecycle( append_agent_run_output(root, &trace.run_id, event, &message)?; write_agent_run_context_bundle(root, &trace)?; + agent_run_control_result_from_trace(root, trace, message) +} + +async fn control_agent_run_at( + root: &Path, + action: &str, + detail: Option<&str>, + progress: Option<&AgentProgressEmitter<'_>>, +) -> Result { + let previous_trace = read_latest_agent_run_trace(root)?; + let control_result = update_agent_run_lifecycle(root, action, detail)?; + if !matches!(action, "retry" | "resume") { + return Ok(control_result); + } + + let prompt = resumed_agent_run_prompt(&previous_trace.goal, action, detail); + let generated = generate_local_game_draft_at(root, &prompt, progress).await?; + let trace = read_latest_agent_run_trace(root)?; + let message = format!( + "{},已重新运行为 {}:{}", + control_result.message, trace.run_id, generated.game_index_path + ); + let event = if action == "retry" { + "agent.retry.run" + } else { + "agent.resume.run" + }; + append_agent_run_activity(root, &trace.run_id, event, &message)?; + append_agent_run_output(root, &trace.run_id, event, &message)?; + write_agent_run_context_bundle(root, &trace)?; + agent_run_control_result_from_trace(root, trace, message) +} + +fn resumed_agent_run_prompt(goal: &str, action: &str, detail: Option<&str>) -> String { + let goal = goal.trim(); + let detail = detail.map(str::trim).filter(|value| !value.is_empty()); + match (action, detail) { + ("resume", Some(detail)) => format!("{goal}\n\n继续说明:{detail}"), + _ => goal.to_string(), + } +} + +fn read_latest_agent_run_trace(root: &Path) -> Result { + let trace_path = root.join(".agent/run.latest.json"); + let content = fs::read_to_string(&trace_path).map_err(|error| { + format!( + "读取 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + })?; + serde_json::from_str::(&content).map_err(|error| { + format!( + "解析 Agent run trace 失败:{}: {error}", + trace_path.display() + ) + }) +} + +fn agent_run_control_result_from_trace( + root: &Path, + trace: GameCreationAgentRunTrace, + message: String, +) -> Result { Ok(AgentRunControlResult { run_id: trace.run_id, - status: trace.status, lifecycle_status: trace .lifecycle_status - .unwrap_or_else(|| agent_run_lifecycle_status("scheduled").to_string()), + .clone() + .unwrap_or_else(|| agent_run_lifecycle_status(&trace.status).to_string()), + status: trace.status, next_step: trace.next_step, message, activity_path: root @@ -4532,7 +5145,53 @@ fn write_agent_run_trace_payload( "写入 Agent run history 失败:{}: {error}", run_path.display() ) - }) + })?; + prune_agent_run_history(&run_dir) +} + +fn prune_agent_run_history(run_dir: &Path) -> Result<(), String> { + let entries = match fs::read_dir(run_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(format!( + "读取 Agent run history 失败:{}: {error}", + run_dir.display() + )); + } + }; + let mut run_files = Vec::new(); + for entry in entries { + let entry = entry.map_err(|error| { + format!( + "读取 Agent run history 失败:{}: {error}", + run_dir.display() + ) + })?; + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("json") { + continue; + } + let updated_at = fs::read_to_string(&path) + .ok() + .and_then(|content| serde_json::from_str::(&content).ok()) + .map(|trace| trace.updated_at) + .unwrap_or(0); + run_files.push((updated_at, path)); + } + if run_files.len() <= GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT { + return Ok(()); + } + run_files.sort_by(|left, right| right.0.cmp(&left.0).then_with(|| right.1.cmp(&left.1))); + for (_, path) in run_files + .into_iter() + .skip(GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT) + { + fs::remove_file(&path).map_err(|error| { + format!("删除旧 Agent run history 失败:{}: {error}", path.display()) + })?; + } + Ok(()) } fn collect_agent_run_artifacts(root: &Path) -> Result, String> { @@ -5059,6 +5718,120 @@ fn render_local_asset_prompt_context(root: &Path) -> Result { Ok(output) } +fn render_local_conversation_prompt_context( + root: &Path, + agent_id: Option<&str>, +) -> Result { + #[derive(Debug)] + struct ConversationPromptEntry { + updated_at: u64, + agent_label: String, + role: String, + content: String, + } + + fn push_conversation_entries( + entries: &mut Vec, + conversation: LocalConversationResult, + agent_label: &str, + ) { + for message in conversation.messages { + let content = sanitize_prompt_context(&message.content) + .split_whitespace() + .collect::>() + .join(" "); + if content.is_empty() { + continue; + } + entries.push(ConversationPromptEntry { + updated_at: message.updated_at, + agent_label: agent_label.to_string(), + role: message.role, + content, + }); + } + } + + validate_project_root(root)?; + let mut entries = Vec::new(); + push_conversation_entries( + &mut entries, + read_local_conversation_at(root, None)?, + "project", + ); + + if let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) { + if agent_id == "*" { + let agents_dir = root.join(".agent/conversations/agents"); + match fs::read_dir(&agents_dir) { + Ok(read_dir) => { + let mut agent_ids = Vec::new(); + for entry in read_dir { + let entry = entry.map_err(|error| { + format!("读取 Agent 对话目录失败:{}: {error}", agents_dir.display()) + })?; + let path = entry.path(); + if path.extension().and_then(|value| value.to_str()) != Some("jsonl") { + continue; + } + if let Some(agent_id) = path.file_stem().and_then(|value| value.to_str()) { + agent_ids.push(agent_id.to_string()); + } + } + agent_ids.sort(); + for agent_id in agent_ids { + push_conversation_entries( + &mut entries, + read_local_conversation_at(root, Some(&agent_id))?, + &agent_id, + ); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(format!( + "读取 Agent 对话目录失败:{}: {error}", + agents_dir.display() + )); + } + } + } else { + push_conversation_entries( + &mut entries, + read_local_conversation_at(root, Some(agent_id))?, + agent_id, + ); + } + } + + if entries.is_empty() { + return Ok(String::new()); + } + entries.sort_by(|left, right| { + right + .updated_at + .cmp(&left.updated_at) + .then_with(|| left.agent_label.cmp(&right.agent_label)) + .then_with(|| left.role.cmp(&right.role)) + }); + entries.truncate(GAME_CREATOR_CONVERSATION_CONTEXT_MAX_MESSAGES); + entries.sort_by(|left, right| { + left.updated_at + .cmp(&right.updated_at) + .then_with(|| left.agent_label.cmp(&right.agent_label)) + .then_with(|| left.role.cmp(&right.role)) + }); + + let mut output = "# 最近对话上下文\n\n".to_string(); + for entry in entries { + output.push_str(&format!( + "- [{} / {}] {}\n", + entry.agent_label, entry.role, entry.content + )); + } + Ok(output) +} + fn asset_source_kind_label(kind: &GameCreationAppAssetSourceKind) -> &'static str { match kind { GameCreationAppAssetSourceKind::Uploaded => "uploaded", @@ -5256,6 +6029,12 @@ fn merge_game_creator_config_file( if let Some(llm) = file_config.llm { merge_game_creator_llm_config(&mut config.llm, llm); } + if let Some(agent_llm) = file_config.agent_llm { + for (agent_id, patch) in agent_llm { + let entry = config.agent_llm.entry(agent_id).or_default(); + merge_game_creator_llm_patch(entry, patch); + } + } if let Some(editor_api) = file_config.editor_api { merge_game_creator_editor_api_config(&mut config.editor_api, editor_api); } @@ -5292,6 +6071,47 @@ fn merge_game_creator_llm_config( } } +fn merge_game_creator_llm_patch( + config: &mut GameCreatorLlmConfigFile, + patch: GameCreatorLlmConfigFile, +) { + if let Some(value) = patch.api_key { + config.api_key = Some(value); + } + if let Some(value) = patch.base_url { + config.base_url = Some(value); + } + if let Some(value) = patch.model { + config.model = Some(value); + } + if let Some(value) = patch.api_kind { + config.api_kind = Some(value); + } + if let Some(value) = patch.stream { + config.stream = Some(value); + } + if let Some(value) = patch.request_timeout_ms { + config.request_timeout_ms = Some(value); + } + if let Some(value) = patch.max_retries { + config.max_retries = Some(value); + } + if let Some(value) = patch.retry_backoff_ms { + config.retry_backoff_ms = Some(value); + } +} + +fn resolve_game_creator_llm_config_for_agent( + config: &GameCreatorAppConfig, + agent_id: &str, +) -> GameCreatorLlmConfig { + let mut llm = config.llm.clone(); + if let Some(patch) = config.agent_llm.get(agent_id) { + merge_game_creator_llm_config(&mut llm, patch.clone()); + } + llm +} + fn merge_game_creator_editor_api_config( config: &mut GameCreatorEditorApiConfig, patch: GameCreatorEditorApiConfigFile, @@ -5318,39 +6138,88 @@ fn normalize_game_creator_app_config( ) -> Result { config.llm.api_key = config.llm.api_key.trim().to_string(); config.llm.base_url = - trim_config_string(&config.llm.base_url).ok_or_else(llm_base_url_config_error)?; - config.llm.model = trim_config_string(&config.llm.model).ok_or_else(llm_model_config_error)?; + trim_config_string(&config.llm.base_url).ok_or_else(|| llm_base_url_config_error("llm"))?; + config.llm.model = + trim_config_string(&config.llm.model).ok_or_else(|| llm_model_config_error("llm"))?; config.llm.api_kind = game_creator_llm_api_kind_name(parse_game_creator_llm_api_kind(&config.llm.api_kind)?); - if config.llm.request_timeout_ms == 0 { - return Err("配置项 llm.requestTimeoutMs 必须大于 0".to_string()); - } - if config.llm.retry_backoff_ms == 0 { - return Err("配置项 llm.retryBackoffMs 必须大于 0".to_string()); + validate_game_creator_llm_timing_config(&config.llm, "llm")?; + let mut agent_llm = BTreeMap::new(); + for (agent_id, patch) in config.agent_llm { + let agent_id = match trim_config_string(&agent_id) { + Some(value) => value, + None => continue, + }; + let patch = normalize_game_creator_llm_patch_config(&agent_id, patch)?; + if !is_empty_game_creator_llm_patch(&patch) { + agent_llm.insert(agent_id, patch); + } } + config.agent_llm = agent_llm; config.editor_api.base_url = trim_config_string(&config.editor_api.base_url) .ok_or_else(|| "配置项 editorApi.baseUrl 不能为空".to_string())?; config.editor_api.api_key = config.editor_api.api_key.trim().to_string(); Ok(config) } -fn llm_api_key_config_error() -> String { +fn normalize_game_creator_llm_patch_config( + agent_id: &str, + mut patch: GameCreatorLlmConfigFile, +) -> Result { + patch.api_key = patch.api_key.and_then(|value| trim_config_string(&value)); + patch.base_url = patch.base_url.and_then(|value| trim_config_string(&value)); + patch.model = patch.model.and_then(|value| trim_config_string(&value)); + patch.api_kind = match patch.api_kind { + Some(value) => Some(game_creator_llm_api_kind_name( + parse_game_creator_llm_api_kind(&value) + .map_err(|error| format!("配置项 agentLlm.{agent_id}.apiKind 无效:{error}"))?, + )), + None => None, + }; + if patch + .request_timeout_ms + .is_some_and(|value| value < MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS) + { + return Err(format!( + "配置项 agentLlm.{agent_id}.requestTimeoutMs 必须至少为 {MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS}" + )); + } + if patch.retry_backoff_ms.is_some_and(|value| value == 0) { + return Err(format!( + "配置项 agentLlm.{agent_id}.retryBackoffMs 必须大于 0" + )); + } + Ok(patch) +} + +fn is_empty_game_creator_llm_patch(patch: &GameCreatorLlmConfigFile) -> bool { + patch.api_key.is_none() + && patch.base_url.is_none() + && patch.model.is_none() + && patch.api_kind.is_none() + && patch.stream.is_none() + && patch.request_timeout_ms.is_none() + && patch.max_retries.is_none() + && patch.retry_backoff_ms.is_none() +} + +fn llm_api_key_config_error(config_path: &str) -> String { format!( - "LLM 未配置:请在 {} 的 llm.apiKey 中设置 API Key", + "LLM 未配置:请在 {} 的 {config_path}.apiKey 中设置 API Key", game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME) ) } -fn llm_base_url_config_error() -> String { +fn llm_base_url_config_error(config_path: &str) -> String { format!( - "LLM base_url 未配置:请在 {} 的 llm.baseUrl 中设置", + "LLM base_url 未配置:请在 {} 的 {config_path}.baseUrl 中设置", game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME) ) } -fn llm_model_config_error() -> String { +fn llm_model_config_error(config_path: &str) -> String { format!( - "LLM model 未配置:请在 {} 的 llm.model 中设置", + "LLM model 未配置:请在 {} 的 {config_path}.model 中设置", game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME) ) } @@ -6152,6 +7021,29 @@ fn read_local_game_memory_at(root: &Path, scope: &str) -> Result Result { + let relative_path = agent_role_memory_relative_path_for_task(task_id)?; + let path = resolve_local_project_path(root, &relative_path)?; + match fs::read_to_string(&path) { + Ok(content) => Ok(LocalAgentMemoryResult { + task_id: task_id.to_string(), + path: path.to_string_lossy().into_owned(), + content, + exists: true, + }), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(LocalAgentMemoryResult { + task_id: task_id.to_string(), + path: path.to_string_lossy().into_owned(), + content: String::new(), + exists: false, + }), + Err(error) => Err(format!("读取 Agent 记忆失败:{}: {error}", path.display())), + } +} + fn write_local_game_memory_at( root: &Path, scope: &str, @@ -6191,6 +7083,116 @@ fn delete_local_game_memory_at(root: &Path, scope: &str) -> Result, +) -> Result { + let (path, normalized_agent_id) = conversation_file_path(root, agent_id)?; + let mut messages = Vec::new(); + match File::open(&path) { + Ok(file) => { + for line in BufReader::new(file).lines() { + let line = + line.map_err(|error| format!("读取对话记录失败:{}: {error}", path.display()))?; + let line = line.trim(); + if line.is_empty() { + continue; + } + let record = serde_json::from_str::(line) + .map_err(|error| format!("解析对话记录失败:{}: {error}", path.display()))?; + messages.push(record); + } + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(format!("读取对话记录失败:{}: {error}", path.display())), + } + + Ok(LocalConversationResult { + path: path.to_string_lossy().into_owned(), + agent_id: normalized_agent_id, + messages, + }) +} + +fn append_local_conversation_message_at( + root: &Path, + agent_id: Option<&str>, + message: LocalConversationMessage, +) -> Result { + let LocalConversationMessage { + role, + content, + agent_id: _client_agent_id, + } = message; + let (path, normalized_agent_id) = conversation_file_path(root, agent_id)?; + let role = role.trim(); + if !matches!(role, "user" | "assistant" | "tool") { + return Err("对话角色必须是 user、assistant 或 tool".to_string()); + } + let content = content.trim(); + if content.is_empty() { + return read_local_conversation_at(root, agent_id); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建对话目录失败:{}: {error}", parent.display()))?; + } + let record = LocalConversationMessageRecord { + schema_version: LOCAL_CONVERSATION_SCHEMA_VERSION.to_string(), + role: role.to_string(), + content: content.to_string(), + agent_id: normalized_agent_id.clone(), + updated_at: unix_timestamp(), + }; + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + .map_err(|error| format!("打开对话记录失败:{}: {error}", path.display()))?; + serde_json::to_writer(&mut file, &record) + .map_err(|error| format!("序列化对话记录失败:{error}"))?; + file.write_all(b"\n") + .map_err(|error| format!("写入对话记录失败:{}: {error}", path.display()))?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "conversation.message", + "agentId": normalized_agent_id, + "role": role, + "path": relative_project_path(root, &path)?, + }), + )?; + read_local_conversation_at(root, agent_id) +} + +fn conversation_file_path( + root: &Path, + agent_id: Option<&str>, +) -> Result<(PathBuf, Option), String> { + validate_project_root(root)?; + let Some(agent_id) = agent_id.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok((root.join(".agent/conversations/project.jsonl"), None)); + }; + let normalized = normalize_conversation_agent_id(agent_id)?; + Ok(( + root.join(".agent/conversations/agents") + .join(format!("{normalized}.jsonl")), + Some(normalized), + )) +} + +fn normalize_conversation_agent_id(agent_id: &str) -> Result { + if agent_id.is_empty() + || agent_id.contains("..") + || agent_id + .chars() + .any(|ch| !(ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')) + { + return Err("agent id 只能包含 ASCII 字母、数字、短横线和下划线".to_string()); + } + Ok(agent_id.to_string()) +} + fn append_markdown_entry( path: &Path, header: &str, @@ -6438,24 +7440,30 @@ fn list_local_project_files_at(root: &Path) -> Result Result { - let path = resolve_local_project_path(root, relative_path)?; + let normalized_path = normalize_relative_path(relative_path)?; + reject_sensitive_project_file_read(&normalized_path)?; + let path = resolve_local_project_path(root, &normalized_path)?; let metadata = fs::metadata(&path) .map_err(|error| format!("读取文件元数据失败:{}: {error}", path.display()))?; if !metadata.is_file() { @@ -6482,12 +7492,31 @@ fn read_local_project_file_at( .map_err(|error| format!("读取项目文件失败:{}: {error}", path.display()))?; Ok(LocalProjectFileResult { - path: normalize_relative_path(relative_path)?, + path: normalized_path, absolute_path: path.to_string_lossy().into_owned(), content, }) } +fn reject_sensitive_project_file_read(normalized_path: &str) -> Result<(), String> { + for part in normalized_path.split('/') { + let lower = part.to_ascii_lowercase(); + if lower == ".env" + || lower.starts_with(".env.") + || lower == GAME_CREATOR_CONFIG_FILE_NAME + || lower == GAME_CREATOR_LOCAL_CONFIG_FILE_NAME + { + return Err("拒绝读取敏感配置文件".to_string()); + } + } + Ok(()) +} + +fn is_agent_trace_read_path(normalized_path: &str) -> bool { + normalized_path == ".agent/run.latest.json" + || (normalized_path.starts_with(".agent/runs/") && normalized_path.ends_with(".json")) +} + fn write_local_project_file_at( root: &Path, relative_path: &str, @@ -6690,10 +7719,12 @@ fn restore_local_project_checkpoint_at( validate_project_root(root)?; let checkpoint_id = normalize_checkpoint_id(checkpoint_id)?; let files = read_checkpoint_files(root, &checkpoint_id)?; + let current_files = collect_project_index_files(root)?; let checkpoint_files_root = root .join(".agent/checkpoints") .join(&checkpoint_id) .join("files"); + let mut restored_count = 0usize; for file in &files { if should_skip_project_restore_path(&file.path) { continue; @@ -6711,21 +7742,49 @@ fn restore_local_project_checkpoint_at( target.display() ) })?; + restored_count += 1; + } + let mut deleted_count = 0usize; + for file in ¤t_files { + if should_skip_project_restore_path(&file.path) { + continue; + } + if files.iter().any(|candidate| candidate.path == file.path) { + continue; + } + let target = root.join(&file.path); + if target.is_file() { + fs::remove_file(&target).map_err(|error| { + format!( + "删除 checkpoint 外新增文件失败:{}: {error}", + target.display() + ) + })?; + deleted_count += 1; + } + } + let project_index_path = root.join(PROJECT_INDEX_PATH); + if project_index_path.is_file() { + fs::remove_file(&project_index_path).map_err(|error| { + format!( + "删除恢复后的旧项目索引失败:{}: {error}", + project_index_path.display() + ) + })?; } append_agent_db_record( root, serde_json::json!({ "recordType": "project.restore", "checkpointId": checkpoint_id, - "restoredCount": files.len(), + "restoredCount": restored_count, + "deletedCount": deleted_count, }), )?; Ok(LocalProjectRestoreResult { checkpoint_id, - restored_count: files - .iter() - .filter(|file| !should_skip_project_restore_path(&file.path)) - .count(), + restored_count, + deleted_count, }) } @@ -6781,6 +7840,8 @@ fn should_skip_project_restore_path(relative_path: &str) -> bool { || relative_path == PROJECT_PERMISSION_POLICY_PATH || relative_path == PROJECT_WRITE_LOCK_PATH || relative_path == PROJECT_INDEX_PATH + || relative_path.starts_with(".agent/logs/") + || relative_path.starts_with(".agent/conversations/") } fn normalize_checkpoint_id(checkpoint_id: &str) -> Result { @@ -6840,6 +7901,9 @@ fn validate_project_root(root: &Path) -> Result<(), String> { if !root.is_absolute() { return Err("项目目录必须是绝对路径".to_string()); } + if project_path_has_control_chars(root) { + return Err("项目目录不能包含控制字符".to_string()); + } if root.exists() { let metadata = fs::symlink_metadata(root) .map_err(|error| format!("读取项目目录失败:{}: {error}", root.display()))?; @@ -6850,6 +7914,10 @@ fn validate_project_root(root: &Path) -> Result<(), String> { Ok(()) } +fn project_path_has_control_chars(root: &Path) -> bool { + root.to_string_lossy().chars().any(char::is_control) +} + fn normalize_relative_path(relative_path: &str) -> Result { let relative_path = relative_path.trim(); if relative_path.is_empty() { @@ -7050,7 +8118,11 @@ fn memory_file_path<'a>(root: &Path, scope: &'a str) -> Result<(&'a str, PathBuf "long", resolve_local_project_path(root, "memory/project.md")?, )), - _ => Err("记忆范围只能是 short 或 long".to_string()), + "blackboard" => Ok(( + "blackboard", + resolve_local_project_path(root, PROJECT_BLACKBOARD_MEMORY_PATH)?, + )), + _ => Err("记忆范围只能是 short、long 或 blackboard".to_string()), } } @@ -7380,13 +8452,8 @@ fn run_cli_command(command: CliCommand) -> Result<(), String> { match command { CliCommand::LlmStatus => { let status = check_game_creator_llm_config_from_config(); - println!("llm.configured={}", status.configured); - println!("llm.apiKeyPresent={}", status.api_key_present); - println!("llm.baseUrl={}", status.base_url.unwrap_or_default()); - println!("llm.model={}", status.model.unwrap_or_default()); - println!("llm.apiKind={}", status.api_kind); - if let Some(error) = status.error { - println!("llm.error={error}"); + for line in game_creator_llm_status_lines(&status) { + println!("{line}"); } if status.configured { Ok(()) @@ -7442,18 +8509,129 @@ fn run_cli_command(command: CliCommand) -> Result<(), String> { } } -#[cfg(debug_assertions)] -fn developer_window_url() -> tauri::WebviewUrl { - tauri::WebviewUrl::App(PathBuf::from("index.html?dev")) +fn workspace_window_url(project_path: &str) -> tauri::WebviewUrl { + tauri::WebviewUrl::App(PathBuf::from(format!( + "index.html?main&projectPath={}", + percent_encode_query_value(project_path) + ))) } -#[cfg(debug_assertions)] -fn open_developer_window(app: &tauri::App) -> tauri::Result<()> { - tauri::WebviewWindowBuilder::new(app, "developer", developer_window_url()) - .title("AI 游戏创作开发环境") - .inner_size(1280.0, 860.0) - .min_inner_size(960.0, 640.0) - .build()?; +fn launcher_window_url() -> tauri::WebviewUrl { + tauri::WebviewUrl::App(PathBuf::from("index.html?launcher")) +} + +fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> { + let project_path = project_path.trim(); + if project_path.is_empty() { + return Err("请提供工作区绝对路径".to_string()); + } + if !Path::new(project_path).is_absolute() { + return Err("工作区路径必须是绝对路径".to_string()); + } + if project_path.chars().any(char::is_control) { + return Err("工作区路径不能包含控制字符".to_string()); + } + Ok(project_path) +} + +fn percent_encode_query_value(value: &str) -> String { + let mut encoded = String::new(); + for byte in value.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + encoded.push(byte as char) + } + _ => encoded.push_str(&format!("%{byte:02X}")), + } + } + encoded +} + +fn game_creator_llm_status_lines(status: &GameCreatorLlmConfigStatus) -> Vec { + let mut lines = vec![ + format!("llm.configured={}", status.configured), + format!("llm.apiKeyPresent={}", status.api_key_present), + format!( + "llm.baseUrl={}", + status.base_url.as_deref().unwrap_or_default() + ), + format!("llm.model={}", status.model.as_deref().unwrap_or_default()), + format!("llm.apiKind={}", status.api_kind), + format!("llm.stream={}", status.stream), + ]; + for agent in &status.agents { + lines.push(format!( + "llm.agent.{}.configured={}", + agent.agent_id, agent.configured + )); + lines.push(format!( + "llm.agent.{}.apiKeyPresent={}", + agent.agent_id, agent.api_key_present + )); + lines.push(format!( + "llm.agent.{}.baseUrl={}", + agent.agent_id, + agent.base_url.as_deref().unwrap_or_default() + )); + lines.push(format!( + "llm.agent.{}.model={}", + agent.agent_id, + agent.model.as_deref().unwrap_or_default() + )); + lines.push(format!( + "llm.agent.{}.apiKind={}", + agent.agent_id, agent.api_kind + )); + lines.push(format!( + "llm.agent.{}.stream={}", + agent.agent_id, agent.stream + )); + if let Some(error) = agent.error.as_deref() { + lines.push(format!("llm.agent.{}.error={error}", agent.agent_id)); + } + } + if let Some(error) = status.error.as_deref() { + lines.push(format!("llm.error={error}")); + } + lines +} + +#[tauri::command] +fn open_game_creator_workspace_window( + app: tauri::AppHandle, + window: tauri::Window, + project_path: String, +) -> Result<(), String> { + let project_path = validate_workspace_window_project_path(&project_path)?; + if let Some(existing) = app.get_webview_window("main") { + existing.close().map_err(|error| error.to_string())?; + } + tauri::WebviewWindowBuilder::new(&app, "main", workspace_window_url(project_path)) + .title("AI 游戏创作") + .inner_size(1180.0, 820.0) + .min_inner_size(760.0, 560.0) + .build() + .map_err(|error| error.to_string())?; + window.close().map_err(|error| error.to_string())?; + Ok(()) +} + +#[tauri::command] +fn open_game_creator_launcher_window( + app: tauri::AppHandle, + window: tauri::Window, +) -> Result<(), String> { + if let Some(existing) = app.get_webview_window("launcher") { + existing.set_focus().map_err(|error| error.to_string())?; + } else { + tauri::WebviewWindowBuilder::new(&app, "launcher", launcher_window_url()) + .title("AI 游戏创作") + .inner_size(820.0, 640.0) + .min_inner_size(720.0, 520.0) + .build() + .map_err(|error| error.to_string())?; + } + window.close().map_err(|error| error.to_string())?; Ok(()) } @@ -7476,19 +8654,19 @@ fn main() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) .manage(PreviewRegistry::default()) .setup(|app| { configure_game_creator_runtime_config_dir(app.handle())?; - #[cfg(debug_assertions)] - open_developer_window(app)?; - #[cfg(not(debug_assertions))] - { - let _ = app; - } Ok(()) }) .invoke_handler(tauri::generate_handler![ init_local_game_project, + is_local_project_directory_non_empty, + inspect_local_project_directory, + pick_local_project_directory, + pick_local_file, + open_local_project_directory, control_agent_run, generate_local_game_draft, check_game_creator_llm_config, @@ -7499,6 +8677,7 @@ fn main() { import_canvas_asset, import_canvas_export, sync_canvas_project_assets, + generate_platform_art_asset, open_canvas_project, get_game_creation_agent_capabilities, get_limited_local_commands, @@ -7509,14 +8688,19 @@ fn main() { write_local_project_file, delete_local_project_file, read_local_game_memory, + read_local_agent_memory, write_local_game_memory, delete_local_game_memory, + read_local_conversation, + append_local_conversation_message, build_local_project_index, create_local_project_checkpoint, diff_local_project_checkpoint, restore_local_project_checkpoint, read_project_permission_policy, write_project_permission_policy, + open_game_creator_workspace_window, + open_game_creator_launcher_window, start_local_game_preview, open_local_game_preview, stop_local_game_preview, @@ -7628,6 +8812,17 @@ mod tests { "maxRetries": 2, "retryBackoffMs": 700 }, + "agentLlm": { + "planner": { + "model": "planner-model", + "apiKind": "anthropic", + "stream": false + }, + "generator": { + "baseUrl": "https://generator.example.test/v1", + "model": "generator-model" + } + }, "editorApi": { "baseUrl": "http://127.0.0.1:8099", "apiKey": "editor-key" @@ -7648,6 +8843,17 @@ mod tests { assert_eq!(config.llm.request_timeout_ms, 42_000); assert_eq!(config.llm.max_retries, 2); assert_eq!(config.llm.retry_backoff_ms, 700); + let planner_llm = resolve_game_creator_llm_config_for_agent(&config, "planner"); + assert_eq!(planner_llm.api_key, "file-key"); + assert_eq!(planner_llm.base_url, "https://example.test/v1"); + assert_eq!(planner_llm.model, "planner-model"); + assert_eq!(planner_llm.api_kind, "anthropic"); + assert!(!planner_llm.stream); + let generator_llm = resolve_game_creator_llm_config_for_agent(&config, "generator"); + assert_eq!(generator_llm.api_key, "file-key"); + assert_eq!(generator_llm.base_url, "https://generator.example.test/v1"); + assert_eq!(generator_llm.model, "generator-model"); + assert_eq!(generator_llm.api_kind, "openai_chat"); assert_eq!(config.editor_api.base_url, "http://127.0.0.1:8099"); assert_eq!(config.editor_api.api_key, "editor-key"); @@ -7677,7 +8883,45 @@ mod tests { assert_eq!(config.llm.api_key, "runtime-key"); assert_eq!(config.llm.base_url, "https://runtime.example.test/v1"); assert_eq!(config.llm.model, "runtime-model"); - assert!(llm_api_key_config_error().contains(&root.to_string_lossy().to_string())); + assert!(llm_api_key_config_error("llm").contains(&root.to_string_lossy().to_string())); + fs::remove_dir_all(root).expect("cleanup runtime config dir"); + } + + #[test] + fn runtime_config_read_returns_defaults_when_file_is_missing() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + + let result = read_game_creator_app_config().expect("read default runtime config"); + + assert_eq!( + result.path, + root.join(GAME_CREATOR_CONFIG_FILE_NAME) + .display() + .to_string() + ); + assert_eq!(result.config.llm.api_key, ""); + assert_eq!( + result.config.llm.base_url, + DEFAULT_GAME_CREATOR_LLM_BASE_URL + ); + assert_eq!(result.config.llm.model, DEFAULT_GAME_CREATOR_LLM_MODEL); + assert_eq!( + result.config.llm.api_kind, + DEFAULT_GAME_CREATOR_LLM_API_KIND + ); + assert!(!result.config.llm.stream); + assert_eq!( + result.config.llm.request_timeout_ms, + GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS + ); + assert_eq!( + result.config.editor_api.base_url, + DEFAULT_CANVAS_SYNC_API_BASE_URL + ); + assert_eq!(result.config.editor_api.api_key, ""); + assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists()); fs::remove_dir_all(root).expect("cleanup runtime config dir"); } @@ -7686,6 +8930,21 @@ mod tests { let root = unique_project_path(); fs::create_dir_all(&root).expect("runtime config dir"); let _guard = use_test_runtime_config_dir(root.clone()); + let mut agent_llm = BTreeMap::new(); + agent_llm.insert( + " planner ".to_string(), + GameCreatorLlmConfigFile { + api_key: Some(" planner-key ".to_string()), + base_url: Some(" https://planner.example.test/v1 ".to_string()), + model: Some(" planner-model ".to_string()), + api_kind: Some("anthropic".to_string()), + stream: Some(true), + request_timeout_ms: Some(15_000), + max_retries: Some(1), + retry_backoff_ms: Some(300), + }, + ); + agent_llm.insert("generator".to_string(), GameCreatorLlmConfigFile::default()); let saved = write_game_creator_app_config(GameCreatorAppConfig { llm: GameCreatorLlmConfig { @@ -7702,6 +8961,7 @@ mod tests { base_url: " http://127.0.0.1:8099 ".to_string(), api_key: " editor-key ".to_string(), }, + agent_llm, }) .expect("write runtime config"); @@ -7714,12 +8974,29 @@ mod tests { assert_eq!(saved.config.llm.api_key, "unit-test-key"); assert_eq!(saved.config.llm.base_url, "https://runtime.example.test/v1"); assert_eq!(saved.config.llm.api_kind, "openai_chat"); + assert_eq!( + saved + .config + .agent_llm + .get("planner") + .and_then(|llm| llm.api_key.as_deref()), + Some("planner-key") + ); + assert!(!saved.config.agent_llm.contains_key("generator")); assert_eq!(saved.config.editor_api.api_key, "editor-key"); assert!(root.join(GAME_CREATOR_CONFIG_FILE_NAME).is_file()); let read_back = read_game_creator_app_config().expect("read runtime config"); assert_eq!(read_back.config.llm.model, "runtime-model"); assert_eq!(read_back.config.llm.request_timeout_ms, 42_000); + assert_eq!( + read_back + .config + .agent_llm + .get("planner") + .and_then(|llm| llm.api_kind.as_deref()), + Some("anthropic") + ); fs::remove_dir_all(root).expect("cleanup runtime config dir"); } @@ -7736,6 +9013,7 @@ mod tests { ..GameCreatorLlmConfig::default() }, editor_api: GameCreatorEditorApiConfig::default(), + agent_llm: BTreeMap::new(), }); assert!(result @@ -7745,6 +9023,28 @@ mod tests { fs::remove_dir_all(root).expect("cleanup runtime config dir"); } + #[test] + fn app_config_write_rejects_too_small_request_timeout() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + + let result = write_game_creator_app_config(GameCreatorAppConfig { + llm: GameCreatorLlmConfig { + request_timeout_ms: MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS - 1, + ..GameCreatorLlmConfig::default() + }, + editor_api: GameCreatorEditorApiConfig::default(), + agent_llm: BTreeMap::new(), + }); + + assert!(result + .expect_err("too small timeout") + .contains("至少为 1000")); + assert!(!root.join(GAME_CREATOR_CONFIG_FILE_NAME).exists()); + fs::remove_dir_all(root).expect("cleanup runtime config dir"); + } + fn assert_task_status(manifest: &Value, task_id: &str, status: &str) { let task = manifest["tasks"] .as_array() @@ -8127,6 +9427,34 @@ mod tests { base_url } + fn spawn_mock_external_canvas_generation_failure_server() -> String { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock canvas api bind"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("mock canvas api addr") + ); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("mock canvas api accept"); + let mut request_buffer = [0_u8; 8192]; + let read_len = stream.read(&mut request_buffer).unwrap_or(0); + let request = String::from_utf8_lossy(&request_buffer[..read_len]); + assert!(request.starts_with("POST /api/external/v1/editor/images/generations ")); + assert!(request + .to_ascii_lowercase() + .contains("authorization: bearer ")); + let body = b"{\"error\":\"generation failed\"}"; + let response = format!( + "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + stream + .write_all(response.as_bytes()) + .expect("mock canvas api header"); + stream.write_all(body).expect("mock canvas api body"); + }); + base_url + } + #[tokio::test] async fn request_llm_game_draft_uses_openai_compatible_provider_output() { let response_content = @@ -8170,6 +9498,26 @@ mod tests { let root = unique_project_path(); let uploaded = upload_local_asset_at(&root, "../角色.png", "image/png", b"fake-png") .expect("asset upload"); + append_local_conversation_message_at( + &root, + None, + LocalConversationMessage { + role: "user".to_string(), + content: "项目对话:主角必须挥舞月光锅铲".to_string(), + agent_id: None, + }, + ) + .expect("append project conversation"); + append_local_conversation_message_at( + &root, + Some("art-asset-plan"), + LocalConversationMessage { + role: "assistant".to_string(), + content: "API Key sk-unit-secret\nAgent 对话:美术要用蓝紫霓虹厨房".to_string(), + agent_id: None, + }, + ) + .expect("append agent conversation"); let mut responses = vec![ "## 核心循环\n\n用上传角色图做主角。\n\n## Evaluator 验收\n\n必须使用本地资产。" .to_string(), @@ -8197,26 +9545,151 @@ mod tests { assert!(planner_request.contains("# 本地项目资产")); assert!(planner_request.contains(&uploaded.local_path)); assert!(planner_request.contains("source=uploaded")); + assert!(planner_request.contains("# 最近对话上下文")); + assert!(planner_request.contains("项目对话:主角必须挥舞月光锅铲")); + assert!(!planner_request.contains("Agent 对话:美术要用蓝紫霓虹厨房")); + assert!(!planner_request.contains("[redacted sensitive context]")); + assert!(!planner_request.contains("sk-unit-secret")); + let art_asset_brief = + fs::read_to_string(root.join(".agent/passes/pass-1/groups/art/asset.md")) + .expect("art asset brief"); + assert!(art_asset_brief.contains("Agent 对话:美术要用蓝紫霓虹厨房")); + assert!(art_asset_brief.contains("[redacted sensitive context]")); + assert!(!art_asset_brief.contains("sk-unit-secret")); fs::remove_dir_all(root).ok(); } + #[tokio::test] + async fn agent_loop_uses_per_agent_llm_overrides() { + let root = unique_project_path(); + init_local_game_project_at(&root, "local-project-draft", "未命名游戏原型") + .expect("init project"); + let (planner_sender, planner_receiver) = mpsc::channel(); + let planner_base_url = spawn_mock_llm_server_responses_with_capture( + vec!["## 核心循环\n\n反弹月光弹幕。\n\n## Evaluator 验收\n\n必须可玩。".to_string()], + Some(planner_sender), + ); + let (generator_sender, generator_receiver) = mpsc::channel(); + let generator_base_url = spawn_mock_llm_server_responses_with_capture( + vec![serde_json::to_string(&fake_llm_game_draft()).expect("draft json")], + Some(generator_sender), + ); + let (art_sender, art_receiver) = mpsc::channel(); + let art_base_url = spawn_mock_llm_server_responses_with_capture( + vec!["远程美术 Agent brief:生成月光锅铲主角和厨房弹幕素材。".to_string()], + Some(art_sender), + ); + let mut agent_llm = BTreeMap::new(); + agent_llm.insert( + "planner".to_string(), + GameCreatorLlmConfigFile { + api_key: Some("planner-key".to_string()), + base_url: Some(planner_base_url), + model: Some("planner-model".to_string()), + api_kind: Some("openai_responses".to_string()), + stream: Some(false), + request_timeout_ms: None, + max_retries: None, + retry_backoff_ms: None, + }, + ); + agent_llm.insert( + "generator".to_string(), + GameCreatorLlmConfigFile { + api_key: Some("generator-key".to_string()), + base_url: Some(generator_base_url), + model: Some("generator-model".to_string()), + api_kind: Some("openai_responses".to_string()), + stream: Some(false), + request_timeout_ms: None, + max_retries: None, + retry_backoff_ms: None, + }, + ); + agent_llm.insert( + "art-asset-plan".to_string(), + GameCreatorLlmConfigFile { + api_key: Some("art-key".to_string()), + base_url: Some(art_base_url), + model: Some("art-model".to_string()), + api_kind: Some("openai_responses".to_string()), + stream: Some(false), + request_timeout_ms: None, + max_retries: None, + retry_backoff_ms: None, + }, + ); + let app_config = GameCreatorAppConfig { + agent_llm, + ..GameCreatorAppConfig::default() + }; + + let loop_result = run_game_creator_agent_loop_at( + &root, + &app_config, + "做一个月光厨房弹幕游戏", + "", + "", + "", + None, + ) + .await + .expect("agent loop"); + + assert_eq!(loop_result.passes, 1); + assert_eq!(planner_receiver.try_iter().count(), 1); + assert_eq!(generator_receiver.try_iter().count(), 1); + assert_eq!(art_receiver.try_iter().count(), 1); + let art_brief = fs::read_to_string(root.join(".agent/passes/pass-1/groups/art/asset.md")) + .expect("art role brief"); + assert!(art_brief.contains("远程美术 Agent brief")); + fs::remove_dir_all(root).ok(); + } + #[test] fn llm_config_check_reports_status_without_leaking_key() { - let missing = check_game_creator_llm_config_values(&GameCreatorLlmConfig { - api_key: String::new(), - ..GameCreatorLlmConfig::default() - }); + let missing = check_game_creator_llm_config_values( + &GameCreatorLlmConfig { + api_key: String::new(), + ..GameCreatorLlmConfig::default() + }, + "llm", + ); assert!(!missing.configured); assert!(!missing.api_key_present); assert!(missing.error.unwrap().contains("LLM 未配置")); - let configured = check_game_creator_llm_config_values(&GameCreatorLlmConfig { - api_key: "unit-test-api-key".to_string(), - base_url: "http://127.0.0.1:1/v1".to_string(), - model: "mock-game-model".to_string(), - ..GameCreatorLlmConfig::default() - }); + let too_fast = check_game_creator_llm_config_values( + &GameCreatorLlmConfig { + api_key: "unit-test-api-key".to_string(), + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "mock-game-model".to_string(), + request_timeout_ms: MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS - 1, + ..GameCreatorLlmConfig::default() + }, + "llm", + ); + assert!(!too_fast.configured); + assert!(too_fast.api_key_present); + assert!(too_fast + .error + .as_deref() + .expect("too fast error") + .contains("至少为 1000")); + assert!(!serde_json::to_string(&too_fast) + .unwrap() + .contains("unit-test-api-key")); + + let configured = check_game_creator_llm_config_values( + &GameCreatorLlmConfig { + api_key: "unit-test-api-key".to_string(), + base_url: "http://127.0.0.1:1/v1".to_string(), + model: "mock-game-model".to_string(), + ..GameCreatorLlmConfig::default() + }, + "llm", + ); assert!(configured.configured); assert!(configured.api_key_present); assert_eq!( @@ -8230,6 +9703,158 @@ mod tests { .contains("unit-test-api-key")); } + #[test] + fn llm_config_check_reports_per_agent_status_without_leaking_keys() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + fs::write( + root.join(GAME_CREATOR_CONFIG_FILE_NAME), + r#"{ + "llm": { + "apiKey": "", + "baseUrl": "https://global.example.test/v1", + "model": "global-model" + }, + "agentLlm": { + "planner": { + "apiKey": "planner-secret-key", + "baseUrl": "https://planner.example.test/v1", + "model": "planner-model", + "apiKind": "anthropic" + }, + "generator": { + "apiKey": "generator-secret-key", + "baseUrl": "https://generator.example.test/v1", + "model": "generator-model", + "apiKind": "openai_chat" + }, + "art-asset-plan": { + "apiKey": "art-secret-key", + "baseUrl": "https://art.example.test/v1", + "model": "art-model", + "apiKind": "openai_chat" + } + } +} +"#, + ) + .expect("write runtime config"); + + let status = check_game_creator_llm_config_from_config(); + + assert!(status.configured); + assert!(!status.api_key_present); + assert!(status.agents.len() > 2); + let planner = status + .agents + .iter() + .find(|agent| agent.agent_id == "planner") + .expect("planner status"); + assert!(planner.configured); + assert!(planner.api_key_present); + assert_eq!(planner.model.as_deref(), Some("planner-model")); + assert_eq!(planner.api_kind, "anthropic"); + let generator = status + .agents + .iter() + .find(|agent| agent.agent_id == "generator") + .expect("generator status"); + assert!(generator.configured); + assert_eq!( + generator.base_url.as_deref(), + Some("https://generator.example.test/v1") + ); + let art = status + .agents + .iter() + .find(|agent| agent.agent_id == "art-asset-plan") + .expect("art agent status"); + assert!(art.configured); + assert_eq!(art.label, "美术组 / Asset"); + assert_eq!(art.model.as_deref(), Some("art-model")); + let serialized = serde_json::to_string(&status).expect("status json"); + assert!(!serialized.contains("planner-secret-key")); + assert!(!serialized.contains("generator-secret-key")); + assert!(!serialized.contains("art-secret-key")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn llm_config_check_reports_agent_specific_config_paths() { + let root = unique_project_path(); + fs::create_dir_all(&root).expect("runtime config dir"); + let _guard = use_test_runtime_config_dir(root.clone()); + fs::write( + root.join(GAME_CREATOR_CONFIG_FILE_NAME), + r#"{ + "llm": { + "apiKey": "global-key", + "baseUrl": "https://global.example.test/v1", + "model": "global-model" + }, + "agentLlm": { + "generator": { + "apiKey": "", + "baseUrl": "https://generator.example.test/v1", + "model": "generator-model" + } + } +} +"#, + ) + .expect("write runtime config"); + + let status = check_game_creator_llm_config_from_config(); + + assert!(!status.configured); + let generator = status + .agents + .iter() + .find(|agent| agent.agent_id == "generator") + .expect("generator status"); + let error = generator.error.as_deref().expect("generator error"); + assert!(error.contains("agentLlm.generator.apiKey")); + assert!(!serde_json::to_string(&status) + .expect("status json") + .contains("global-key")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn llm_status_cli_lines_include_agent_errors_without_leaking_keys() { + let status = GameCreatorLlmConfigStatus { + configured: false, + api_key_present: false, + base_url: Some("https://global.example.test/v1".to_string()), + model: Some("global-model".to_string()), + api_kind: "openai_responses".to_string(), + stream: false, + error: Some("Generator:缺少 API Key".to_string()), + agents: vec![GameCreatorAgentLlmConfigStatus { + agent_id: "generator".to_string(), + label: "Generator".to_string(), + configured: false, + api_key_present: false, + base_url: Some("https://generator.example.test/v1".to_string()), + model: Some("generator-model".to_string()), + api_kind: "openai_chat".to_string(), + stream: true, + error: Some( + "LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key".to_string(), + ), + }], + }; + + let lines = game_creator_llm_status_lines(&status).join("\n"); + + assert!(lines.contains("llm.agent.generator.error=LLM 未配置")); + assert!(lines.contains("llm.agent.generator.stream=true")); + assert!(lines.contains("llm.error=Generator:缺少 API Key")); + assert!(!lines.contains("sk-")); + assert!(!lines.contains("secret")); + } + #[test] fn llm_api_kind_parses_canonical_names() { assert_eq!( @@ -8276,21 +9901,19 @@ mod tests { responses.push(serde_json::to_string(&first_draft).expect("first draft json")); responses.push(serde_json::to_string(&fixed_draft).expect("fixed draft json")); let base_url = spawn_mock_llm_server_responses(responses); - let config = LlmConfig::new( - LlmProvider::OpenAiCompatible, - base_url, - "test-key".to_string(), - "mock-game-model".to_string(), - GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS, - 0, - DEFAULT_RETRY_BACKOFF_MS, - ) - .expect("llm config"); - let client = LlmClient::new(config).expect("llm client"); + let app_config = GameCreatorAppConfig { + llm: GameCreatorLlmConfig { + api_key: "test-key".to_string(), + base_url, + model: "mock-game-model".to_string(), + ..GameCreatorLlmConfig::default() + }, + ..GameCreatorAppConfig::default() + }; let mut loop_result = run_game_creator_agent_loop_at( &root, - &client, + &app_config, "做一个月光厨房弹幕游戏", "", "# 项目长期记忆\n", @@ -8938,6 +10561,159 @@ mod tests { assert!(error.contains("绝对路径")); } + #[test] + fn project_directory_commands_reject_control_characters() { + let project_path = format!("{}\nnext", unique_project_path().display()); + + assert!(is_local_project_directory_non_empty(project_path.clone()) + .expect_err("non-empty check should reject control characters") + .contains("控制字符")); + assert!(inspect_local_project_directory(project_path.clone()) + .expect_err("directory inspect should reject control characters") + .contains("控制字符")); + assert!( + init_local_game_project_at(Path::new(&project_path), "project-1", "demo") + .expect_err("project init should reject control characters") + .contains("控制字符") + ); + } + + #[test] + fn project_directory_non_empty_check_reports_existing_content() { + let root = unique_project_path(); + assert!( + !is_local_project_directory_non_empty(root.to_string_lossy().to_string()) + .expect("missing dir should be empty") + ); + + fs::create_dir_all(&root).expect("project dir"); + assert!( + !is_local_project_directory_non_empty(root.to_string_lossy().to_string()) + .expect("empty dir should be empty") + ); + + fs::write(root.join("old.txt"), "existing").expect("existing file"); + assert!( + is_local_project_directory_non_empty(root.to_string_lossy().to_string()) + .expect("non-empty dir should be reported") + ); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn project_directory_status_distinguishes_missing_file_and_dir() { + let root = unique_project_path(); + let missing = inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect("missing status"); + assert_eq!( + missing, + LocalProjectDirectoryStatus { + project_path: root.to_string_lossy().into_owned(), + exists: false, + is_directory: false, + is_game_creator_project: false, + project_name: None, + manifest_error: None, + recent_run_status: None, + recent_run_stop_reason: None, + } + ); + + fs::write(&root, "not a dir").expect("file"); + let file_status = inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect("file status"); + assert!(file_status.exists); + assert!(!file_status.is_directory); + assert!(!file_status.is_game_creator_project); + assert_eq!(file_status.project_name, None); + assert_eq!(file_status.manifest_error, None); + assert_eq!(file_status.recent_run_status, None); + fs::remove_file(&root).expect("remove file"); + + fs::create_dir_all(&root).expect("dir"); + let dir_status = inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect("dir status"); + assert!(dir_status.exists); + assert!(dir_status.is_directory); + assert!(!dir_status.is_game_creator_project); + assert_eq!(dir_status.project_name, None); + assert_eq!(dir_status.manifest_error, None); + assert_eq!(dir_status.recent_run_status, None); + + fs::create_dir_all(root.join(".agent")).expect("agent dir"); + fs::write(root.join(".agent/manifest.json"), "{broken").expect("broken manifest"); + let broken_manifest_status = + inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect("broken manifest status"); + assert!(broken_manifest_status.exists); + assert!(broken_manifest_status.is_directory); + assert!(!broken_manifest_status.is_game_creator_project); + assert!(broken_manifest_status + .manifest_error + .as_deref() + .is_some_and(|error| error.contains("解析 manifest 失败"))); + fs::remove_file(root.join(".agent/manifest.json")).expect("remove broken manifest"); + + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + fs::write( + root.join(".agent/run.latest.json"), + serde_json::json!({ + "schemaVersion": "game-creator-agent-run.v1", + "runId": "run-1", + "commandId": "game.generate_draft", + "status": "failed", + "passes": 2, + "goal": "demo", + "coordination": "demo", + "steps": [], + "artifacts": [], + "nextStep": "fix", + "error": null, + "updatedAt": 1, + "stopReason": "max-passes-exhausted" + }) + .to_string(), + ) + .expect("run trace"); + let project_status = inspect_local_project_directory(root.to_string_lossy().to_string()) + .expect("project status"); + assert!(project_status.exists); + assert!(project_status.is_directory); + assert!(project_status.is_game_creator_project); + assert_eq!( + project_status.project_name, + Some("像素动作原型".to_string()) + ); + assert_eq!(project_status.manifest_error, None); + assert_eq!(project_status.recent_run_status, Some("failed".to_string())); + assert_eq!( + project_status.recent_run_stop_reason, + Some("max-passes-exhausted".to_string()) + ); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_project_directory_open_path_requires_existing_absolute_directory() { + let root = unique_project_path(); + assert!(validated_local_project_directory_path("relative-game").is_err()); + assert!(validated_local_project_directory_path(&root.to_string_lossy()).is_err()); + + fs::write(&root, "not a dir").expect("file"); + assert!(validated_local_project_directory_path(&root.to_string_lossy()).is_err()); + fs::remove_file(&root).expect("remove file"); + + fs::create_dir_all(&root).expect("dir"); + assert_eq!( + validated_local_project_directory_path(&root.to_string_lossy()).expect("valid dir"), + root + ); + + fs::remove_dir_all(root).ok(); + } + #[test] fn generate_local_game_draft_writes_memory_design_and_game() { let root = unique_project_path(); @@ -9702,6 +11478,79 @@ mod tests { fs::remove_dir_all(config_dir).ok(); } + #[tokio::test] + async fn platform_art_generation_step_falls_back_without_leaking_editor_key() { + let root = unique_project_path(); + let config_dir = unique_project_path(); + let base_url = spawn_mock_external_canvas_generation_failure_server(); + fs::create_dir_all(&config_dir).expect("runtime config dir"); + fs::write( + config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME), + serde_json::json!({ + "editorApi": { + "baseUrl": base_url, + "apiKey": "editor-fallback-secret" + } + }) + .to_string(), + ) + .expect("write runtime config"); + let _guard = use_test_runtime_config_dir(config_dir.clone()); + let art_role = AgentRoleBrief { + group_definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2], + role_definition: ART_AGENT_ROLES[1], + markdown: "需要像素月光主角和厨房场景素材。".to_string(), + relative_path: ".agent/passes/pass-1/groups/art/asset.md".to_string(), + memory_relative_path: agent_role_memory_relative_path( + GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2], + ART_AGENT_ROLES[1], + ), + status: "completed".to_string(), + tool_id: "agent.role.brief.art.asset".to_string(), + summary: "规划首版美术资产".to_string(), + }; + let briefs = vec![AgentGroupBrief { + definition: GAME_CREATOR_AGENT_GROUP_DEFINITIONS[2], + markdown: "美术组汇总。".to_string(), + relative_path: ".agent/passes/pass-1/groups/art.md".to_string(), + role_briefs: vec![art_role], + }]; + + let step = maybe_generate_platform_art_asset_step(&root, "月光弹幕厨房", &briefs, 1, None) + .await + .expect("platform art generation step"); + + assert_eq!(step.status, "failed"); + assert!(step.output_paths.is_empty()); + assert!(step.summary.contains("HTTP 500")); + assert!(!step.summary.contains("editor-fallback-secret")); + assert!(read_manifest_for_project(&root).unwrap().assets.is_empty()); + + fs::remove_dir_all(root).ok(); + fs::remove_dir_all(config_dir).ok(); + } + + #[tokio::test] + async fn generate_platform_art_asset_respects_project_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["canvas.asset_generate".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + + let error = generate_platform_art_asset_at(&root, "月光弹幕厨房", &[]) + .await + .expect_err("policy should deny platform art generation"); + + assert!(error.contains("项目权限策略拒绝执行:canvas.asset_generate")); + fs::remove_dir_all(root).ok(); + } + #[test] fn canvas_project_url_defaults_to_local_editor_route() { let url = build_canvas_project_url(None, Some("canvas-project-1")).expect("canvas url"); @@ -9757,13 +11606,84 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn local_game_memory_can_read_write_and_delete_blackboard_memory() { + let root = unique_project_path(); + + let written = + write_local_game_memory_at(&root, "blackboard", "# 项目黑板\n").expect("write memory"); + assert_eq!(written.scope, "blackboard"); + assert_eq!(written.content, "# 项目黑板\n"); + + let read = read_local_game_memory_at(&root, "blackboard").expect("read memory"); + assert_eq!(read.scope, "blackboard"); + assert_eq!(read.content, "# 项目黑板\n"); + + let deleted = delete_local_game_memory_at(&root, "blackboard").expect("delete memory"); + assert!(!deleted.exists); + assert!(!root.join(PROJECT_BLACKBOARD_MEMORY_PATH).exists()); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_agent_memory_reads_private_memory_by_task_id() { + let root = unique_project_path(); + let memory_path = root.join("memory/agents/design/director.md"); + fs::create_dir_all(memory_path.parent().unwrap()).expect("agent memory dir"); + fs::write(&memory_path, "# 策划 Director 私有记忆\n").expect("agent memory"); + + let read = read_local_agent_memory_at(&root, "design-director").expect("read agent memory"); + assert_eq!(read.task_id, "design-director"); + assert!(read.path.ends_with("memory/agents/design/director.md")); + assert_eq!(read.content, "# 策划 Director 私有记忆\n"); + assert!(read.exists); + + let missing = + read_local_agent_memory_at(&root, "art-asset-plan").expect("read missing memory"); + assert_eq!(missing.task_id, "art-asset-plan"); + assert!(missing.path.ends_with("memory/agents/art/asset.md")); + assert!(!missing.exists); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_memory_reads_respect_project_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_local_game_memory_at(&root, "long", "# 项目长期记忆\n").expect("write memory"); + let agent_memory_path = root.join("memory/agents/design/director.md"); + fs::create_dir_all(agent_memory_path.parent().unwrap()).expect("agent memory dir"); + fs::write(&agent_memory_path, "# Agent 私有记忆\n").expect("agent memory"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["memory.read".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + let project_path = root.to_string_lossy().into_owned(); + + let game_error = read_local_game_memory(project_path.clone(), "long".to_string()) + .expect_err("memory.read denied"); + let agent_error = read_local_agent_memory(project_path, "design-director".to_string()) + .expect_err("agent memory read denied"); + + assert!(game_error.contains("项目权限策略拒绝执行:memory.read")); + assert!(agent_error.contains("项目权限策略拒绝执行:memory.read")); + + fs::remove_dir_all(root).ok(); + } + #[test] fn local_game_memory_rejects_unknown_scope() { let root = unique_project_path(); let error = read_local_game_memory_at(&root, "notes").expect_err("unknown memory scope fails"); - assert!(error.contains("short 或 long")); + assert!(error.contains("short、long 或 blackboard")); } #[cfg(unix)] @@ -9784,6 +11704,380 @@ mod tests { fs::remove_dir_all(outside).ok(); } + #[test] + fn local_conversation_can_read_and_append_project_and_agent_messages() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + + let project = append_local_conversation_message_at( + &root, + None, + LocalConversationMessage { + role: "user".to_string(), + content: "做一个像素动作游戏".to_string(), + agent_id: None, + }, + ) + .expect("append project conversation"); + assert!(project.path.ends_with(".agent/conversations/project.jsonl")); + assert_eq!(project.agent_id, None); + assert_eq!(project.messages[0].content, "做一个像素动作游戏"); + + let agent = append_local_conversation_message_at( + &root, + Some("design-director"), + LocalConversationMessage { + role: "user".to_string(), + content: "策划 agent 备注".to_string(), + agent_id: None, + }, + ) + .expect("append agent conversation"); + assert!(agent + .path + .ends_with(".agent/conversations/agents/design-director.jsonl")); + assert_eq!(agent.agent_id.as_deref(), Some("design-director")); + assert_eq!( + agent.messages[0].agent_id.as_deref(), + Some("design-director") + ); + + let read_agent = + read_local_conversation_at(&root, Some("design-director")).expect("read agent"); + assert_eq!(read_agent.messages.len(), 1); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"recordType\":\"conversation.message\"")); + assert!(agent_db.contains("\"agentId\":\"design-director\"")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_conversation_prompt_context_scopes_agent_messages() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + + append_local_conversation_message_at( + &root, + None, + LocalConversationMessage { + role: "user".to_string(), + content: "希望主角用月光厨房做弹幕躲避".to_string(), + agent_id: None, + }, + ) + .expect("append project conversation"); + append_local_conversation_message_at( + &root, + Some("art-asset-plan"), + LocalConversationMessage { + role: "assistant".to_string(), + content: "Authorization: Bearer secret-token\n美术建议:霓虹锅铲和月亮灶台" + .to_string(), + agent_id: None, + }, + ) + .expect("append agent conversation"); + append_local_conversation_message_at( + &root, + Some("design-director"), + LocalConversationMessage { + role: "user".to_string(), + content: "策划建议:只保留三种输入".to_string(), + agent_id: None, + }, + ) + .expect("append other agent conversation"); + + let project_context = + render_local_conversation_prompt_context(&root, None).expect("project context"); + + assert!(project_context.contains("# 最近对话上下文")); + assert!(project_context.contains("[project / user] 希望主角用月光厨房做弹幕躲避")); + assert!(!project_context.contains("[art-asset-plan / assistant]")); + assert!(!project_context.contains("美术建议:霓虹锅铲和月亮灶台")); + + let art_context = render_local_conversation_prompt_context(&root, Some("art-asset-plan")) + .expect("art agent context"); + assert!(art_context.contains("[project / user] 希望主角用月光厨房做弹幕躲避")); + assert!(art_context.contains("[art-asset-plan / assistant]")); + assert!(art_context.contains("美术建议:霓虹锅铲和月亮灶台")); + assert!(art_context.contains("[redacted sensitive context]")); + assert!(!art_context.contains("secret-token")); + assert!(!art_context.contains("策划建议:只保留三种输入")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_agent_role_brief_keeps_project_and_agent_conversation_context() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + append_local_conversation_message_at( + &root, + None, + LocalConversationMessage { + role: "user".to_string(), + content: "项目对话:主角必须挥舞月光锅铲".to_string(), + agent_id: None, + }, + ) + .expect("append project conversation"); + append_local_conversation_message_at( + &root, + Some("art-asset-plan"), + LocalConversationMessage { + role: "assistant".to_string(), + content: "Authorization: Bearer sk-brief-secret\nAgent 对话:美术要用蓝紫霓虹厨房" + .to_string(), + agent_id: None, + }, + ) + .expect("append agent conversation"); + + let conversation_context = + render_local_conversation_prompt_context(&root, Some("art-asset-plan")) + .expect("conversation context"); + let markdown = render_local_agent_role_brief( + GAME_CREATOR_AGENT_GROUP_DEFINITIONS + .iter() + .find(|definition| definition.id == "art") + .copied() + .expect("art group"), + ART_AGENT_ROLES + .iter() + .find(|role| role.task_id == "art-asset-plan") + .copied() + .expect("asset role"), + "做一个月光厨房弹幕游戏", + &conversation_context, + "", + "", + "", + "", + "", + "", + "", + "", + 1, + ); + + assert!(markdown.contains("项目对话:主角必须挥舞月光锅铲")); + assert!(markdown.contains("Agent 对话:美术要用蓝紫霓虹厨房")); + assert!(markdown.contains("[redacted sensitive context]")); + assert!(!markdown.contains("sk-brief-secret")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_conversation_prompt_context_can_include_all_agents() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + append_local_conversation_message_at( + &root, + None, + LocalConversationMessage { + role: "user".to_string(), + content: "项目对话:月光锅铲".to_string(), + agent_id: None, + }, + ) + .expect("append project conversation"); + append_local_conversation_message_at( + &root, + Some("art-asset-plan"), + LocalConversationMessage { + role: "assistant".to_string(), + content: "美术对话:蓝紫霓虹厨房".to_string(), + agent_id: None, + }, + ) + .expect("append art conversation"); + append_local_conversation_message_at( + &root, + Some("code-prototype"), + LocalConversationMessage { + role: "assistant".to_string(), + content: "程序对话:保留反弹碰撞".to_string(), + agent_id: None, + }, + ) + .expect("append code conversation"); + + let context = + render_local_conversation_prompt_context(&root, Some("*")).expect("all agent context"); + + assert!(context.contains("[project / user] 项目对话:月光锅铲")); + assert!(context.contains("[art-asset-plan / assistant] 美术对话:蓝紫霓虹厨房")); + assert!(context.contains("[code-prototype / assistant] 程序对话:保留反弹碰撞")); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_conversation_rejects_unsafe_agent_id() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + + let error = + read_local_conversation_at(&root, Some("../design")).expect_err("unsafe agent id"); + assert!(error.contains("agent id")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_conversation_agent_id_comes_from_outer_target() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + + let project = append_local_conversation_message_at( + &root, + None, + LocalConversationMessage { + role: "user".to_string(), + content: "项目聊天不能伪造 agent".to_string(), + agent_id: Some("design-director".to_string()), + }, + ) + .expect("append project conversation"); + assert_eq!(project.messages[0].agent_id, None); + + let agent = append_local_conversation_message_at( + &root, + Some("art-asset-plan"), + LocalConversationMessage { + role: "assistant".to_string(), + content: "单 agent 对话使用外层目标".to_string(), + agent_id: Some("design-director".to_string()), + }, + ) + .expect("append agent conversation"); + assert_eq!( + agent.messages[0].agent_id.as_deref(), + Some("art-asset-plan") + ); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_conversation_write_respects_project_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["conversation.write".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + + let error = append_local_conversation_message( + root.to_string_lossy().into_owned(), + None, + LocalConversationMessage { + role: "user".to_string(), + content: "should fail".to_string(), + agent_id: None, + }, + ) + .expect_err("conversation write denied"); + assert!(error.contains("项目权限策略拒绝执行:conversation.write")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_conversation_read_respects_project_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + append_local_conversation_message_at( + &root, + None, + LocalConversationMessage { + role: "user".to_string(), + content: "hello".to_string(), + agent_id: None, + }, + ) + .expect("append conversation"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["conversation.read".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + + let error = read_local_conversation(root.to_string_lossy().into_owned(), None) + .expect_err("conversation read denied"); + assert!(error.contains("项目权限策略拒绝执行:conversation.read")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn agent_run_history_prunes_to_latest_hundred_traces() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + + for index in 0..101 { + let run_id = format!("run-{index:03}"); + let trace = GameCreationAgentRunTrace { + schema_version: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION.to_string(), + run_id: run_id.clone(), + command_id: "game.generate_draft".to_string(), + status: "passed".to_string(), + lifecycle_status: Some("done".to_string()), + passes: 1, + max_passes: GAME_CREATOR_AGENT_LOOP_MAX_PASSES, + tool_call_count: 0, + max_tool_calls: GAME_CREATOR_AGENT_TOOL_CALL_MAX, + stop_reason: "evaluator-passed".to_string(), + goal: "像素动作原型".to_string(), + coordination: "filesystem".to_string(), + steps: Vec::new(), + artifacts: Vec::new(), + task_graph: GameCreationAgentRunTaskGraphTrace { + goal: "像素动作原型".to_string(), + ready_task_ids: Vec::new(), + active_task_ids: Vec::new(), + carried_task_ids: Vec::new(), + repair_focus: Vec::new(), + repair_routes: Vec::new(), + tasks: Vec::new(), + }, + pass_plans: Vec::new(), + next_step: "preview-playtest".to_string(), + error: None, + updated_at: index, + }; + write_agent_run_trace_payload(&root, &trace).expect("write run trace"); + assert!(root.join(format!(".agent/runs/{run_id}.json")).exists()); + } + + let run_dir = root.join(".agent/runs"); + let run_count = fs::read_dir(&run_dir) + .expect("run dir") + .filter_map(Result::ok) + .filter(|entry| { + entry.path().extension().and_then(|value| value.to_str()) == Some("json") + }) + .count(); + assert_eq!(run_count, GAME_CREATOR_AGENT_RUN_HISTORY_MAX_COUNT); + assert!(!root.join(".agent/runs/run-000.json").exists()); + assert!(root.join(".agent/runs/run-001.json").exists()); + assert!(root.join(".agent/runs/run-100.json").exists()); + let latest: GameCreationAgentRunTrace = + serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) + .expect("latest run trace"); + assert_eq!(latest.run_id, "run-100"); + + fs::remove_dir_all(root).ok(); + } + #[test] fn local_project_file_commands_read_write_list_and_delete_text_files() { let root = unique_project_path(); @@ -9801,6 +12095,10 @@ mod tests { .files .iter() .any(|file| file.path == "game/notes.txt" && file.kind == "file")); + assert!(listed + .files + .iter() + .any(|file| file.path == "game/notes.txt" && file.modified_at > 0)); let deleted = delete_local_project_file_at(&root, "game/notes.txt").expect("delete project file"); @@ -9851,11 +12149,14 @@ mod tests { "v1" ); assert!(root.join("exports/README.md").exists()); + assert!(!root.join("game/extra.txt").exists()); + assert_eq!(restored.deleted_count, 1); let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); assert!(agent_db.contains("\"recordType\":\"project.checkpoint\"")); assert!(agent_db.contains("\"recordType\":\"project.index\"")); assert!(agent_db.contains("\"recordType\":\"project.restore\"")); + assert!(agent_db.contains("\"deletedCount\":1")); fs::remove_dir_all(root).ok(); } @@ -9896,6 +12197,123 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn project_permission_policy_read_respects_project_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["project.policy_read".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + + let error = read_project_permission_policy(root.to_string_lossy().into_owned()) + .expect_err("project.policy_read denied"); + assert!(error.contains("项目权限策略拒绝执行:project.policy_read")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_game_manifest_reads_respect_declared_command_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["project.status".to_string(), "asset.list".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + let project_path = root.to_string_lossy().into_owned(); + + let status_error = get_local_game_manifest(project_path.clone(), None) + .expect_err("default project.status denied"); + let asset_error = + get_local_game_manifest(project_path.clone(), Some("asset.list".to_string())) + .expect_err("asset.list denied"); + let unsupported_error = + get_local_game_manifest(project_path, Some("file.read".to_string())) + .expect_err("unsupported manifest command denied"); + + assert!(status_error.contains("项目权限策略拒绝执行:project.status")); + assert!(asset_error.contains("项目权限策略拒绝执行:asset.list")); + assert!(unsupported_error.contains("不支持通过 manifest 执行命令:file.read")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_project_file_read_and_list_respect_project_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + fs::write(root.join("game/notes.txt"), "hello").expect("notes"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["file.list".to_string(), "file.read".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + let project_path = root.to_string_lossy().into_owned(); + + let list_error = + list_local_project_files(project_path.clone()).expect_err("file.list denied"); + let read_error = read_local_project_file(project_path, "game/notes.txt".to_string(), None) + .expect_err("file.read denied"); + + assert!(list_error.contains("项目权限策略拒绝执行:file.list")); + assert!(read_error.contains("项目权限策略拒绝执行:file.read")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_project_file_read_can_enforce_agent_trace_read_policy() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + fs::write(root.join(".agent/run.latest.json"), "{}").expect("trace"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["agent.trace_read".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + let project_path = root.to_string_lossy().into_owned(); + + let trace_error = read_local_project_file( + project_path.clone(), + ".agent/run.latest.json".to_string(), + Some("agent.trace_read".to_string()), + ) + .expect_err("agent.trace_read denied"); + let scope_error = read_local_project_file( + project_path.clone(), + "game/notes.txt".to_string(), + Some("agent.trace_read".to_string()), + ) + .expect_err("trace command cannot read arbitrary files"); + let unsupported_error = read_local_project_file( + project_path, + ".agent/run.latest.json".to_string(), + Some("project.status".to_string()), + ) + .expect_err("unsupported file read command denied"); + + assert!(trace_error.contains("项目权限策略拒绝执行:agent.trace_read")); + assert!(scope_error.contains("agent.trace_read 只能读取 Agent run trace")); + assert!(unsupported_error.contains("不支持通过文件读取执行命令:project.status")); + + fs::remove_dir_all(root).ok(); + } + #[test] fn local_project_file_commands_reject_unsafe_paths() { let root = unique_project_path(); @@ -9906,6 +12324,37 @@ mod tests { assert!(write_local_project_file_at(&root, "C:/secret.txt", "x").is_err()); } + #[test] + fn local_project_file_read_rejects_sensitive_config_files() { + let root = unique_project_path(); + fs::create_dir_all(root.join("nested")).expect("nested dir"); + fs::write(root.join(".env"), "OPENAI_API_KEY=secret").expect("env file"); + fs::write(root.join("nested/.env.local"), "TOKEN=secret").expect("local env file"); + fs::write( + root.join(GAME_CREATOR_CONFIG_FILE_NAME), + "{\"apiKey\":\"secret\"}", + ) + .expect("config file"); + fs::write( + root.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME), + "{\"apiKey\":\"secret\"}", + ) + .expect("local config file"); + + for path in [ + ".env", + "nested/.env.local", + GAME_CREATOR_CONFIG_FILE_NAME, + GAME_CREATOR_LOCAL_CONFIG_FILE_NAME, + ] { + let error = read_local_project_file_at(&root, path) + .expect_err("sensitive project file should not be readable"); + assert!(error.contains("敏感配置文件")); + } + + fs::remove_dir_all(root).ok(); + } + #[test] fn limited_local_command_runs_static_game_smoke_and_writes_log() { let root = unique_project_path(); @@ -10065,12 +12514,12 @@ mod tests { update_agent_run_lifecycle(&root, "retry", None).expect("retry should mark pending"); assert_eq!(retried.status, "pending"); assert_eq!(retried.lifecycle_status, "pending"); - assert_eq!(retried.next_step, "runner-claim"); + assert_eq!(retried.next_step, "rerun-now"); let resumed = update_agent_run_lifecycle(&root, "resume", Some("继续修复输入监听")) .expect("resume should mark pending"); assert_eq!(resumed.status, "pending"); assert_eq!(resumed.lifecycle_status, "pending"); - assert_eq!(resumed.next_step, "runner-claim"); + assert_eq!(resumed.next_step, "rerun-now"); let trace: Value = serde_json::from_str(&fs::read_to_string(root.join(".agent/run.latest.json")).unwrap()) .expect("run trace json after resume"); @@ -10093,6 +12542,113 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn agent_run_status_derives_missing_lifecycle_from_status() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_agent_run_trace( + &root, + "run-control-legacy", + "做一个旧 trace", + "passed", + 1, + &[agent_trace_step( + 1, + "Playtest", + "completed", + &["game/index.html"], + &[".agent/logs/command.log"], + "静态入口自检通过", + "game.static_smoke", + )], + None, + ) + .expect("run trace"); + let trace_path = root.join(".agent/run.latest.json"); + let mut trace: Value = + serde_json::from_str(&fs::read_to_string(&trace_path).unwrap()).expect("trace json"); + trace + .as_object_mut() + .expect("trace object") + .remove("lifecycleStatus"); + fs::write( + &trace_path, + serde_json::to_string_pretty(&trace).expect("trace json"), + ) + .expect("write legacy trace"); + + let status = + update_agent_run_lifecycle(&root, "status", None).expect("status should read trace"); + + assert_eq!(status.status, "passed"); + assert_eq!(status.lifecycle_status, "done"); + fs::remove_dir_all(root).ok(); + } + + #[tokio::test] + async fn agent_run_resume_restarts_generation_from_latest_goal() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_agent_run_trace( + &root, + "run-control-resume", + "做一个可恢复 run", + "killed", + 1, + &[agent_trace_step( + 1, + "Planner", + "completed", + &[".agent/manifest.json"], + &[".agent/spec.md"], + "已拆解目标", + "llm.chat.planner", + )], + Some("用户请求停止当前 run"), + ) + .expect("run trace"); + let responses = vec![ + "## 核心循环\n\n继续修复输入监听。\n\n## Evaluator 验收\n\n必须可运行。".to_string(), + serde_json::to_string(&fake_llm_game_draft()).expect("draft json"), + ]; + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture(responses, Some(sender)); + let _config_guard = write_test_local_config(format!( + r#"{{ + "llm": {{ + "apiKey": "test-key", + "baseUrl": {base_url:?}, + "model": "mock-game-model", + "apiKind": "openai_responses" + }} +}}"# + )); + + let result = control_agent_run_at(&root, "resume", Some("继续修复输入监听"), None) + .await + .expect("resume should rerun"); + + assert_eq!(result.status, "passed"); + assert_eq!(result.lifecycle_status, "done"); + assert_eq!(result.next_step, "preview-playtest"); + assert!(result.message.contains("已重新运行为")); + assert!(result.message.contains("game/index.html")); + let trace = read_latest_agent_run_trace(&root).expect("latest trace"); + assert_ne!(trace.run_id, "run-control-resume"); + assert!(trace.goal.contains("做一个可恢复 run")); + assert!(trace.goal.contains("继续说明:继续修复输入监听")); + let requests = receiver.try_iter().collect::>(); + assert!(requests + .first() + .expect("planner request") + .contains("继续说明:继续修复输入监听")); + let activity = fs::read_to_string(root.join(".agent/activity.jsonl")).expect("activity"); + assert!(activity.contains("agent.resume")); + assert!(activity.contains("agent.resume.run")); + + fs::remove_dir_all(root).ok(); + } + #[test] fn limited_local_command_rejects_placeholder_game_smoke() { let root = unique_project_path(); @@ -10397,6 +12953,69 @@ mod tests { fs::remove_dir_all(second).ok(); } + #[test] + fn preview_stop_for_other_project_does_not_write_stopped_evidence() { + let first = unique_project_path(); + let second = unique_project_path(); + init_local_game_project_at(&first, "project-1", "预览项目一").expect("first project"); + init_local_game_project_at(&second, "project-2", "预览项目二").expect("second project"); + write_agent_run_trace( + &second, + "run-second", + "第二个项目", + "passed", + 1, + &[agent_trace_step( + 1, + "Playtest", + "completed", + &["game/index.html"], + &[".agent/logs/command.log"], + "静态入口自检通过", + "game.static_smoke", + )], + None, + ) + .expect("second trace"); + let registry = PreviewRegistry::default(); + let (first_stop, first_receiver) = mpsc::channel(); + registry.set_running( + LocalPreviewResult { + url: "http://127.0.0.1:1/".to_string(), + port: 1, + root: first.to_string_lossy().into_owned(), + }, + first_stop, + ); + + let status = stop_local_game_preview_for_root(Some(&second), ®istry) + .expect("stop other project preview"); + + assert_eq!(status.status, "stopped"); + assert!(first_receiver.try_recv().is_err()); + assert_eq!(registry.status().port, Some(1)); + let second_manifest: Value = + serde_json::from_str(&fs::read_to_string(second.join(".agent/manifest.json")).unwrap()) + .expect("second manifest"); + assert!(second_manifest.get("preview").is_none()); + assert!(!second.join(".agent/logs/preview.log").exists()); + let second_trace: Value = serde_json::from_str( + &fs::read_to_string(second.join(".agent/run.latest.json")).unwrap(), + ) + .expect("second trace"); + assert_eq!(second_trace["status"], "passed"); + assert!(second_trace["steps"] + .as_array() + .unwrap() + .iter() + .all(|step| step["toolCalls"][0]["toolId"] != "preview.stop")); + + stop_local_game_preview_for_root(Some(&first), ®istry).expect("cleanup first preview"); + assert!(first_receiver.try_recv().is_ok()); + fs::remove_dir_all(first).ok(); + fs::remove_dir_all(second).ok(); + } + #[test] fn preview_open_url_requires_running_localhost_preview() { assert_eq!( @@ -10443,6 +13062,58 @@ mod tests { fs::remove_dir_all(second).ok(); } + #[test] + fn preview_open_respects_project_policy_when_project_is_provided() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["preview.open".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + let status = LocalPreviewStatus { + status: "running".to_string(), + url: Some("http://127.0.0.1:3210/".to_string()), + port: Some(3210), + root: Some(root.to_string_lossy().into_owned()), + }; + + let error = validate_preview_open_project(&status, Some(root.to_str().unwrap())) + .expect_err("preview.open denied"); + assert!(error.contains("项目权限策略拒绝执行:preview.open")); + validate_preview_open_project(&status, None).expect("global preview open allowed"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn preview_status_respects_project_policy_when_project_is_provided() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_project_permission_policy_at( + &root, + ProjectPermissionPolicy { + denied_commands: vec!["preview.status".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + let registry = PreviewRegistry::default(); + + let error = get_local_game_preview_status_at(®istry, Some(root.to_str().unwrap())) + .expect_err("preview.status denied"); + assert!(error.contains("项目权限策略拒绝执行:preview.status")); + + let global_status = + get_local_game_preview_status_at(®istry, None).expect("global status allowed"); + assert_eq!(global_status.status, "stopped"); + + fs::remove_dir_all(root).ok(); + } + #[test] fn preview_status_filter_hides_other_project_preview() { let first = unique_project_path(); @@ -10562,8 +13233,30 @@ mod tests { } #[test] - fn developer_window_uses_dev_route() { - assert_eq!(developer_window_url().to_string(), "index.html?dev"); + fn launcher_window_uses_launcher_route() { + assert_eq!(launcher_window_url().to_string(), "index.html?launcher"); + } + + #[test] + fn workspace_window_url_carries_encoded_project_path() { + assert_eq!( + workspace_window_url("/tmp/AI Game 项目").to_string(), + "index.html?main&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE" + ); + } + + #[test] + fn workspace_window_project_path_requires_absolute_path() { + assert!(validate_workspace_window_project_path(" /tmp/game ").is_ok()); + assert!(validate_workspace_window_project_path("relative-game") + .expect_err("relative path should be rejected") + .contains("绝对路径")); + assert!(validate_workspace_window_project_path(" ") + .expect_err("empty path should be rejected") + .contains("绝对路径")); + assert!(validate_workspace_window_project_path("/tmp/game\nnext") + .expect_err("control character path should be rejected") + .contains("控制字符")); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json index be6e8cc97..1354d16b5 100644 --- a/apps/ai-game-creator-shell/src-tauri/tauri.conf.json +++ b/apps/ai-game-creator-shell/src-tauri/tauri.conf.json @@ -13,12 +13,13 @@ "withGlobalTauri": true, "windows": [ { - "label": "main", + "label": "launcher", "title": "AI 游戏创作", - "width": 760, - "height": 820, - "minWidth": 420, - "minHeight": 560 + "url": "index.html?launcher", + "width": 820, + "height": 640, + "minWidth": 720, + "minHeight": 520 } ], "security": { diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 716fe7910..6ecc80563 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -1,4 +1,14 @@ -import { type ChangeEvent, type FormEvent, useEffect, useState } from 'react'; +import { + Fragment, + type ChangeEvent, + type FormEvent, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type UIEvent, + useEffect, + useRef, + useState, +} from 'react'; import { createGameCreationAppManifest, @@ -9,8 +19,13 @@ import { GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS, GAME_CREATION_APP_LIMITED_RUN_COMMANDS, + type GameCreationAgentCapabilityDescriptor, type GameCreationAppAgentGroup, type GameCreationAppCommandDescriptor, + type GameCreationAppLimitedRunCommandDescriptor, + type GameCreationAgentRepairRouteTrace, + type GameCreationAgentRunStep, + type GameCreationAgentToolCallTrace, type GameCreationAgentRunTrace, type GameCreationAppManifest, type GameCreationAppPermission, @@ -26,6 +41,13 @@ const seedManifest = createGameCreationAppManifest( ); const defaultProjectPath = '/tmp/genarrative-ai-game-draft'; +const RECENT_WORKSPACES_STORAGE_KEY = + 'genarrative-ai-game-creator.recent-workspaces.v1'; +const AGENT_RUN_HISTORY_MAX_COUNT = 100; +const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20; +const AGENT_RUN_HISTORY_VISIBLE_STEP = 20; +const CONVERSATION_INITIAL_VISIBLE_COUNT = 20; +const CONVERSATION_VISIBLE_STEP = 20; interface InitLocalProjectResult { projectPath: string; @@ -33,6 +55,17 @@ interface InitLocalProjectResult { manifest: GameCreationAppManifest; } +interface LocalProjectDirectoryStatus { + projectPath: string; + exists: boolean; + isDirectory: boolean; + isGameCreatorProject: boolean; + projectName: string | null; + manifestError?: string | null; + recentRunStatus: string | null; + recentRunStopReason: string | null; +} + interface LocalPreviewResult { url: string; port: number; @@ -61,22 +94,50 @@ interface GameCreatorLlmConfigStatus { baseUrl: string | null; model: string | null; apiKind: string; + stream: boolean; + error: string | null; + agents?: GameCreatorAgentLlmConfigStatus[]; +} + +interface GameCreatorAgentLlmConfigStatus { + agentId: string; + label: string; + configured: boolean; + apiKeyPresent: boolean; + baseUrl: string | null; + model: string | null; + apiKind: string; + stream: boolean; error: string | null; } type GameCreatorLlmApiKind = 'openai_responses' | 'openai_chat' | 'anthropic'; +type RuntimeLlmProviderPresetId = + | 'custom' + | 'openai' + | 'deepseek' + | 'anthropic' + | 'ark'; +type RuntimeAgentLlmProviderPresetId = + | 'inherit' + | RuntimeLlmProviderPresetId; + +interface GameCreatorLlmConfig { + apiKey: string; + baseUrl: string; + model: string; + apiKind: GameCreatorLlmApiKind; + stream: boolean; + requestTimeoutMs: number; + maxRetries: number; + retryBackoffMs: number; +} + +type GameCreatorAgentLlmConfig = Partial; interface GameCreatorAppConfig { - llm: { - apiKey: string; - baseUrl: string; - model: string; - apiKind: GameCreatorLlmApiKind; - stream: boolean; - requestTimeoutMs: number; - maxRetries: number; - retryBackoffMs: number; - }; + llm: GameCreatorLlmConfig; + agentLlm: Record; editorApi: { baseUrl: string; apiKey: string; @@ -113,7 +174,7 @@ interface OpenCanvasProjectResult { url: string; } -type MemoryScope = 'long' | 'short'; +type MemoryScope = 'long' | 'short' | 'blackboard'; type MemoryWriteMode = 'append' | 'replace'; interface LocalGameMemoryResult { @@ -123,6 +184,13 @@ interface LocalGameMemoryResult { exists: boolean; } +interface LocalAgentMemoryResult { + taskId: string; + path: string; + content: string; + exists: boolean; +} + interface LimitedLocalCommandResult { commandId: string; status: string; @@ -135,8 +203,20 @@ interface LocalProjectFileEntry { path: string; kind: string; size: number; + modifiedAt?: number; } +type ProjectAssetDraft = { + localPath: string; + kind: string; + mediaType: string; +}; + +type ProjectFileActionDraft = { + readCommand: string; + assetCommand: string; +}; + interface ListLocalProjectFilesResult { projectPath: string; files: LocalProjectFileEntry[]; @@ -154,6 +234,20 @@ interface LocalProjectFileMutationResult { deleted: boolean; } +interface LocalConversationMessageRecord { + schemaVersion: string; + role: 'user' | 'assistant' | 'tool'; + content: string; + agentId: string | null; + updatedAt: number; +} + +interface LocalConversationResult { + path: string; + agentId: string | null; + messages: LocalConversationMessageRecord[]; +} + interface ProjectPermissionPolicy { deniedCommands: string[]; confirmCommands: string[]; @@ -189,11 +283,23 @@ interface LocalProjectDiffResult { interface LocalProjectRestoreResult { checkpointId: string; restoredCount: number; + deletedCount: number; +} + +interface LocalProjectCheckpointSummary { + checkpointId: string; + path: string; + fileCount: number; + totalBytes: number; + createdAt: string; + modifiedAt?: number; } interface ChatMessage { role: 'assistant' | 'user'; text: string; + draftCommand?: string; + draftCommandLabel?: string; } interface AgentProgressEvent { @@ -213,6 +319,32 @@ interface AgentRunControlResult { contextBundlePath: string; } +interface AgentRunHistoryItem { + path: string; + size: number; + trace: GameCreationAgentRunTrace; +} + +interface AgentStatusCard { + id: string; + taskId: string; + title: string; + group: GameCreationAppAgentGroup; + role: string; + status: GameCreationAppTaskStatus; + summary: string; + pass: number | null; + phase: string | null; + lifecycleStatus: string | null; + hasRecentEvidence: boolean; + taskGraphState: AgentTaskGraphState | null; + inputPaths: string[]; + outputPaths: string[]; + toolCalls: GameCreationAgentToolCallTrace[]; +} + +type AgentTaskGraphState = 'active' | 'carried' | 'ready'; + export type PendingCommand = | { id: 'game.generate_draft'; @@ -225,6 +357,12 @@ export type PendingCommand = id: 'asset.upload'; file: File; } + | { + id: 'asset.register'; + localPath: string; + kind: string; + mediaType: string; + } | { id: 'command.run_limited'; commandId: string; @@ -236,6 +374,9 @@ export type PendingCommand = | { id: 'project.checkpoint'; } + | { + id: 'project.index'; + } | { id: 'project.restore'; checkpointId: string; @@ -281,12 +422,21 @@ export type PendingCommand = kind: string; mediaType: string; } + | { + id: 'canvas.asset_generate'; + prompt: string; + } | { id: 'canvas.export_import'; exportPath: string; canvasProjectId: string; }; +interface PendingUiConfirmation { + commandId: GameCreationAppCommandDescriptor['id']; + detail: string; +} + type TauriInvoke = ( command: string, args?: Record, @@ -296,6 +446,133 @@ function resolveTauriInvoke() { return window.__TAURI__?.core?.invoke; } +function createDefaultChatMessages(): ChatMessage[] { + return [ + { + role: 'assistant', + text: '想做什么游戏?', + }, + ]; +} + +function normalizeRecentWorkspaceList(values: unknown[]) { + const recent: string[] = []; + for (const value of values) { + if (typeof value !== 'string') { + continue; + } + const workspace = value.trim(); + if ( + !workspace || + !isAbsoluteProjectPath(workspace) || + recent.includes(workspace) + ) { + continue; + } + recent.push(workspace); + if (recent.length >= 8) { + break; + } + } + return recent; +} + +function readRecentWorkspaces() { + try { + const raw = window.localStorage.getItem(RECENT_WORKSPACES_STORAGE_KEY); + const parsed: unknown = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? normalizeRecentWorkspaceList(parsed) : []; + } catch { + return []; + } +} + +function writeRecentWorkspace(path: string) { + const recent = normalizeRecentWorkspaceList([ + path, + ...readRecentWorkspaces(), + ]); + try { + window.localStorage.setItem( + RECENT_WORKSPACES_STORAGE_KEY, + JSON.stringify(recent), + ); + } catch { + // WebView storage can be unavailable in restricted test shells. + } + return recent; +} + +function removeRecentWorkspace(path: string) { + const trimmedPath = path.trim(); + const recent = readRecentWorkspaces().filter( + (workspace) => workspace !== trimmedPath, + ); + try { + window.localStorage.setItem( + RECENT_WORKSPACES_STORAGE_KEY, + JSON.stringify(recent), + ); + } catch { + // WebView storage can be unavailable in restricted test shells. + } + return recent; +} + +function clearRecentWorkspaces() { + try { + window.localStorage.removeItem(RECENT_WORKSPACES_STORAGE_KEY); + } catch { + // WebView storage can be unavailable in restricted test shells. + } + return []; +} + +function isRuntimeConfigMissingError(message: string) { + return ( + message.includes('LLM 未配置') || + message.includes('LLM base_url 未配置') || + message.includes('LLM model 未配置') || + message.includes('editorApi.apiKey') + ); +} + +function projectNameFromPath(projectPath: string) { + return ( + projectPath + .split(/[\\/]/) + .map((part) => part.trim()) + .filter(Boolean) + .pop() || '未命名游戏原型' + ); +} + +function conversationRecordsToChatMessages( + records: LocalConversationMessageRecord[], +): ChatMessage[] { + if (records.length === 0) { + return createDefaultChatMessages(); + } + return records.map((record) => ({ + role: record.role === 'user' ? 'user' : 'assistant', + text: record.content, + })); +} + +function isTransientProjectOpenMessage(message: ChatMessage, projectPath: string) { + return ( + message.role === 'assistant' && + message.text === `已设置本地项目:${projectPath}` + ); +} + +function latestVisibleItems(items: T[], visibleCount: number) { + if (items.length <= visibleCount) { + return items; + } + return items.slice(items.length - visibleCount); +} + function isDeveloperMode() { if (!import.meta.env.DEV) { return false; @@ -304,6 +581,1181 @@ function isDeveloperMode() { return params.has('dev') || window.location.hash === '#dev'; } +function readInitialProjectPath() { + const params = new URLSearchParams(window.location.search); + return params.get('projectPath') ?? ''; +} + +const defaultRuntimeConfigDraft: GameCreatorAppConfig = { + llm: { + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + stream: false, + requestTimeoutMs: 180000, + maxRetries: 0, + retryBackoffMs: 500, + }, + agentLlm: {}, + editorApi: { + baseUrl: 'http://127.0.0.1:8082', + apiKey: '', + }, +}; + +const runtimeCoreAgentLlmRows = [ + { id: 'planner', label: 'Planner' }, + { id: 'orchestrator', label: 'Orchestrator' }, + { id: 'generator', label: 'Generator' }, + { id: 'evaluator', label: 'Evaluator' }, +] as const; + +const runtimeAgentLlmRows = [ + ...runtimeCoreAgentLlmRows, + ...createGameCreationAppSeedTasks().map((task) => ({ + id: task.id, + label: `${task.title} (${task.group}/${task.role})`, + })), +] satisfies Array<{ id: string; label: string }>; + +const runtimeLlmProviderPresets: Array<{ + id: RuntimeLlmProviderPresetId; + label: string; + baseUrl: string; + model: string; + apiKind: GameCreatorLlmApiKind; +}> = [ + { + id: 'openai', + label: 'OpenAI', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + }, + { + id: 'deepseek', + label: 'DeepSeek', + baseUrl: 'https://api.deepseek.com', + model: 'deepseek-chat', + apiKind: 'openai_chat', + }, + { + id: 'anthropic', + label: 'Anthropic', + baseUrl: 'https://api.anthropic.com', + model: 'claude-3-5-sonnet-latest', + apiKind: 'anthropic', + }, + { + id: 'ark', + label: '火山 Ark', + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + model: 'doubao-seed-1-6', + apiKind: 'openai_chat', + }, +]; + +function findRuntimeLlmProviderPreset( + config: Pick, +) { + return runtimeLlmProviderPresets.find( + (preset) => + preset.baseUrl === config.baseUrl && + preset.model === config.model && + preset.apiKind === config.apiKind, + ); +} + +function resolveRuntimeLlmProviderPresetId( + config: Pick, +): RuntimeLlmProviderPresetId { + return findRuntimeLlmProviderPreset(config)?.id ?? 'custom'; +} + +function resolveRuntimeAgentLlmProviderPresetId( + config: GameCreatorAgentLlmConfig, +): RuntimeAgentLlmProviderPresetId { + if (!config.baseUrl && !config.model && !config.apiKind) { + return 'inherit'; + } + return findRuntimeLlmProviderPreset({ + baseUrl: config.baseUrl ?? '', + model: config.model ?? '', + apiKind: config.apiKind ?? defaultRuntimeConfigDraft.llm.apiKind, + })?.id ?? 'custom'; +} + +function clampRuntimeConfigNumber(value: number, minimum: number) { + const numericValue = Number(value); + return Number.isFinite(numericValue) && numericValue >= minimum + ? numericValue + : minimum; +} + +function normalizeRuntimeAgentLlmConfig( + config: GameCreatorAgentLlmConfig | undefined, +): GameCreatorAgentLlmConfig { + if (!config) { + return {}; + } + const normalized: GameCreatorAgentLlmConfig = {}; + if (typeof config.apiKey === 'string' && config.apiKey.trim()) { + normalized.apiKey = config.apiKey; + } + if (typeof config.baseUrl === 'string' && config.baseUrl.trim()) { + normalized.baseUrl = config.baseUrl; + } + if (typeof config.model === 'string' && config.model.trim()) { + normalized.model = config.model; + } + if ( + config.apiKind && + ['openai_responses', 'openai_chat', 'anthropic'].includes(config.apiKind) + ) { + normalized.apiKind = config.apiKind; + } + if (typeof config.stream === 'boolean') { + normalized.stream = config.stream; + } + if (typeof config.requestTimeoutMs === 'number') { + normalized.requestTimeoutMs = clampRuntimeConfigNumber( + config.requestTimeoutMs, + 1000, + ); + } + if (typeof config.maxRetries === 'number') { + normalized.maxRetries = clampRuntimeConfigNumber(config.maxRetries, 0); + } + if (typeof config.retryBackoffMs === 'number') { + normalized.retryBackoffMs = clampRuntimeConfigNumber( + config.retryBackoffMs, + 1, + ); + } + return normalized; +} + +function normalizeRuntimeConfigDraft( + config: GameCreatorAppConfig, +): GameCreatorAppConfig { + const apiKind: GameCreatorLlmApiKind = [ + 'openai_responses', + 'openai_chat', + 'anthropic', + ].includes(config.llm.apiKind) + ? config.llm.apiKind + : defaultRuntimeConfigDraft.llm.apiKind; + const agentLlm: Record = {}; + for (const [agentId, agentConfig] of Object.entries(config.agentLlm ?? {})) { + const normalized = normalizeRuntimeAgentLlmConfig(agentConfig); + if (Object.keys(normalized).length > 0) { + agentLlm[agentId] = normalized; + } + } + return { + ...config, + llm: { + ...config.llm, + apiKind, + requestTimeoutMs: clampRuntimeConfigNumber( + config.llm.requestTimeoutMs, + 1000, + ), + maxRetries: clampRuntimeConfigNumber(config.llm.maxRetries, 0), + retryBackoffMs: clampRuntimeConfigNumber(config.llm.retryBackoffMs, 1), + }, + agentLlm, + }; +} + +function isEscapeKey(event: { key: string }) { + return event.key === 'Escape'; +} + +function isEditableEscapeTarget(target: EventTarget | null) { + return ( + target instanceof HTMLInputElement || + target instanceof HTMLTextAreaElement || + target instanceof HTMLSelectElement || + (target instanceof HTMLElement && target.isContentEditable) + ); +} + +function closeDialogOnEscape(event: ReactKeyboardEvent, onClose: () => void) { + if (isEscapeKey(event) && !isEditableEscapeTarget(event.target)) { + event.preventDefault(); + onClose(); + } +} + +function useEscapeToClose(onClose: () => void, enabled = true) { + useEffect(() => { + if (!enabled) { + return; + } + + const handleKeyDown = (event: globalThis.KeyboardEvent) => { + if (isEscapeKey(event) && !isEditableEscapeTarget(event.target)) { + event.preventDefault(); + onClose(); + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [enabled, onClose]); +} + +function closeDialogOnBackdropMouseDown( + event: ReactMouseEvent, + onClose: () => void, +) { + if (event.target === event.currentTarget) { + onClose(); + } +} + +function RuntimeConfigDialog({ + onClose, + onLog, +}: { + onClose: () => void; + onLog?: (entry: string) => void; +}) { + const [runtimeConfigPath, setRuntimeConfigPath] = useState(''); + const [runtimeConfigStatus, setRuntimeConfigStatus] = useState('未读取'); + const [runtimeConfigDraft, setRuntimeConfigDraft] = + useState(defaultRuntimeConfigDraft); + const [runtimeConfigBusy, setRuntimeConfigBusy] = useState(false); + const runtimeConfigBusyRef = useRef(false); + + useEscapeToClose(onClose); + + useEffect(() => { + void readRuntimeConfig(); + }, []); + + function updateRuntimeLlmConfig( + key: K, + value: GameCreatorAppConfig['llm'][K], + ) { + setRuntimeConfigDraft((current) => ({ + ...current, + llm: { + ...current.llm, + [key]: value, + }, + })); + } + + function updateRuntimeLlmProviderPreset( + presetId: RuntimeLlmProviderPresetId, + ) { + const preset = runtimeLlmProviderPresets.find( + (candidate) => candidate.id === presetId, + ); + if (!preset) { + return; + } + setRuntimeConfigDraft((current) => ({ + ...current, + llm: { + ...current.llm, + baseUrl: preset.baseUrl, + model: preset.model, + apiKind: preset.apiKind, + }, + })); + } + + function updateRuntimeAgentLlmConfig< + K extends keyof GameCreatorAgentLlmConfig, + >(agentId: string, key: K, value: GameCreatorAgentLlmConfig[K]) { + setRuntimeConfigDraft((current) => ({ + ...current, + agentLlm: { + ...(current.agentLlm ?? {}), + [agentId]: { + ...(current.agentLlm?.[agentId] ?? {}), + [key]: value, + }, + }, + })); + } + + function updateRuntimeAgentLlmProviderPreset( + agentId: string, + presetId: RuntimeAgentLlmProviderPresetId, + ) { + if (presetId === 'inherit') { + setRuntimeConfigDraft((current) => { + const nextConfig = { ...(current.agentLlm?.[agentId] ?? {}) }; + delete nextConfig.baseUrl; + delete nextConfig.model; + delete nextConfig.apiKind; + return { + ...current, + agentLlm: { + ...(current.agentLlm ?? {}), + [agentId]: nextConfig, + }, + }; + }); + return; + } + const preset = runtimeLlmProviderPresets.find( + (candidate) => candidate.id === presetId, + ); + if (!preset) { + return; + } + setRuntimeConfigDraft((current) => ({ + ...current, + agentLlm: { + ...(current.agentLlm ?? {}), + [agentId]: { + ...(current.agentLlm?.[agentId] ?? {}), + baseUrl: preset.baseUrl, + model: preset.model, + apiKind: preset.apiKind, + }, + }, + })); + } + + function updateRuntimeEditorConfig< + K extends keyof GameCreatorAppConfig['editorApi'], + >(key: K, value: GameCreatorAppConfig['editorApi'][K]) { + setRuntimeConfigDraft((current) => ({ + ...current, + editorApi: { + ...current.editorApi, + [key]: value, + }, + })); + } + + async function readRuntimeConfig() { + if (runtimeConfigBusyRef.current) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setRuntimeConfigStatus('需要在 Tauri App 内运行'); + return; + } + + runtimeConfigBusyRef.current = true; + setRuntimeConfigBusy(true); + setRuntimeConfigStatus('正在读取'); + try { + const result = await invoke( + 'read_game_creator_app_config', + ); + setRuntimeConfigPath(result.path); + setRuntimeConfigDraft(normalizeRuntimeConfigDraft(result.config)); + setRuntimeConfigStatus(`已读取:${result.path}`); + onLog?.('runtime_config.read'); + } catch (error) { + setRuntimeConfigStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + runtimeConfigBusyRef.current = false; + setRuntimeConfigBusy(false); + } + } + + async function handleRuntimeConfigSave(event: FormEvent) { + event.preventDefault(); + if (runtimeConfigBusyRef.current) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setRuntimeConfigStatus('需要在 Tauri App 内运行'); + return; + } + + runtimeConfigBusyRef.current = true; + setRuntimeConfigBusy(true); + setRuntimeConfigStatus('正在保存'); + try { + const config = normalizeRuntimeConfigDraft(runtimeConfigDraft); + const result = await invoke( + 'write_game_creator_app_config', + { config }, + ); + setRuntimeConfigPath(result.path); + setRuntimeConfigDraft(normalizeRuntimeConfigDraft(result.config)); + setRuntimeConfigStatus(`已保存:${result.path}`); + onLog?.('runtime_config.save'); + } catch (error) { + setRuntimeConfigStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + runtimeConfigBusyRef.current = false; + setRuntimeConfigBusy(false); + } + } + + function resetRuntimeConfigDraft() { + setRuntimeConfigDraft(defaultRuntimeConfigDraft); + setRuntimeConfigStatus('已恢复默认配置,保存后生效'); + } + + return ( +
closeDialogOnBackdropMouseDown(event, onClose)} + > +
closeDialogOnEscape(event, onClose)} + > +
+

运行时配置

+
+ + + + +
+
+ {runtimeConfigPath ? ( +

{runtimeConfigPath}

+ ) : null} +
+ + + + + + + + + + {runtimeAgentLlmRows.map((agent) => { + const agentLlm = runtimeConfigDraft.agentLlm?.[agent.id] ?? {}; + return ( + + + + + + + + + ); + })} + + +
+

{runtimeConfigStatus}

+
+
+ ); +} + +export function WorkspaceLauncher() { + const [projectPath, setProjectPath] = useState(defaultProjectPath); + const [status, setStatus] = useState('请选择项目'); + const [recentWorkspaces, setRecentWorkspaces] = + useState(readRecentWorkspaces); + const [recentWorkspaceStatuses, setRecentWorkspaceStatuses] = useState< + Record + >({}); + const [recentWorkspaceRefreshKey, setRecentWorkspaceRefreshKey] = useState(0); + const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] = + useState(false); + const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); + const [pendingNonEmptyProjectPath, setPendingNonEmptyProjectPath] = useState< + string | null + >(null); + + useEffect(() => { + const invoke = resolveTauriInvoke(); + if (!invoke || recentWorkspaces.length === 0) { + setRecentWorkspaceStatuses({}); + setRecentWorkspaceRefreshing(false); + return; + } + let disposed = false; + setRecentWorkspaceRefreshing(true); + void Promise.all( + recentWorkspaces.map(async (workspace) => { + try { + const result = await invoke( + 'inspect_local_project_directory', + { projectPath: workspace }, + ); + return [workspace, result] as const; + } catch { + return [workspace, null] as const; + } + }), + ).then((entries) => { + if (disposed) { + return; + } + setRecentWorkspaceStatuses(Object.fromEntries(entries)); + setRecentWorkspaceRefreshing(false); + }); + return () => { + disposed = true; + }; + }, [recentWorkspaces, recentWorkspaceRefreshKey]); + + async function openWorkspaceWindow( + trimmedProjectPath: string, + invoke: TauriInvoke, + ) { + await invoke('open_game_creator_workspace_window', { + projectPath: trimmedProjectPath, + }); + setRecentWorkspaces(writeRecentWorkspace(trimmedProjectPath)); + setRecentWorkspaceRefreshKey((current) => current + 1); + } + + async function createWorkspaceAndOpen( + trimmedProjectPath: string, + invoke: TauriInvoke, + ) { + await invoke('init_local_game_project', { + projectPath: trimmedProjectPath, + projectId: 'local-project-draft', + name: projectNameFromPath(trimmedProjectPath), + }); + await openWorkspaceWindow(trimmedProjectPath, invoke); + } + + async function openProject(nextProjectPath: string, mode: 'open' | 'create') { + const trimmedProjectPath = nextProjectPath.trim(); + if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { + setStatus('请提供项目绝对路径'); + return; + } + if (projectPathHasControlCharacter(trimmedProjectPath)) { + setStatus('项目目录不能包含控制字符'); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setStatus('需要在 Tauri App 内运行'); + return; + } + setStatus('正在打开'); + try { + if (mode === 'open') { + const directoryStatus = await invoke( + 'inspect_local_project_directory', + { projectPath: trimmedProjectPath }, + ); + if (!directoryStatus.exists) { + setStatus('项目目录不存在'); + return; + } + if (!directoryStatus.isDirectory) { + setStatus('项目路径不是文件夹'); + return; + } + if (!directoryStatus.isGameCreatorProject) { + setStatus('这不是已初始化的 AI 游戏项目,请使用新建项目。'); + return; + } + } else { + const nonEmpty = await invoke( + 'is_local_project_directory_non_empty', + { projectPath: trimmedProjectPath }, + ); + if (nonEmpty) { + setPendingNonEmptyProjectPath(trimmedProjectPath); + setStatus('目标文件夹不是空的'); + return; + } + await createWorkspaceAndOpen(trimmedProjectPath, invoke); + return; + } + await openWorkspaceWindow(trimmedProjectPath, invoke); + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)); + } + } + + async function confirmCreateInNonEmptyFolder() { + if (!pendingNonEmptyProjectPath) { + return; + } + const targetProjectPath = pendingNonEmptyProjectPath; + setPendingNonEmptyProjectPath(null); + const invoke = resolveTauriInvoke(); + if (!invoke) { + setStatus('需要在 Tauri App 内运行'); + return; + } + setStatus('正在打开'); + try { + await createWorkspaceAndOpen(targetProjectPath, invoke); + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)); + } + } + + function cancelCreateInNonEmptyFolder() { + setPendingNonEmptyProjectPath(null); + setStatus('已取消'); + } + + useEscapeToClose( + cancelCreateInNonEmptyFolder, + pendingNonEmptyProjectPath !== null, + ); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + void openProject(projectPath, 'open'); + } + + async function handlePickProjectDirectory() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setStatus('需要在 Tauri App 内运行'); + return; + } + setStatus('正在选择'); + try { + const selectedPath = await invoke( + 'pick_local_project_directory', + ); + if (!selectedPath) { + setStatus('已取消'); + return; + } + setProjectPath(selectedPath); + setStatus('已选择项目目录'); + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)); + } + } + + async function handleRevealProjectDirectory(nextProjectPath: string) { + const trimmedProjectPath = nextProjectPath.trim(); + if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { + setStatus('请提供项目绝对路径'); + return; + } + if (projectPathHasControlCharacter(trimmedProjectPath)) { + setStatus('项目目录不能包含控制字符'); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setStatus('需要在 Tauri App 内运行'); + return; + } + try { + await invoke('open_local_project_directory', { + projectPath: trimmedProjectPath, + }); + setStatus('已打开项目目录'); + } catch (error) { + setStatus(error instanceof Error ? error.message : String(error)); + } + } + + return ( +
+ +
+
+ setProjectPath(event.currentTarget.value)} + /> + + + + +
+ {recentWorkspaces.length > 0 ? ( +
+
+

最近项目

+ + +
+ {recentWorkspaces.map((workspace) => ( +
+ + + +
+ ))} +
+ ) : ( +
+

欢迎使用 AI 游戏创作

+
+ + +
+
+ )} +

{status}

+
+ {runtimeConfigOpen ? ( + setRuntimeConfigOpen(false)} /> + ) : null} + {pendingNonEmptyProjectPath ? ( +
{ + if (event.target === event.currentTarget) { + cancelCreateInNonEmptyFolder(); + } + }} + > +
+ closeDialogOnEscape(event, cancelCreateInNonEmptyFolder) + } + > +

文件夹不是空的

+

{pendingNonEmptyProjectPath}

+
+ + +
+
+
+ ) : null} +
+ ); +} + const taskGroupLabels: Record = { design: '策划组', art: '美术组', @@ -321,6 +1773,12 @@ const taskStatusLabels: Record = { failed: '失败', }; +const agentTaskGraphStateLabels: Record = { + active: '本轮 active', + carried: 'carry-over', + ready: 'ready', +}; + const previewStatusLabels: Record = { stopped: '未启动', starting: '启动中', @@ -340,58 +1798,89 @@ const capabilityAreaLabels: Record< const chatCommandHelp = [ '/project /绝对路径:设置本地项目目录', + '/config:打开运行时配置', '/llm-status:检查 LLM 配置', '/capabilities:查看 Agent 能力清单', '/audit:审计当前项目的 Agent 能力证据', '/status:查看项目状态', + '/open-project:在系统文件管理器中显示项目目录', + '/switch-project:回到项目启动器切换工作区', '/index:刷新本地项目索引', '/checkpoint:保存本地项目快照', + '/checkpoints:列出最近 checkpoint', '/diff checkpoint-id:对比 checkpoint', - '/restore checkpoint-id:恢复 checkpoint 中跟踪的文件', + '/restore checkpoint-id:回滚项目文件到 checkpoint', '/policy:查看项目权限策略', '/policy-deny 命令:拒绝项目内某个内置命令', '/policy-allow 命令:移除项目内某个命令拒绝项', + '/policy-confirm 命令:执行前每次确认', + '/policy-auto 命令:恢复自动执行', '/tasks:查看任务拆分', '/trace 或 /loop:查看最近一次 Agent loop trace', '/agent-status:查看最近 run 生命周期', '/agent-kill:标记最近 run 为 killed', - '/agent-retry:把最近 run 标记为 pending 等待重试', - '/agent-resume [说明]:把最近 run 标记为 pending 等待继续', + '/agent-retry:用最近 run 目标重新运行一次', + '/agent-resume [说明]:带说明继续运行最近 run 目标', + '/history:重新读取当前项目对话历史', '/files:列出本地项目文件', '/assets:列出本地项目资产', + '/asset-register 路径 [kind] [mediaType]:登记项目内已有资产', '/read 路径:读取本地项目内文本文件', '/run:运行自检,启动本地 HTTP 预览并交给外部浏览器', '/preview:启动本地 HTTP 预览并交给外部浏览器', '/open-preview:打开当前本地预览', '/preview-status:查看预览状态', '/preview-stop:停止预览', - '/memory [short]:查看长期或短期记忆', - '/remember [short|long] 内容:追加短期或长期记忆', - '/memory-set [short|long] 内容:覆盖保存短期或长期记忆', - '/forget-memory [short]:删除记忆', + '/memory [short|long|blackboard]:查看短期、长期或黑板记忆', + '/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆', + '/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆', + '/forget-memory [short|long|blackboard]:删除对应记忆', + '/commands:查看可运行的受限命令白名单', '/smoke:运行静态入口自检', '/canvas 画板项目ID:打开本机画板项目', '/sync-canvas-project 画板项目ID:同步画板项目资源到本地资产', + '/generate-art 提示词:通过平台 External Editor API 生成首版美术素材', '/import-canvas-asset 本地路径 画板项目ID 资源ID|object:资产对象ID:登记画板来源资产', '/import-canvas-export /绝对/导出.zip 画板项目ID:导入画板素材导出包', ]; -const defaultRuntimeConfigDraft: GameCreatorAppConfig = { - llm: { - apiKey: '', - baseUrl: 'https://api.openai.com/v1', - model: 'gpt-4.1', - apiKind: 'openai_responses', - stream: false, - requestTimeoutMs: 180000, - maxRetries: 0, - retryBackoffMs: 500, - }, - editorApi: { - baseUrl: 'http://127.0.0.1:8082', - apiKey: '', - }, -}; +function missingChatCommandArgumentMessage(prompt: string) { + switch (prompt) { + case '/project': + return '格式:/project /绝对路径'; + case '/diff': + return '格式:/diff checkpoint-id'; + case '/restore': + return '格式:/restore checkpoint-id'; + case '/policy-deny': + return '格式:/policy-deny file.write'; + case '/policy-allow': + return '格式:/policy-allow file.write'; + case '/policy-confirm': + return '格式:/policy-confirm project.index'; + case '/policy-auto': + return '格式:/policy-auto project.index'; + case '/read': + return '格式:/read game/index.html'; + case '/asset-register': + return '格式:/asset-register assets/hero.png [kind] [mediaType]'; + case '/remember': + return '请提供要追加的记忆内容。'; + case '/memory-set': + return '请提供要保存的记忆内容。'; + case '/canvas': + case '/sync-canvas-project': + return '请提供画板项目 ID。'; + case '/generate-art': + return '请提供美术生成提示词。'; + case '/import-canvas-asset': + return '格式:/import-canvas-asset assets/hero.png 画板项目ID 资源ID|object:资产对象ID'; + case '/import-canvas-export': + return '格式:/import-canvas-export /绝对/画板素材.zip 画板项目ID'; + default: + return null; + } +} function taskRowsFromManifest( manifest: GameCreationAppManifest, @@ -493,14 +1982,124 @@ function summarizeProjectCheckpoint(result: LocalProjectCheckpointResult) { ].join('\n'); } +function checkpointIdFromManifestPath(path: string) { + const match = path.match(/^\.agent\/checkpoints\/([^/]+)\/manifest\.json$/); + return match?.[1] ?? null; +} + +function isCheckpointManifestFile(file: LocalProjectFileEntry) { + return file.kind === 'file' && checkpointIdFromManifestPath(file.path); +} + +function sortCheckpointManifestFiles(files: LocalProjectFileEntry[]) { + return files + .filter(isCheckpointManifestFile) + .sort((left, right) => { + const modifiedDelta = (right.modifiedAt ?? 0) - (left.modifiedAt ?? 0); + return modifiedDelta || right.path.localeCompare(left.path); + }); +} + +function summarizeProjectCheckpoints( + checkpoints: LocalProjectCheckpointSummary[], + hiddenCount: number, +) { + if (checkpoints.length === 0) { + return '还没有 checkpoint。输入 /checkpoint 保存当前项目快照。'; + } + const lines = checkpoints.map((checkpoint) => + [ + `- ${checkpoint.checkpointId}`, + `${checkpoint.fileCount} 个文件`, + `${checkpoint.totalBytes}B`, + checkpoint.createdAt ? `createdAt ${checkpoint.createdAt}` : null, + `/diff ${checkpoint.checkpointId}`, + `/restore ${checkpoint.checkpointId}`, + ] + .filter(Boolean) + .join(' · '), + ); + if (hiddenCount > 0) { + lines.push(`- 还有 ${hiddenCount} 个更早 checkpoint`); + } + return `最近 checkpoint:\n${lines.join('\n')}`; +} + +function checkpointSummaryFromManifest( + file: LocalProjectFileEntry, + content: string, +): LocalProjectCheckpointSummary { + const fallbackId = checkpointIdFromManifestPath(file.path) ?? file.path; + try { + const parsed: unknown = JSON.parse(content); + const data = + parsed && typeof parsed === 'object' + ? (parsed as { + checkpointId?: unknown; + createdAt?: unknown; + files?: unknown; + }) + : {}; + const files = Array.isArray(data.files) ? data.files : []; + const manifestFileCount = (data as { fileCount?: unknown }).fileCount; + const fileCount = + files.length > 0 + ? files.length + : typeof manifestFileCount === 'number' && manifestFileCount >= 0 + ? manifestFileCount + : 0; + const totalBytes = files.reduce((sum, item) => { + if (!item || typeof item !== 'object') { + return sum; + } + const size = (item as { size?: unknown }).size; + return sum + (typeof size === 'number' && size > 0 ? size : 0); + }, 0); + const manifestTotalBytes = (data as { totalBytes?: unknown }).totalBytes; + const resolvedTotalBytes = + totalBytes > 0 + ? totalBytes + : typeof manifestTotalBytes === 'number' && manifestTotalBytes >= 0 + ? manifestTotalBytes + : 0; + return { + checkpointId: + typeof data.checkpointId === 'string' && data.checkpointId.trim() + ? data.checkpointId + : fallbackId, + path: file.path, + fileCount, + totalBytes: resolvedTotalBytes, + createdAt: + typeof data.createdAt === 'number' || typeof data.createdAt === 'string' + ? String(data.createdAt) + : '', + modifiedAt: file.modifiedAt, + }; + } catch { + return { + checkpointId: fallbackId, + path: file.path, + fileCount: 0, + totalBytes: 0, + createdAt: '', + modifiedAt: file.modifiedAt, + }; + } +} + function summarizeProjectDiff(result: LocalProjectDiffResult) { - const section = (label: string, files: Array<{ path: string }>) => - files.length > 0 - ? `${label}:\n${files - .slice(0, 20) - .map((file) => `- ${file.path}`) - .join('\n')}` - : null; + const section = (label: string, files: Array<{ path: string }>) => { + if (files.length === 0) { + return null; + } + const visibleFiles = files.slice(0, 20); + const lines = visibleFiles.map((file) => `- ${file.path}`); + if (files.length > visibleFiles.length) { + lines.push(`- 还有 ${files.length - visibleFiles.length} 项`); + } + return `${label}:\n${lines.join('\n')}`; + }; return ( [ `checkpoint:${result.checkpointId}`, @@ -516,11 +2115,37 @@ function summarizeProjectDiff(result: LocalProjectDiffResult) { function summarizeProjectPolicy(view: ProjectPermissionPolicyView) { return [ `策略:${view.path}`, - `拒绝:${view.policy.deniedCommands.join('、') || '无'}`, - `确认:${view.policy.confirmCommands.join('、') || '无'}`, + `拒绝:${formatProjectPolicyCommandList(view.policy.deniedCommands)}`, + `确认:${formatProjectPolicyCommandList(view.policy.confirmCommands)}`, ].join('\n'); } +function formatProjectPolicyCommandList(values: string[]) { + if (values.length === 0) { + return '无'; + } + const visibleValues = values.slice(0, 12); + return [ + visibleValues.join('、'), + values.length > visibleValues.length + ? `还有 ${values.length - visibleValues.length} 项` + : null, + ] + .filter(Boolean) + .join('、'); +} + +function formatCanvasAssetSource(source: { + canvasProjectId: string; + canvasAssetId: string; + canvasAssetObjectId?: string; +}) { + const assetReference = source.canvasAssetObjectId + ? `object:${source.canvasAssetObjectId}` + : source.canvasAssetId; + return `${source.canvasProjectId} / ${assetReference || '未提供资产 ID'}`; +} + function summarizeProjectFileContent(result: LocalProjectFileResult) { const limit = 4000; const content = @@ -533,36 +2158,100 @@ function summarizeProjectFileContent(result: LocalProjectFileResult) { return `文件:${result.path}\n${content || '空文件'}`; } +function inferProjectFileAssetDraft(localPath: string): ProjectAssetDraft { + const extension = localPath.split('.').pop()?.toLowerCase() ?? ''; + if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif'].includes(extension)) { + const normalizedExtension = extension === 'jpg' ? 'jpeg' : extension; + return { + localPath, + kind: 'image', + mediaType: + extension === 'svg' ? 'image/svg+xml' : `image/${normalizedExtension}`, + }; + } + if (['mp3', 'wav', 'ogg', 'm4a', 'flac'].includes(extension)) { + return { + localPath, + kind: 'audio', + mediaType: extension === 'm4a' ? 'audio/mp4' : `audio/${extension}`, + }; + } + if (['mp4', 'webm', 'mov'].includes(extension)) { + return { + localPath, + kind: 'video', + mediaType: extension === 'mov' ? 'video/quicktime' : `video/${extension}`, + }; + } + if (extension === 'json') { + return { localPath, kind: 'data', mediaType: 'application/json' }; + } + if (extension === 'html') { + return { localPath, kind: 'document', mediaType: 'text/html' }; + } + if (['txt', 'md', 'csv'].includes(extension)) { + return { localPath, kind: 'document', mediaType: 'text/plain' }; + } + return { localPath, kind: 'asset', mediaType: 'application/octet-stream' }; +} + +function projectAssetDraftCommand(draft: ProjectAssetDraft) { + return `/asset-register ${draft.localPath} ${draft.kind} ${draft.mediaType}`; +} + +function projectFileActionDrafts(localPath: string): ProjectFileActionDraft { + return { + readCommand: `/read ${localPath}`, + assetCommand: projectAssetDraftCommand( + inferProjectFileAssetDraft(localPath), + ), + }; +} + function summarizeProjectAssets(nextManifest: GameCreationAppManifest) { if (nextManifest.assets.length === 0) { return '本地项目还没有登记资产。'; } - return `本地项目资产:\n${nextManifest.assets - .map( - (asset) => - `- ${asset.kind} · ${asset.localPath} · ${asset.source.kind}${ - asset.source.canvasProjectId - ? ` · 画板 ${asset.source.canvasProjectId}` - : '' - }`, - ) - .join('\n')}`; + const visibleAssets = nextManifest.assets.slice(0, 20); + const lines = visibleAssets.map( + (asset) => + `- ${asset.kind} · ${asset.localPath} · ${asset.source.kind}${ + asset.source.canvasProjectId + ? ` · 画板 ${asset.source.canvasProjectId}` + : '' + }`, + ); + if (nextManifest.assets.length > visibleAssets.length) { + lines.push( + `- 还有 ${nextManifest.assets.length - visibleAssets.length} 个资产`, + ); + } + return `本地项目资产:\n${lines.join('\n')}`; } -function summarizeAgentCapabilities() { +function firstReadableProjectAssetPath(nextManifest: GameCreationAppManifest) { + return nextManifest.assets.find((asset) => + isSafeProjectRelativePath(asset.localPath), + )?.localPath; +} + +function summarizeAgentCapabilities( + capabilities: readonly GameCreationAgentCapabilityDescriptor[] = + GAME_CREATION_AGENT_CAPABILITIES, +) { const lines = ['Agent 能力清单:']; for (const area of Object.keys(capabilityAreaLabels) as Array< keyof typeof capabilityAreaLabels >) { - const capabilities = GAME_CREATION_AGENT_CAPABILITIES.filter( + const areaCapabilities = capabilities.filter( (capability) => capability.area === area, ); - if (capabilities.length === 0) { + if (areaCapabilities.length === 0) { continue; } lines.push( - `${capabilityAreaLabels[area]}:${capabilities + `${capabilityAreaLabels[area]}:${areaCapabilities .map((capability) => capability.title) .join('、')}`, ); @@ -570,6 +2259,164 @@ function summarizeAgentCapabilities() { return lines.join('\n'); } +function summarizeLimitedLocalCommands( + commands: readonly GameCreationAppLimitedRunCommandDescriptor[], +) { + if (commands.length === 0) { + return '当前没有可运行的受限命令。'; + } + return `可运行受限命令:\n${commands + .map((command) => `- ${command.id} · ${command.title}`) + .join('\n')}`; +} + +function agentConversationId(task: GameCreationAppTaskState) { + return `${task.group}-${task.role}` + .toLowerCase() + .replace(/[^a-z0-9_-]/g, '-'); +} + +function taskStatusFromTraceStep( + stepStatus: string | undefined, + fallback: GameCreationAppTaskStatus, +): GameCreationAppTaskStatus { + switch (stepStatus) { + case 'completed': + case 'passed': + return 'completed'; + case 'running': + return 'running'; + case 'failed': + case 'needs-revision': + return 'failed'; + case 'suggested': + return 'waiting-for-confirmation'; + default: + return fallback; + } +} + +function agentGroupRoleKey( + group: string | null | undefined, + role: string | null | undefined, +) { + return group && role ? `${group}::${role}` : null; +} + +function taskGraphStateForTask( + taskId: string, + trace: GameCreationAgentRunTrace | null, +): AgentTaskGraphState | null { + if (!trace) { + return null; + } + if (trace.taskGraph.activeTaskIds.includes(taskId)) { + return 'active'; + } + if (trace.taskGraph.carriedTaskIds.includes(taskId)) { + return 'carried'; + } + if (trace.taskGraph.readyTaskIds.includes(taskId)) { + return 'ready'; + } + return null; +} + +function taskRowsForAgentStatus( + nextManifest: GameCreationAppManifest, + trace: GameCreationAgentRunTrace | null, +) { + const manifestTasks = taskRowsFromManifest(nextManifest); + const traceTasks = trace?.taskGraph.tasks ?? []; + const traceTasksById = new Map(traceTasks.map((task) => [task.id, task])); + const mergedTasks = manifestTasks.map( + (task) => traceTasksById.get(task.id) ?? task, + ); + for (const task of traceTasks) { + if (!manifestTasks.some((manifestTask) => manifestTask.id === task.id)) { + mergedTasks.push(task); + } + } + return mergedTasks; +} + +export function deriveAgentStatusCards( + nextManifest: GameCreationAppManifest, + trace: GameCreationAgentRunTrace | null, +): AgentStatusCard[] { + const latestByTaskId = new Map(); + const latestByGroupRole = new Map(); + for (const step of trace?.steps ?? []) { + if (step.taskId) { + latestByTaskId.set(step.taskId, step); + } + const groupRoleKey = agentGroupRoleKey(step.group, step.role); + if (groupRoleKey) { + latestByGroupRole.set(groupRoleKey, step); + } + } + return taskRowsForAgentStatus(nextManifest, trace).map((task) => { + const latestStep = + latestByTaskId.get(task.id) ?? + latestByGroupRole.get(agentGroupRoleKey(task.group, task.role) ?? ''); + return { + id: agentConversationId(task), + taskId: task.id, + title: task.title, + group: task.group, + role: task.role, + status: taskStatusFromTraceStep(latestStep?.status, task.status), + summary: latestStep?.summary ?? task.acceptanceCriteria[0] ?? task.id, + pass: latestStep?.pass ?? null, + phase: latestStep?.phase ?? null, + lifecycleStatus: trace?.lifecycleStatus ?? null, + hasRecentEvidence: latestStep !== undefined, + taskGraphState: taskGraphStateForTask(task.id, trace), + inputPaths: latestStep?.inputPaths ?? [], + outputPaths: latestStep?.outputPaths ?? [], + toolCalls: latestStep?.toolCalls ?? [], + }; + }); +} + +function sameStringArray(left: string[], right: string[]) { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ); +} + +function sameAgentStatusCard(left: AgentStatusCard, right: AgentStatusCard) { + return ( + left.id === right.id && + left.taskId === right.taskId && + left.title === right.title && + left.group === right.group && + left.role === right.role && + left.status === right.status && + left.summary === right.summary && + left.pass === right.pass && + left.phase === right.phase && + left.lifecycleStatus === right.lifecycleStatus && + left.hasRecentEvidence === right.hasRecentEvidence && + left.taskGraphState === right.taskGraphState && + sameStringArray(left.inputPaths, right.inputPaths) && + sameStringArray(left.outputPaths, right.outputPaths) && + left.toolCalls.length === right.toolCalls.length && + left.toolCalls.every((toolCall, index) => { + const other = right.toolCalls[index]; + return ( + other && + toolCall.toolId === other.toolId && + toolCall.status === other.status && + toolCall.summary === other.summary && + sameStringArray(toolCall.inputPaths, other.inputPaths) && + sameStringArray(toolCall.outputPaths, other.outputPaths) + ); + }) + ); +} + function hasProjectFile(files: LocalProjectFileEntry[], path: string) { return files.some((file) => file.kind === 'file' && file.path === path); } @@ -709,7 +2556,9 @@ export function summarizeAgentAudit( const hasPermissionDecision = commandLogContent.includes('permission.confirm') || commandLogContent.includes('permission.cancel'); - const hasCommandLog = hasPermissionPending && hasPermissionDecision; + const hasAutoPermissionLog = commandLogContent.includes('command.auto'); + const hasCommandLog = + (hasPermissionPending && hasPermissionDecision) || hasAutoPermissionLog; const preview = nextManifest.preview; const previewSummary = preview?.status === 'running' && preview.url @@ -782,8 +2631,10 @@ export function summarizeAgentAudit( : '暂无 canvas 来源资产' }`, `- 权限 Gate/命令日志:${formatAuditStatus(hasCommandLog)} · ${ - hasCommandLog + hasPermissionPending && hasPermissionDecision ? '.agent/logs/command.log 含 pending/decision' + : hasAutoPermissionLog + ? '.agent/logs/command.log 含 auto 权限记录' : commandRuns.length > 0 || hasProjectFile(files, '.agent/logs/command.log') ? '缺 permission.pending 或确认/取消记录' @@ -819,18 +2670,52 @@ function summarizeProjectTasks(nextManifest: GameCreationAppManifest) { } function memoryScopeLabel(scope: MemoryScope) { - return scope === 'short' ? '短期' : '长期'; + if (scope === 'short') { + return '短期'; + } + return scope === 'blackboard' ? '黑板' : '长期'; } function memoryScopePath(scope: MemoryScope) { - return scope === 'short' ? 'memory/session.md' : 'memory/project.md'; + if (scope === 'short') { + return 'memory/session.md'; + } + return scope === 'blackboard' + ? 'memory/blackboard.md' + : 'memory/project.md'; } function parseMemoryScope(value: string): MemoryScope { const scope = value.trim().toLowerCase(); - return scope === 'short' || scope === '短期' || scope === 'session' - ? 'short' - : 'long'; + if (scope === 'short' || scope === '短期' || scope === 'session') { + return 'short'; + } + if (scope === 'blackboard' || scope === '黑板') { + return 'blackboard'; + } + return 'long'; +} + +function parseOptionalMemoryScope(value: string): MemoryScope | null { + const scope = value.trim().toLowerCase(); + if (!scope) { + return 'long'; + } + if ( + [ + 'short', + '短期', + 'session', + 'long', + '长期', + 'project', + 'blackboard', + '黑板', + ].includes(scope) + ) { + return parseMemoryScope(scope); + } + return null; } export function parseRememberInput(value: string): { @@ -840,7 +2725,18 @@ export function parseRememberInput(value: string): { const input = value.trim(); const [first = '', ...rest] = input.split(/\s+/); const lower = first.toLowerCase(); - if (['short', '短期', 'session', 'long', '长期', 'project'].includes(lower)) { + if ( + [ + 'short', + '短期', + 'session', + 'long', + '长期', + 'project', + 'blackboard', + '黑板', + ].includes(lower) + ) { return { scope: parseMemoryScope(first), content: rest.join(' ').trim(), @@ -852,7 +2748,15 @@ export function parseRememberInput(value: string): { export function resolveChatProjectPath( localProject: { projectPath: string } | null, ) { - return localProject?.projectPath ?? null; + const projectPath = localProject?.projectPath?.trim(); + if ( + !projectPath || + !isAbsoluteProjectPath(projectPath) || + projectPathHasControlCharacter(projectPath) + ) { + return null; + } + return projectPath; } export function isAbsoluteProjectPath(value: string) { @@ -864,15 +2768,101 @@ export function isAbsoluteProjectPath(value: string) { ); } -export function needsInitializedChatProject(commandId: PendingCommand['id']) { +function projectPathHasControlCharacter(value: string) { + return value + .trim() + .split('') + .some((character) => { + const code = character.charCodeAt(0); + return code < 32 || code === 127; + }); +} + +function isSafeProjectRelativePath(value: string) { + const path = value.trim(); + return ( + !!path && + !isAbsoluteProjectPath(path) && + !projectPathHasControlCharacter(path) && + !path.includes('\\') && + !path.includes(':') && + path.split('/').every((part) => part && part !== '.' && part !== '..') + ); +} + +function isAgentTraceFilePath(value: string) { + const path = value.trim(); + return ( + path === '.agent/run.latest.json' || + (path.startsWith('.agent/runs/') && path.endsWith('.json')) + ); +} + +function isRegisteredGameCreationCommandId(value: string) { + return GAME_CREATION_APP_COMMANDS.some((command) => command.id === value); +} + +function isProjectPolicyConfirmableCommandId(value: string) { + return [ + 'project.index', + 'project.status', + 'project.checkpoint', + 'project.diff', + 'project.restore', + 'file.list', + 'file.read', + 'memory.read', + 'asset.register', + 'asset.list', + 'task.list', + 'agent.run_status', + 'agent.kill', + 'agent.retry', + 'agent.resume', + 'agent.audit', + 'agent.trace_read', + 'preview.status', + 'preview.start', + 'preview.open', + 'preview.stop', + 'canvas.project_sync', + 'canvas.asset_import', + 'canvas.asset_generate', + 'canvas.export_import', + 'conversation.read', + 'conversation.write', + ].includes(value); +} + +function isSafeCanvasProjectId(value: string) { + return !!value.trim() && !projectPathHasControlCharacter(value); +} + +function isSafeCheckpointId(value: string) { + const checkpointId = value.trim(); + return ( + !!checkpointId && + !projectPathHasControlCharacter(checkpointId) && + !checkpointId.includes('/') && + !checkpointId.includes('\\') && + !checkpointId.includes('..') + ); +} + +export function needsInitializedChatProject( + commandId: GameCreationAppCommandDescriptor['id'], +) { return [ 'asset.upload', + 'asset.register', 'agent.kill', 'agent.retry', 'agent.resume', 'command.run_limited', 'game.generate_draft', 'game.run_local', + 'file.list', + 'file.read', 'project.checkpoint', 'project.restore', 'project.policy_write', @@ -880,9 +2870,13 @@ export function needsInitializedChatProject(commandId: PendingCommand['id']) { 'preview.start', 'canvas.project_sync', 'canvas.asset_import', + 'canvas.asset_generate', 'canvas.export_import', + 'memory.read', 'memory.write', 'memory.delete', + 'conversation.read', + 'conversation.write', ].includes(commandId); } @@ -905,15 +2899,28 @@ function appendMemoryContent(current: string, next: string) { return current.trim() ? `${current.trimEnd()}\n${entry}` : entry; } +function normalizeTraceArray( + value: T[] | null | undefined, + fallback: T[], +) { + if (value == null) { + return fallback; + } + if (!Array.isArray(value)) { + throw new Error('Agent run trace 格式不正确'); + } + return value; +} + function parseAgentRunTrace(content: string): GameCreationAgentRunTrace { const parsed = JSON.parse(content) as GameCreationAgentRunTrace; if ( parsed.schemaVersion !== GAME_CREATION_AGENT_RUN_SCHEMA_VERSION || - !Array.isArray(parsed.steps) || - !Array.isArray(parsed.artifacts) + !Array.isArray(parsed.steps) ) { throw new Error('Agent run trace 格式不正确'); } + parsed.artifacts = normalizeTraceArray(parsed.artifacts, []); parsed.taskGraph ??= { goal: parsed.goal, readyTaskIds: [], @@ -923,6 +2930,14 @@ function parseAgentRunTrace(content: string): GameCreationAgentRunTrace { repairRoutes: [], tasks: createGameCreationAppSeedTasks(), }; + if (typeof parsed.taskGraph !== 'object' || Array.isArray(parsed.taskGraph)) { + throw new Error('Agent run trace 格式不正确'); + } + parsed.steps.forEach((step) => { + step.inputPaths = normalizeTraceArray(step.inputPaths, []); + step.outputPaths = normalizeTraceArray(step.outputPaths, []); + step.toolCalls = normalizeTraceArray(step.toolCalls, []); + }); parsed.maxPasses ??= GAME_CREATION_AGENT_RUN_MAX_PASSES; parsed.toolCallCount ??= parsed.steps.reduce( (count, step) => count + step.toolCalls.length, @@ -930,11 +2945,101 @@ function parseAgentRunTrace(content: string): GameCreationAgentRunTrace { ); parsed.maxToolCalls ??= GAME_CREATION_AGENT_TOOL_CALL_MAX; parsed.stopReason ??= parsed.status; - parsed.taskGraph.repairRoutes ??= []; - parsed.passPlans ??= []; + parsed.taskGraph.readyTaskIds = normalizeTraceArray( + parsed.taskGraph.readyTaskIds, + [], + ); + parsed.taskGraph.activeTaskIds = normalizeTraceArray( + parsed.taskGraph.activeTaskIds, + [], + ); + parsed.taskGraph.carriedTaskIds = normalizeTraceArray( + parsed.taskGraph.carriedTaskIds, + [], + ); + parsed.taskGraph.repairFocus = normalizeTraceArray( + parsed.taskGraph.repairFocus, + [], + ); + parsed.taskGraph.repairRoutes = normalizeTraceArray( + parsed.taskGraph.repairRoutes, + [], + ); + parsed.taskGraph.repairRoutes.forEach((route) => { + route.taskIds = normalizeTraceArray(route.taskIds, []); + }); + parsed.taskGraph.tasks = normalizeTraceArray( + parsed.taskGraph.tasks, + createGameCreationAppSeedTasks(), + ); + parsed.passPlans = normalizeTraceArray(parsed.passPlans, []); + parsed.passPlans.forEach((plan) => { + plan.activeTaskIds = normalizeTraceArray(plan.activeTaskIds, []); + plan.carriedTaskIds = normalizeTraceArray(plan.carriedTaskIds, []); + plan.dependencyWaves = normalizeTraceArray(plan.dependencyWaves, []); + plan.dependencyWaves.forEach((wave) => { + if (!Array.isArray(wave)) { + throw new Error('Agent run trace 格式不正确'); + } + }); + plan.repairFocus = normalizeTraceArray(plan.repairFocus, []); + plan.repairRoutes = normalizeTraceArray(plan.repairRoutes, []); + plan.repairRoutes.forEach((route) => { + route.taskIds = normalizeTraceArray(route.taskIds, []); + }); + }); return parsed; } +function formatAgentRunStatus(trace: GameCreationAgentRunTrace) { + const status = trace.lifecycleStatus + ? `${trace.status} / ${trace.lifecycleStatus}` + : trace.status; + return `${status} · ${trace.passes}/${trace.maxPasses} 轮 · ${trace.stopReason}`; +} + +function isMissingProjectFileError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes('读取文件元数据失败') && + (message.includes('No such file') || + message.includes('os error 2') || + message.includes('找不到')) + ); +} + +function isMissingAgentRunTraceError(message: string) { + return ( + message.includes('.agent/run.latest.json') && + (message.includes('No such file') || + message.includes('os error 2') || + message.includes('找不到')) + ); +} + +function formatAgentRunControlError(action: string, message: string) { + if (!isMissingAgentRunTraceError(message)) { + return message; + } + return action === 'status' + ? '暂无最近 Agent run。先生成一次游戏草案后再查看状态。' + : '暂无可控制的 Agent run。先生成一次游戏草案后再操作。'; +} + +function formatLlmAgentStatusLine(agent: GameCreatorAgentLlmConfigStatus) { + const parts = [ + `${agent.label}:${agent.configured ? '已配置' : '未就绪'}`, + `${agent.model ?? '未命名模型'} @ ${agent.baseUrl ?? '未设置 base_url'}`, + agent.apiKind, + `流式 ${agent.stream ? '开启' : '关闭'}`, + `API Key ${agent.apiKeyPresent ? '已读取' : '未读取'}`, + ]; + if (!agent.configured && agent.error) { + parts.push(`错误:${agent.error}`); + } + return parts.join(','); +} + function formatTraceTaskId(taskId: string, tasks: GameCreationAppTaskState[]) { const task = tasks.find((candidate) => candidate.id === taskId); if (!task) { @@ -952,44 +3057,105 @@ function formatTraceTaskIds( : 'none'; } +function formatTraceTaskWaves( + waves: string[][], + tasks: GameCreationAppTaskState[], +) { + return ( + waves + .map((wave) => + wave.map((taskId) => formatTraceTaskId(taskId, tasks)).join(' + '), + ) + .join(' / ') || 'none' + ); +} + +function formatTraceRepairRoutes( + routes: GameCreationAgentRepairRouteTrace[], + tasks: GameCreationAppTaskState[], +) { + const visibleRoutes = routes + .slice(0, 3) + .map((route) => `${route.reason}: ${formatTraceTaskIds(route.taskIds, tasks)}`); + if (routes.length > visibleRoutes.length) { + visibleRoutes.push(`还有 ${routes.length - visibleRoutes.length} 条路线`); + } + return visibleRoutes.join(';'); +} + function summarizeSuggestedToolCalls(trace: GameCreationAgentRunTrace) { - return trace.steps + const suggestedToolCalls = trace.steps .flatMap((step) => step.toolCalls) .filter( (toolCall) => toolCall.status === 'suggested' || toolCall.toolId.startsWith('agent.tool.suggest.'), - ) + ); + const lines = suggestedToolCalls .slice(0, 5) - .map((toolCall) => `- ${toolCall.toolId}: ${toolCall.summary}`) - .join('\n'); + .map((toolCall) => `- ${toolCall.toolId}: ${toolCall.summary}`); + if (suggestedToolCalls.length > lines.length) { + lines.push(`- 还有 ${suggestedToolCalls.length - lines.length} 个建议命令`); + } + return lines.join('\n'); +} + +function commandDraftFromSuggestedToolCall( + toolCall: GameCreationAgentToolCallTrace, +) { + if ( + toolCall.toolId.includes('canvas.project_sync') && + (toolCall.status === 'suggested' || + toolCall.toolId.startsWith('agent.tool.suggest.')) + ) { + return '/sync-canvas-project '; + } + return null; +} + +function commandDraftFromAgentRunTrace(trace: GameCreationAgentRunTrace) { + return ( + trace.steps + .flatMap((step) => step.toolCalls) + .map(commandDraftFromSuggestedToolCall) + .find((commandDraft) => commandDraft !== null) ?? + null + ); +} + +function localAgentConversationReceipt(agent: AgentStatusCard) { + return `已记录给 ${agent.title}。下一次生成会把这条对话作为该 agent 的上下文读取。`; } function summarizeLlmConversation(trace: GameCreationAgentRunTrace) { - return trace.steps - .filter((step) => - step.toolCalls.some((toolCall) => toolCall.toolId.startsWith('llm.')), - ) - .slice(-6) - .map((step) => { - const toolIds = step.toolCalls - .filter((toolCall) => toolCall.toolId.startsWith('llm.')) - .map((toolCall) => toolCall.toolId) - .join(', '); - return `- ${step.agent} #${step.pass} · ${step.status} · ${step.phase} · ${toolIds}`; - }) - .join('\n'); + const llmSteps = trace.steps.filter((step) => + step.toolCalls.some((toolCall) => toolCall.toolId.startsWith('llm.')), + ); + const visibleLlmSteps = llmSteps.slice(-6); + const lines = visibleLlmSteps.map((step) => { + const toolIds = step.toolCalls + .filter((toolCall) => toolCall.toolId.startsWith('llm.')) + .map((toolCall) => toolCall.toolId) + .join(', '); + return `- ${step.agent} #${step.pass} · ${step.status} · ${step.phase} · ${toolIds}`; + }); + if (llmSteps.length > visibleLlmSteps.length) { + lines.push(`- 还有 ${llmSteps.length - visibleLlmSteps.length} 个较早 LLM 步骤`); + } + return lines.join('\n'); } export function summarizeAgentRunTrace(trace: GameCreationAgentRunTrace) { const llmConversation = summarizeLlmConversation(trace); - const recentSteps = trace.steps - .slice(-5) - .map( - (step) => - `- ${step.agent} #${step.pass} · ${step.status} · ${step.phase}`, - ) - .join('\n'); + const visibleRecentSteps = trace.steps.slice(-5); + const recentStepLines = visibleRecentSteps.map( + (step) => + `- ${step.agent} #${step.pass} · ${step.status} · ${step.phase}`, + ); + if (trace.steps.length > visibleRecentSteps.length) { + recentStepLines.push(`- 还有 ${trace.steps.length - visibleRecentSteps.length} 个较早步骤`); + } + const recentSteps = recentStepLines.join('\n'); const taskCounts = trace.taskGraph.tasks.reduce< Record >( @@ -1017,35 +3183,53 @@ export function summarizeAgentRunTrace(trace: GameCreationAgentRunTrace) { .filter((status) => taskCounts[status] > 0) .map((status) => `${taskStatusLabels[status]} ${taskCounts[status]}`) .join(','); - const repair = trace.taskGraph.repairRoutes - .slice(0, 3) - .map( - (route) => - `${route.reason}: ${formatTraceTaskIds(route.taskIds, trace.taskGraph.tasks)}`, - ) - .join(';'); + const repair = formatTraceRepairRoutes( + trace.taskGraph.repairRoutes, + trace.taskGraph.tasks, + ); const suggestedTools = summarizeSuggestedToolCalls(trace); - const passPlans = (trace.passPlans ?? []) - .slice(-3) - .map((plan) => { - const waves = plan.dependencyWaves - .map((wave) => - wave - .map((taskId) => formatTraceTaskId(taskId, trace.taskGraph.tasks)) - .join(' + '), - ) - .join(' / '); - return `- pass ${plan.pass} · ${plan.mode} · active ${plan.activeTaskIds.length} · carry ${plan.carriedTaskIds.length} · waves ${waves || 'none'}`; - }) - .join('\n'); - const artifacts = trace.artifacts - .slice(-5) - .map((artifact) => `- ${artifact.path} · ${artifact.checksum}`) - .join('\n'); + const allPassPlans = trace.passPlans ?? []; + const visiblePassPlans = allPassPlans.slice(-3); + const passPlanLines = visiblePassPlans.map((plan) => { + const waves = formatTraceTaskWaves( + plan.dependencyWaves, + trace.taskGraph.tasks, + ); + return [ + `- pass ${plan.pass} · ${plan.mode}`, + `active ${formatTraceTaskIds(plan.activeTaskIds, trace.taskGraph.tasks)}`, + `carry ${formatTraceTaskIds(plan.carriedTaskIds, trace.taskGraph.tasks)}`, + `waves ${waves}`, + plan.repairFocus.length > 0 + ? `repair ${plan.repairFocus.join(';')}` + : null, + plan.repairRoutes.length > 0 + ? `routes ${formatTraceRepairRoutes(plan.repairRoutes, trace.taskGraph.tasks)}` + : null, + ] + .filter(Boolean) + .join(' · '); + }); + if (allPassPlans.length > visiblePassPlans.length) { + passPlanLines.push( + `- 还有 ${allPassPlans.length - visiblePassPlans.length} 个较早轮次`, + ); + } + const passPlans = passPlanLines.join('\n'); + const visibleArtifacts = trace.artifacts.slice(-5); + const artifactLines = visibleArtifacts.map( + (artifact) => `- ${artifact.path} · ${artifact.checksum}`, + ); + if (trace.artifacts.length > visibleArtifacts.length) { + artifactLines.push( + `- 还有 ${trace.artifacts.length - visibleArtifacts.length} 个较早产物`, + ); + } + const artifacts = artifactLines.join('\n'); return [ `Run:${trace.runId}`, - `状态:${trace.status} · ${trace.passes}/${trace.maxPasses} 轮 · ${trace.stopReason}`, + `状态:${formatAgentRunStatus(trace)}`, `工具调用:${trace.toolCallCount}/${trace.maxToolCalls}`, `下一步:${trace.nextStep}`, llmConversation ? `LLM 对话:\n${llmConversation}` : null, @@ -1072,7 +3256,10 @@ export function summarizeAgentRunTrace(trace: GameCreationAgentRunTrace) { } function summarizeAgentRunCompletionForChat(trace: GameCreationAgentRunTrace) { - return `${summarizeAgentRunTrace(trace)}\n完整 trace:/trace`; + return { + text: `${summarizeAgentRunTrace(trace)}\n完整 trace:/trace`, + draftCommand: commandDraftFromAgentRunTrace(trace) ?? undefined, + }; } function gameDraftStartedMessage() { @@ -1087,6 +3274,9 @@ function pendingCommandTitle(command: PendingCommand) { if (command.id === 'asset.upload') { return `${command.id} · ${command.file.name}`; } + if (command.id === 'asset.register') { + return `${command.id} · ${command.localPath}`; + } if (command.id === 'command.run_limited') { const descriptor = GAME_CREATION_APP_LIMITED_RUN_COMMANDS.find( (limitedCommand) => limitedCommand.id === command.commandId, @@ -1102,6 +3292,9 @@ function pendingCommandTitle(command: PendingCommand) { if (command.id === 'project.checkpoint') { return command.id; } + if (command.id === 'project.index') { + return command.id; + } if (command.id === 'project.restore') { return `${command.id} · ${command.checkpointId}`; } @@ -1127,6 +3320,9 @@ function pendingCommandTitle(command: PendingCommand) { if (command.id === 'canvas.asset_import') { return `${command.id} · ${command.localPath}`; } + if (command.id === 'canvas.asset_generate') { + return command.id; + } if (command.id === 'canvas.export_import') { return `${command.id} · ${command.exportPath}`; } @@ -1140,6 +3336,9 @@ export function pendingCommandDetail( if (command.id === 'asset.upload') { return `保存到 ${projectPath}/assets/uploads/`; } + if (command.id === 'asset.register') { + return `登记 ${projectPath}/${command.localPath} · ${command.kind} · ${command.mediaType}`; + } if (command.id === 'command.run_limited') { return `运行 ${command.commandId} 于 ${projectPath}`; } @@ -1155,26 +3354,33 @@ export function pendingCommandDetail( if (command.id === 'project.checkpoint') { return `保存 ${projectPath} 当前项目快照`; } + if (command.id === 'project.index') { + return `刷新 ${projectPath}/.agent/project.index.json`; + } if (command.id === 'project.restore') { - return `从 ${command.checkpointId} 恢复已跟踪项目文件`; + return `从 ${command.checkpointId} 恢复 ${projectPath} 的已跟踪项目文件`; } if (command.id === 'project.policy_write') { - return `写入 ${projectPath}/.agent/policy.json`; + return `写入 ${projectPath}/.agent/policy.json · 拒绝:${formatProjectPolicyCommandList( + command.policy.deniedCommands, + )} · 确认:${formatProjectPolicyCommandList(command.policy.confirmCommands)}`; } if (command.id === 'preview.start') { return `启动 ${projectPath}/game/ 并交给外部浏览器`; } if (command.id === 'preview.open') { - return '打开当前本地预览'; + return `打开 ${projectPath} 的当前本地预览`; } if (command.id === 'agent.kill') { return `标记 ${projectPath}/.agent/run.latest.json 为 killed,并写入 activity/output`; } if (command.id === 'agent.retry') { - return `标记 ${projectPath}/.agent/run.latest.json 为 pending,等待 runner claim`; + return `使用 ${projectPath}/.agent/run.latest.json 的目标重新运行一次`; } if (command.id === 'agent.resume') { - return `附加用户说明并标记 ${projectPath}/.agent/run.latest.json 为 pending`; + return `附加说明${ + command.detail ? `「${command.detail}」` : '' + },继续运行 ${projectPath}/.agent/run.latest.json 的目标`; } if (command.id === 'memory.write') { return `${ @@ -1191,25 +3397,36 @@ export function pendingCommandDetail( return `从画板项目 ${command.canvasProjectId} 同步资源到 ${projectPath}/assets/canvas-sync/`; } if (command.id === 'canvas.asset_import') { - return `导入 ${projectPath}/${command.localPath}`; + return `导入 ${projectPath}/${command.localPath} · 画板 ${formatCanvasAssetSource( + command, + )} · ${command.kind} · ${command.mediaType}`; + } + if (command.id === 'canvas.asset_generate') { + return `通过平台 External Editor API 生成美术素材并写入 ${projectPath}/assets/canvas-generated/`; } if (command.id === 'canvas.export_import') { - return `导入 ${command.exportPath} 到 ${projectPath}/assets/canvas-imports/`; + return `导入 ${command.exportPath} 到 ${projectPath}/assets/canvas-imports/ · 画板 ${command.canvasProjectId}`; } return `写入 ${projectPath}`; } export function App() { const [devMode] = useState(isDeveloperMode); - const [projectPath, setProjectPath] = useState(defaultProjectPath); + const [initialProjectPath] = useState(readInitialProjectPath); + const [projectPath, setProjectPath] = useState( + initialProjectPath || defaultProjectPath, + ); const [localProject, setLocalProject] = useState(null); + const localProjectPathRef = useRef(null); + localProjectPathRef.current = localProject?.projectPath ?? null; const [manifest, setManifest] = useState(seedManifest); const [projectStatus, setProjectStatus] = useState('未初始化'); const [preview, setPreview] = useState(null); const [previewStatus, setPreviewStatus] = useState('未启动'); const [chatInput, setChatInput] = useState(''); + const chatInputRef = useRef(null); const [assetStatus, setAssetStatus] = useState('未上传'); const [uploadedAssets, setUploadedAssets] = useState< UploadLocalAssetResult[] @@ -1223,38 +3440,108 @@ export function App() { const [assetCanvasProjectId, setAssetCanvasProjectId] = useState(''); const [assetResourceId, setAssetResourceId] = useState(''); const [assetObjectId, setAssetObjectId] = useState(''); + const [assetGenerationPrompt, setAssetGenerationPrompt] = + useState('首版核心美术素材'); + const [canvasExportPath, setCanvasExportPath] = useState( + '/tmp/canvas-export.zip', + ); const [editorBaseUrl, setEditorBaseUrl] = useState('http://127.0.0.1:3000'); const [memoryScope, setMemoryScope] = useState('long'); const [memoryDraft, setMemoryDraft] = useState(''); const [memoryStatus, setMemoryStatus] = useState('未读取'); const [limitedCommandStatus, setLimitedCommandStatus] = useState('未运行'); + const [limitedLocalCommands, setLimitedLocalCommands] = useState< + GameCreationAppLimitedRunCommandDescriptor[] + >([...GAME_CREATION_APP_LIMITED_RUN_COMMANDS]); const [projectFiles, setProjectFiles] = useState([]); + const [projectCheckpoints, setProjectCheckpoints] = useState< + LocalProjectCheckpointSummary[] + >([]); const [filePath, setFilePath] = useState('game/index.html'); const [fileDraft, setFileDraft] = useState(''); const [fileStatus, setFileStatus] = useState('未读取'); const [agentRunTrace, setAgentRunTrace] = useState(null); - const [agentRunHistory, setAgentRunHistory] = useState< + const [agentRunHistory, setAgentRunHistory] = useState( + [], + ); + const [agentRunHistoryFiles, setAgentRunHistoryFiles] = useState< LocalProjectFileEntry[] >([]); + const [agentRunHistoryVisibleCount, setAgentRunHistoryVisibleCount] = + useState(AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT); + const [agentRunHistoryOverflowCount, setAgentRunHistoryOverflowCount] = + useState(0); + const [agentRunHistoryLoadingMore, setAgentRunHistoryLoadingMore] = + useState(false); const [agentRunStatus, setAgentRunStatus] = useState('未运行'); const [runtimeConfigOpen, setRuntimeConfigOpen] = useState(false); - const [runtimeConfigPath, setRuntimeConfigPath] = useState(''); - const [runtimeConfigStatus, setRuntimeConfigStatus] = useState('未读取'); - const [runtimeConfigDraft, setRuntimeConfigDraft] = - useState(defaultRuntimeConfigDraft); - const [messages, setMessages] = useState([ - { - role: 'assistant', - text: '想做什么游戏?', - }, - ]); + const [workspaceStatus, setWorkspaceStatus] = useState('请选择工作区'); + const [selectedAgent, setSelectedAgent] = useState( + null, + ); + const [agentConversationInput, setAgentConversationInput] = useState(''); + const [agentConversationStatus, setAgentConversationStatus] = + useState('未选择 agent'); + const [agentConversationMessages, setAgentConversationMessages] = useState< + LocalConversationMessageRecord[] + >([]); + const [ + agentConversationVisibleCount, + setAgentConversationVisibleCount, + ] = useState(CONVERSATION_INITIAL_VISIBLE_COUNT); + const [agentConversationSaving, setAgentConversationSaving] = useState(false); + const [agentMemoryStatus, setAgentMemoryStatus] = useState('未读取'); + const [agentMemoryContent, setAgentMemoryContent] = useState(''); + const [messages, setMessages] = useState( + createDefaultChatMessages, + ); + const [conversationVisibleCount, setConversationVisibleCount] = useState( + CONVERSATION_INITIAL_VISIBLE_COUNT, + ); const [pendingCommand, setPendingCommand] = useState( null, ); + const [pendingUiConfirmation, setPendingUiConfirmation] = + useState(null); + const [pendingNonEmptyProjectCreate, setPendingNonEmptyProjectCreate] = + useState<{ + projectPath: string; + announceToChat: boolean; + } | null>(null); const [commandLog, setCommandLog] = useState([ `${GAME_CREATION_APP_COMMANDS.length} 个命令已登记权限。`, ]); + const [conversationWriteVersion, setConversationWriteVersion] = useState(0); + const savedConversationCountRef = useRef(0); + const savedConversationProjectPathRef = useRef(null); + const projectConversationWriteConfirmedRef = useRef(null); + const projectConversationWriteCancelledRef = useRef<{ + projectPath: string; + messageCount: number; + } | null>(null); + const latestMessagesRef = useRef([]); + const conversationWriteInFlightRef = useRef(false); + const agentConversationSavingRef = useRef(false); + const agentConversationLoadVersionRef = useRef(0); + const agentRunHistoryLoadingMoreRef = useRef(false); + const initialProjectOpenedRef = useRef(false); + const pendingUiConfirmationActionRef = useRef<(() => void) | null>(null); + + useEscapeToClose(closeAgentConversation, selectedAgent !== null); + useEscapeToClose(cancelUiCommandConfirmation, pendingUiConfirmation !== null); + useEscapeToClose( + cancelProjectCreateInNonEmptyFolder, + pendingNonEmptyProjectCreate !== null, + ); + + useEffect(() => { + if (!initialProjectPath || initialProjectOpenedRef.current) { + return; + } + initialProjectOpenedRef.current = true; + void openWorkspace(initialProjectPath, false); + }, [initialProjectPath]); useEffect(() => { const listen = window.__TAURI__?.event?.listen; @@ -1264,10 +3551,7 @@ export function App() { let cleanup: (() => void) | null = null; let disposed = false; void listen('game-creator-agent-progress', (event) => { - if ( - localProject && - event.payload.projectPath !== localProject.projectPath - ) { + if (event.payload.projectPath !== localProjectPathRef.current) { return; } setMessages((current) => [ @@ -1285,7 +3569,139 @@ export function App() { disposed = true; cleanup?.(); }; - }, [localProject?.projectPath]); + }, []); + + useEffect(() => { + latestMessagesRef.current = messages; + const invoke = resolveTauriInvoke(); + const nextProjectPath = localProject?.projectPath; + if (!invoke || !nextProjectPath) { + return; + } + if (savedConversationProjectPathRef.current !== nextProjectPath) { + savedConversationProjectPathRef.current = nextProjectPath; + savedConversationCountRef.current = 0; + projectConversationWriteConfirmedRef.current = null; + projectConversationWriteCancelledRef.current = null; + } + const start = savedConversationCountRef.current; + const pendingMessages = messages.slice(start); + if (pendingMessages.length === 0) { + return; + } + if (conversationWriteInFlightRef.current) { + return; + } + if (pendingUiConfirmation) { + return; + } + if ( + projectConversationWriteCancelledRef.current?.projectPath === + nextProjectPath && + projectConversationWriteCancelledRef.current.messageCount === + messages.length + ) { + return; + } + if ( + projectConversationWriteCancelledRef.current?.projectPath === + nextProjectPath && + projectConversationWriteCancelledRef.current.messageCount < messages.length + ) { + projectConversationWriteCancelledRef.current = null; + } + if (projectConversationWriteConfirmedRef.current !== nextProjectPath) { + void invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ) + .then((policyView) => { + if (!policyView.policy.confirmCommands.includes('conversation.write')) { + projectConversationWriteConfirmedRef.current = nextProjectPath; + setConversationWriteVersion((current) => current + 1); + return; + } + requestProjectPolicyConfirmation( + 'conversation.write', + nextProjectPath, + `写入 ${nextProjectPath} 的项目对话`, + () => { + projectConversationWriteConfirmedRef.current = nextProjectPath; + setConversationWriteVersion((current) => current + 1); + }, + ); + setWorkspaceStatus('等待确认保存项目对话'); + }) + .catch(() => { + projectConversationWriteConfirmedRef.current = nextProjectPath; + setConversationWriteVersion((current) => current + 1); + }); + return; + } + conversationWriteInFlightRef.current = true; + void (async () => { + let wroteMessage = false; + for (const [index, message] of pendingMessages.entries()) { + if (isTransientProjectOpenMessage(message, nextProjectPath)) { + savedConversationCountRef.current = start + index + 1; + continue; + } + await invoke( + 'append_local_conversation_message', + { + projectPath: nextProjectPath, + agentId: null, + message: { + role: message.role, + content: message.text, + agentId: null, + }, + }, + ); + wroteMessage = true; + savedConversationCountRef.current = start + index + 1; + } + setWorkspaceStatus((current) => + current.startsWith('项目对话保存失败') || + current === '等待确认保存项目对话' + ? `已打开:${nextProjectPath}` + : current, + ); + if (wroteMessage) { + setCommandLog((current) => [...current, 'conversation.write']); + } + })() + .catch((error) => { + savedConversationCountRef.current = Math.min( + savedConversationCountRef.current, + start, + ); + setWorkspaceStatus( + `项目对话保存失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + setCommandLog((current) => [ + ...current, + `conversation.write.failed ${ + error instanceof Error ? error.message : String(error) + }`, + ]); + }) + .finally(() => { + conversationWriteInFlightRef.current = false; + if ( + savedConversationCountRef.current < latestMessagesRef.current.length + ) { + setConversationWriteVersion((current) => current + 1); + } + }); + }, [ + localProject?.projectPath, + messages, + conversationWriteVersion, + pendingUiConfirmation, + ]); function appendLocalPermissionLog( projectPath: string | null, @@ -1300,11 +3716,25 @@ export function App() { if (!invoke || !projectPath) { return; } - void invoke('append_local_permission_log', { - projectPath, - event, - commandId, - }).catch((error) => { + let result: Promise; + try { + result = Promise.resolve( + invoke('append_local_permission_log', { + projectPath, + event, + commandId, + }), + ); + } catch (error) { + setCommandLog((current) => [ + ...current, + `permission.log.failed ${commandId}: ${ + error instanceof Error ? error.message : String(error) + }`, + ]); + return; + } + void result.catch((error) => { setCommandLog((current) => [ ...current, `permission.log.failed ${commandId}: ${ @@ -1321,6 +3751,18 @@ export function App() { return resolveChatProjectPath(localProject); } + function resolveUiPermissionLogProjectPath( + commandId: GameCreationAppCommandDescriptor['id'], + ) { + if (commandId === 'project.create') { + return null; + } + if (needsInitializedChatProject(commandId)) { + return resolveChatProjectPath(localProject); + } + return projectPath; + } + function queuePendingCommand(command: PendingCommand) { setPendingCommand(command); setCommandLog((current) => [ @@ -1334,101 +3776,446 @@ export function App() { ); } - function confirmCommand(commandId: string) { + function requestCommandConfirmation( + commandId: GameCreationAppCommandDescriptor['id'], + detail: string, + onConfirm: () => void, + ) { const permission = GAME_CREATION_APP_COMMANDS.find( (command) => command.id === commandId, )?.permission as GameCreationAppPermission | undefined; if (permission === 'deny' || !permission) { setCommandLog((current) => [...current, `permission.deny ${commandId}`]); - return false; + return; } if (permission === 'confirm') { - const confirmed = window.confirm(`确认执行 ${commandId}?`); + pendingUiConfirmationActionRef.current = onConfirm; + setPendingUiConfirmation({ commandId, detail }); setCommandLog((current) => [ ...current, - `${confirmed ? 'permission.confirm' : 'permission.cancel'} ${commandId}`, + `permission.pending ${commandId}`, ]); - return confirmed; + appendLocalPermissionLog( + resolveUiPermissionLogProjectPath(commandId), + 'permission.pending', + commandId, + ); + return; } + onConfirm(); + } + + function requestProjectPolicyConfirmation( + commandId: GameCreationAppCommandDescriptor['id'], + projectPath: string, + detail: string, + onConfirm: () => void, + ) { + pendingUiConfirmationActionRef.current = onConfirm; + setPendingUiConfirmation({ commandId, detail }); + setCommandLog((current) => [...current, `permission.pending ${commandId}`]); + appendLocalPermissionLog(projectPath, 'permission.pending', commandId); + } + + function markProjectPolicyDenied( + commandId: GameCreationAppCommandDescriptor['id'], + message: string, + ) { + if (commandId.startsWith('project.') || commandId === 'task.list') { + setProjectStatus(message); + setWorkspaceStatus(message); + } + if ( + commandId.startsWith('file.') || + commandId === 'agent.trace_read' || + commandId === 'project.index' || + commandId === 'project.diff' + ) { + setFileStatus(message); + } + if (commandId.startsWith('asset.') || commandId.startsWith('canvas.')) { + setAssetStatus(message); + } + if (commandId.startsWith('preview.')) { + setPreviewStatus(message); + setWorkspaceStatus(message); + } + if (commandId.startsWith('agent.')) { + setAgentRunStatus(message); + } + if (commandId.startsWith('memory.')) { + setMemoryStatus(message); + } + } + + async function queueProjectPolicyConfirmationIfNeeded( + invoke: TauriInvoke, + commandId: GameCreationAppCommandDescriptor['id'], + projectPath: string, + detail: string, + readyMessage: string, + onConfirm: () => void, + ) { + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath }, + ); + if (policyView.policy.deniedCommands.includes(commandId)) { + const message = `项目权限策略拒绝执行:${commandId}`; + markProjectPolicyDenied(commandId, message); + setCommandLog((current) => [...current, `permission.deny ${commandId}`]); + setMessages((current) => [...current, { role: 'assistant', text: message }]); + return true; + } + if (!policyView.policy.confirmCommands.includes(commandId)) { + return false; + } + requestProjectPolicyConfirmation(commandId, projectPath, detail, onConfirm); + setMessages((current) => [ + ...current, + { role: 'assistant', text: readyMessage }, + ]); return true; } - function updateRuntimeLlmConfig( - key: K, - value: GameCreatorAppConfig['llm'][K], + async function denyPendingCommandIfNeeded( + commandId: GameCreationAppCommandDescriptor['id'], + projectPath: string | null, ) { - setRuntimeConfigDraft((current) => ({ - ...current, - llm: { - ...current.llm, - [key]: value, - }, - })); + const invoke = resolveTauriInvoke(); + if (!invoke || !projectPath) { + return false; + } + try { + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath }, + ); + if (!policyView.policy.deniedCommands.includes(commandId)) { + return false; + } + const message = `项目权限策略拒绝执行:${commandId}`; + markProjectPolicyDenied(commandId, message); + setCommandLog((current) => [...current, `permission.deny ${commandId}`]); + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + return true; + } catch { + return false; + } } - function updateRuntimeEditorConfig< - K extends keyof GameCreatorAppConfig['editorApi'], - >(key: K, value: GameCreatorAppConfig['editorApi'][K]) { - setRuntimeConfigDraft((current) => ({ + async function confirmUiCommand() { + const pending = pendingUiConfirmation; + if (!pending) { + return; + } + if ( + await denyPendingCommandIfNeeded( + pending.commandId, + resolveUiPermissionLogProjectPath(pending.commandId), + ) + ) { + pendingUiConfirmationActionRef.current = null; + setPendingUiConfirmation(null); + return; + } + const action = pendingUiConfirmationActionRef.current; + pendingUiConfirmationActionRef.current = null; + setPendingUiConfirmation(null); + setCommandLog((current) => [ ...current, - editorApi: { - ...current.editorApi, - [key]: value, - }, - })); + `permission.confirm ${pending.commandId}`, + ]); + appendLocalPermissionLog( + resolveUiPermissionLogProjectPath(pending.commandId), + 'permission.confirm', + pending.commandId, + ); + action?.(); + } + + function cancelUiCommandConfirmation() { + const pending = pendingUiConfirmation; + if (!pending) { + return; + } + if (pending.commandId === 'conversation.write') { + const nextProjectPath = resolveChatProjectPath(localProject); + if (nextProjectPath) { + projectConversationWriteCancelledRef.current = { + projectPath: nextProjectPath, + messageCount: latestMessagesRef.current.length, + }; + setWorkspaceStatus((current) => + current === '等待确认保存项目对话' + ? `已打开:${nextProjectPath}` + : current, + ); + } + } + if (pending.commandId === 'conversation.read') { + if (pending.detail.includes('Agent 对话')) { + setAgentConversationStatus((current) => + current === '等待确认' ? '已取消读取 Agent 对话' : current, + ); + setAgentMemoryStatus((current) => + current === '等待确认' ? '已取消读取 Agent 对话' : current, + ); + } else { + setWorkspaceStatus((current) => + current === '等待确认' ? '已取消读取项目对话' : current, + ); + } + } + if (pending.commandId === 'memory.read') { + setMemoryStatus((current) => + current === '等待确认' ? '已取消读取项目记忆' : current, + ); + setAgentMemoryStatus((current) => + current === '等待确认' ? '已取消读取 Agent 私有记忆' : current, + ); + if (!pending.detail.includes('Agent 私有记忆')) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '已取消读取项目记忆。' }, + ]); + } + } + if ( + pending.commandId === 'file.list' || + pending.commandId === 'file.read' || + pending.commandId === 'agent.trace_read' + ) { + setFileStatus((current) => + current === '等待确认' ? '已取消读取项目文件' : current, + ); + } + if (pending.commandId === 'agent.trace_read') { + setAgentRunStatus((current) => + current === '等待确认' || current === '等待确认读取 Agent trace' + ? '已取消读取 Agent trace' + : current, + ); + } + if ( + pending.commandId === 'project.status' || + pending.commandId === 'task.list' + ) { + const status = + pending.commandId === 'task.list' + ? '已取消读取任务拆分' + : '已取消读取项目状态'; + setProjectStatus(status); + setWorkspaceStatus(status); + } + if (pending.commandId === 'asset.list') { + setAssetStatus('已取消读取项目资产'); + setWorkspaceStatus('已取消读取项目资产'); + } + if (pending.commandId === 'asset.upload') { + setAssetStatus('已取消上传资产'); + } + if (pending.commandId === 'canvas.project_open') { + setAssetStatus('已取消打开画板'); + } + if (pending.commandId === 'canvas.project_sync') { + setAssetStatus('已取消同步画板项目'); + } + if (pending.commandId === 'canvas.asset_import') { + setAssetStatus('已取消导入画板资产'); + } + if (pending.commandId === 'canvas.asset_generate') { + setAssetStatus('已取消生成美术素材'); + } + if (pending.commandId === 'canvas.export_import') { + setAssetStatus('已取消导入画板导出包'); + } + if ( + pending.commandId === 'preview.start' || + pending.commandId === 'preview.open' || + pending.commandId === 'preview.stop' || + pending.commandId === 'preview.status' + ) { + setPreviewStatus('已取消预览操作'); + setWorkspaceStatus('已取消预览操作'); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '已取消预览操作' }, + ]); + } + if ( + pending.commandId === 'agent.run_status' || + pending.commandId === 'agent.audit' + ) { + setAgentRunStatus( + pending.commandId === 'agent.audit' + ? '已取消 Agent 审计' + : '已取消读取 Agent run 状态', + ); + } + if ( + pending.commandId === 'project.index' || + pending.commandId === 'project.diff' + ) { + const status = + pending.commandId === 'project.index' + ? '已取消刷新项目索引' + : '已取消对比项目 checkpoint'; + setFileStatus(status); + setWorkspaceStatus(status); + } + if (pending.commandId === 'project.create') { + setProjectStatus('已取消'); + setWorkspaceStatus('已取消'); + } + if (pending.commandId === 'file.write') { + setFileStatus('已取消保存项目文件'); + } + if (pending.commandId === 'file.delete') { + setFileStatus('已取消删除项目文件'); + } + if (pending.commandId === 'memory.write') { + setMemoryStatus('已取消写入项目记忆'); + } + if (pending.commandId === 'memory.delete') { + setMemoryStatus('已取消删除项目记忆'); + } + if (pending.commandId === 'asset.register') { + setAssetStatus('已取消登记资产'); + } + if (pending.commandId === 'command.run_limited') { + setLimitedCommandStatus('已取消运行'); + } + pendingUiConfirmationActionRef.current = null; + setPendingUiConfirmation(null); + setCommandLog((current) => [ + ...current, + `permission.cancel ${pending.commandId}`, + ]); + appendLocalPermissionLog( + resolveUiPermissionLogProjectPath(pending.commandId), + 'permission.cancel', + pending.commandId, + ); } function handleRuntimeConfigOpen() { setRuntimeConfigOpen(true); - void readRuntimeConfig(); } - async function readRuntimeConfig() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setRuntimeConfigStatus('需要在 Tauri App 内运行'); + function queueRunLocalShortcut() { + if (!requireChatProjectForUserAction()) { return; } + queuePendingCommand({ id: 'game.run_local' }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备运行当前本地游戏。' }, + ]); + } - setRuntimeConfigStatus('正在读取'); + function queueStaticSmokeShortcut() { + if (!requireChatProjectForUserAction()) { + return; + } + queuePendingCommand({ + id: 'command.run_limited', + commandId: 'game.static_smoke', + }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备运行静态入口自检。' }, + ]); + } + + function queueProjectCheckpointShortcut() { + if (!requireChatProjectForUserAction()) { + return; + } + queuePendingCommand({ id: 'project.checkpoint' }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备保存当前项目快照。' }, + ]); + } + + function queuePreviewStartShortcut() { + if (!requireChatProjectForUserAction()) { + return; + } + queuePendingCommand({ id: 'preview.start' }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备启动本地预览。' }, + ]); + } + + function queuePreviewOpenShortcut() { + if (!requireChatProjectForUserAction()) { + return; + } + queuePendingCommand({ id: 'preview.open' }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备打开当前本地预览。' }, + ]); + } + + async function handleOpenLauncherWindow() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内切换项目。' }, + ]); + return; + } try { - const result = await invoke( - 'read_game_creator_app_config', - ); - setRuntimeConfigPath(result.path); - setRuntimeConfigDraft(result.config); - setRuntimeConfigStatus(`已读取:${result.path}`); - setCommandLog((current) => [...current, 'runtime_config.read']); + await invoke('open_game_creator_launcher_window'); } catch (error) { - setRuntimeConfigStatus( - error instanceof Error ? error.message : String(error), - ); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: error instanceof Error ? error.message : String(error), + }, + ]); } } - async function handleRuntimeConfigSave(event: FormEvent) { - event.preventDefault(); - const invoke = resolveTauriInvoke(); - if (!invoke) { - setRuntimeConfigStatus('需要在 Tauri App 内运行'); + async function handleRevealCurrentProjectDirectory() { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内打开项目目录。' }, + ]); return; } - - setRuntimeConfigStatus('正在保存'); try { - const result = await invoke( - 'write_game_creator_app_config', - { config: runtimeConfigDraft }, - ); - setRuntimeConfigPath(result.path); - setRuntimeConfigDraft(result.config); - setRuntimeConfigStatus(`已保存:${result.path}`); - setCommandLog((current) => [...current, 'runtime_config.save']); + await invoke('open_local_project_directory', { + projectPath: nextProjectPath, + }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '已打开项目目录。' }, + ]); } catch (error) { - setRuntimeConfigStatus( - error instanceof Error ? error.message : String(error), - ); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: error instanceof Error ? error.message : String(error), + }, + ]); } } @@ -1447,11 +4234,517 @@ export function App() { async function handleProjectInit(event: FormEvent) { event.preventDefault(); - if (!confirmCommand('project.create')) { + requestCommandConfirmation( + 'project.create', + `创建 ${projectPath}`, + () => void executeProjectCreate(projectPath, false), + ); + } + + async function loadProjectConversation( + nextProjectPath: string, + skipPolicyConfirm = false, + mode: 'initial' | 'replace' = 'initial', + ) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + if (!skipPolicyConfirm) { + try { + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + if (policyView.policy.confirmCommands.includes('conversation.read')) { + requestProjectPolicyConfirmation( + 'conversation.read', + nextProjectPath, + `读取 ${nextProjectPath} 的项目对话历史`, + () => void loadProjectConversation(nextProjectPath, true, mode), + ); + setWorkspaceStatus('等待确认'); + return; + } + } catch { + return; + } + } + try { + const conversation = await invoke( + 'read_local_conversation', + { + projectPath: nextProjectPath, + agentId: null, + }, + ); + const conversationMessages = conversationRecordsToChatMessages( + conversation.messages, + ); + setMessages((current) => { + const hasOnlyDefaultGreeting = + current.length === 1 && + current[0]?.role === 'assistant' && + current[0]?.text === '想做什么游戏?'; + const hasOnlyOpenStatus = + current.length === 2 && + current[0]?.role === 'assistant' && + current[0]?.text === '想做什么游戏?' && + current[1]?.role === 'assistant' && + current[1]?.text === `已设置本地项目:${nextProjectPath}`; + if ( + mode !== 'replace' && + !hasOnlyDefaultGreeting && + !hasOnlyOpenStatus + ) { + return current; + } + setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); + savedConversationProjectPathRef.current = nextProjectPath; + savedConversationCountRef.current = conversationMessages.length; + setWorkspaceStatus((workspaceStatus) => { + if (mode === 'replace') { + return `已读取项目对话历史:${conversationMessages.length} 条`; + } + return workspaceStatus === '等待确认' + ? `已打开:${nextProjectPath}` + : workspaceStatus; + }); + return conversationMessages; + }); + } catch (error) { + setWorkspaceStatus((workspaceStatus) => + workspaceStatus === '等待确认' + ? `项目对话读取失败:${ + error instanceof Error ? error.message : String(error) + }` + : workspaceStatus, + ); + // Keep the default greeting when history is missing or blocked. + } + } + + async function openWorkspace( + nextProjectPath: string, + announceToChat: boolean, + ) { + const trimmedProjectPath = nextProjectPath.trim(); + if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { + setWorkspaceStatus('请提供工作区绝对路径'); + return; + } + if (projectPathHasControlCharacter(trimmedProjectPath)) { + setWorkspaceStatus('工作区路径不能包含控制字符'); return; } - void executeProjectCreate(projectPath, false); + const invoke = resolveTauriInvoke(); + if (!invoke) { + setWorkspaceStatus('需要在 Tauri App 内运行'); + setProjectStatus('需要在 Tauri App 内运行'); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内运行。' }, + ]); + } + return; + } + + setWorkspaceStatus('正在打开'); + setProjectStatus('正在初始化'); + setSelectedAgent(null); + setAgentConversationInput(''); + setAgentConversationMessages([]); + setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); + setAgentConversationStatus('未选择 agent'); + setAgentConversationSaving(false); + setAgentMemoryStatus('未读取'); + setAgentMemoryContent(''); + agentConversationSavingRef.current = false; + agentConversationLoadVersionRef.current += 1; + try { + const result = await invoke( + 'init_local_game_project', + { + projectPath: trimmedProjectPath, + projectId: seedManifest.projectId, + name: seedManifest.name, + }, + ); + const openedProjectPath = result.projectPath.trim(); + if ( + !openedProjectPath || + !isAbsoluteProjectPath(openedProjectPath) || + projectPathHasControlCharacter(openedProjectPath) + ) { + const message = '本地项目路径无效'; + setWorkspaceStatus(message); + setProjectStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + return; + } + const openedProject = { ...result, projectPath: openedProjectPath }; + const conversationMessages = createDefaultChatMessages(); + + setProjectPath(openedProject.projectPath); + setLocalProject(openedProject); + setManifest(openedProject.manifest); + setProjectFiles([]); + setProjectCheckpoints([]); + setAgentRunHistory([]); + setAgentRunHistoryFiles([]); + setMessages(conversationMessages); + setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); + savedConversationProjectPathRef.current = openedProject.projectPath; + savedConversationCountRef.current = conversationMessages.length; + writeRecentWorkspace(openedProject.projectPath); + setWorkspaceStatus(`已打开:${openedProject.projectPath}`); + setProjectStatus('已初始化'); + appendLocalPermissionLog( + openedProject.projectPath, + 'permission.confirm', + 'project.create', + ); + if (announceToChat) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: `已设置本地项目:${openedProject.projectPath}`, + }, + ]); + } + void loadProjectConversation(openedProject.projectPath); + void refreshAgentRunTrace(openedProject.projectPath); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setWorkspaceStatus(message); + setProjectStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + + async function openAgentConversation( + agent: AgentStatusCard, + skipConversationPolicyConfirm = false, + skipMemoryPolicyConfirm = false, + ) { + const loadVersion = agentConversationLoadVersionRef.current + 1; + agentConversationLoadVersionRef.current = loadVersion; + setSelectedAgent(agent); + setAgentConversationInput(''); + setAgentConversationMessages([]); + setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); + setAgentMemoryContent(''); + const invoke = resolveTauriInvoke(); + const nextProjectPath = resolveChatProjectPath(localProject); + if (!invoke || !nextProjectPath) { + setAgentConversationStatus('请先初始化本地项目'); + setAgentMemoryStatus('请先初始化本地项目'); + return; + } + try { + if ( + !skipConversationPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'conversation.read', + nextProjectPath, + `读取 ${agent.title} Agent 对话`, + '准备读取 Agent 对话。', + () => void openAgentConversation(agent, true, skipMemoryPolicyConfirm), + )) + ) { + setAgentConversationStatus('等待确认'); + setAgentMemoryStatus('等待确认'); + return; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setAgentConversationStatus(message); + setAgentMemoryStatus(message); + return; + } + setAgentConversationStatus('正在读取'); + setAgentMemoryStatus('正在读取'); + try { + const result = await invoke( + 'read_local_conversation', + { + projectPath: nextProjectPath, + agentId: agent.id, + }, + ); + if (agentConversationLoadVersionRef.current !== loadVersion) { + return; + } + setAgentConversationMessages(result.messages); + setAgentConversationStatus(`已读取 ${result.messages.length} 条:${result.path}`); + setCommandLog((current) => [...current, 'conversation.read']); + } catch (error) { + if (agentConversationLoadVersionRef.current !== loadVersion) { + return; + } + setAgentConversationMessages([]); + setAgentConversationStatus( + error instanceof Error ? error.message : String(error), + ); + } + try { + if ( + !skipMemoryPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'memory.read', + nextProjectPath, + `读取 ${agent.title} Agent 私有记忆`, + '准备读取 Agent 私有记忆。', + () => void openAgentConversation(agent, true, true), + )) + ) { + setAgentMemoryStatus('等待确认'); + return; + } + } catch (error) { + if (agentConversationLoadVersionRef.current !== loadVersion) { + return; + } + setAgentMemoryStatus(error instanceof Error ? error.message : String(error)); + return; + } + try { + const result = await invoke( + 'read_local_agent_memory', + { + projectPath: nextProjectPath, + taskId: agent.id, + }, + ); + if (agentConversationLoadVersionRef.current !== loadVersion) { + return; + } + setAgentMemoryContent(result.content); + setAgentMemoryStatus(result.exists ? `已读取:${result.path}` : '私有记忆为空'); + setCommandLog((current) => [...current, 'memory.agent.read']); + } catch (error) { + if (agentConversationLoadVersionRef.current !== loadVersion) { + return; + } + setAgentMemoryContent(''); + setAgentMemoryStatus( + error instanceof Error ? error.message : String(error), + ); + } + } + + function closeAgentConversation() { + agentConversationLoadVersionRef.current += 1; + agentConversationSavingRef.current = false; + setSelectedAgent(null); + setAgentConversationInput(''); + setAgentConversationMessages([]); + setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); + setAgentConversationStatus('未选择 agent'); + setAgentConversationSaving(false); + setAgentMemoryStatus('未读取'); + setAgentMemoryContent(''); + } + + function prepareSuggestedToolCommandDraft( + toolCall: GameCreationAgentToolCallTrace, + ) { + const commandDraft = commandDraftFromSuggestedToolCall(toolCall); + if (!commandDraft) { + return; + } + prepareChatCommandDraft(commandDraft); + closeAgentConversation(); + } + + function prepareChatCommandDraft(commandDraft: string) { + setChatInput(commandDraft); + window.setTimeout(() => chatInputRef.current?.focus(), 0); + } + + function prepareProjectAssetRegisterDraft(localPath: string) { + prepareChatCommandDraft(projectFileActionDrafts(localPath).assetCommand); + } + + function prepareAgentEvidenceReadDraft(localPath: string) { + if (!isSafeProjectRelativePath(localPath)) { + return; + } + prepareChatCommandDraft(projectFileActionDrafts(localPath).readCommand); + closeAgentConversation(); + } + + async function handlePickCanvasExportFileDraft() { + if (!requireChatProjectForUserAction()) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内选择画板导出包。' }, + ]); + return; + } + try { + const selectedPath = await invoke('pick_local_file'); + if (!selectedPath) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '已取消选择画板导出包。' }, + ]); + return; + } + setCanvasExportPath(selectedPath); + prepareChatCommandDraft(`/import-canvas-export ${selectedPath} `); + setAssetStatus(`已选择画板导出包:${selectedPath}`); + setMessages((current) => [ + ...current, + { role: 'assistant', text: `已选择画板导出包:${selectedPath}` }, + ]); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setAssetStatus(message); + setMessages((current) => [...current, { role: 'assistant', text: message }]); + } + } + + async function saveAgentConversationMessage( + agent: AgentStatusCard, + content: string, + skipPolicyConfirm = false, + ) { + if (!agent || !content || agentConversationSavingRef.current) { + return; + } + const invoke = resolveTauriInvoke(); + const nextProjectPath = resolveChatProjectPath(localProject); + if (!invoke) { + setAgentConversationStatus('需要在 Tauri App 内运行'); + return; + } + if (!nextProjectPath) { + setAgentConversationStatus('请先初始化本地项目'); + return; + } + const saveVersion = agentConversationLoadVersionRef.current; + try { + if ( + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'conversation.write', + nextProjectPath, + `写入 ${agent.title} Agent 对话`, + '准备保存 Agent 对话。', + () => void saveAgentConversationMessage(agent, content, true), + )) + ) { + setAgentConversationStatus('等待确认'); + return; + } + } catch (error) { + setAgentConversationStatus( + error instanceof Error ? error.message : String(error), + ); + return; + } + agentConversationSavingRef.current = true; + setAgentConversationSaving(true); + setAgentConversationInput(''); + setAgentConversationStatus('正在保存'); + let savedUserResult: LocalConversationResult | null = null; + try { + savedUserResult = await invoke('append_local_conversation_message', { + projectPath: nextProjectPath, + agentId: agent.id, + message: { + role: 'user', + content, + agentId: null, + }, + }); + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + setAgentConversationMessages(savedUserResult.messages); + const assistantResult = await invoke( + 'append_local_conversation_message', + { + projectPath: nextProjectPath, + agentId: agent.id, + message: { + role: 'assistant', + content: localAgentConversationReceipt(agent), + agentId: null, + }, + }, + ); + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + setAgentConversationMessages(assistantResult.messages); + setAgentConversationStatus( + `已保存 ${assistantResult.messages.length} 条:${assistantResult.path}`, + ); + setCommandLog((current) => [...current, 'conversation.write']); + } catch (error) { + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + if (savedUserResult) { + setAgentConversationMessages(savedUserResult.messages); + setAgentConversationStatus( + `已保存用户消息;Agent 回执失败:${ + error instanceof Error ? error.message : String(error) + }`, + ); + } else { + setAgentConversationInput(content); + setAgentConversationStatus( + error instanceof Error ? error.message : String(error), + ); + } + } finally { + agentConversationSavingRef.current = false; + setAgentConversationSaving(false); + } + } + + function handleAgentConversationSubmit(event: FormEvent) { + event.preventDefault(); + const agent = selectedAgent; + const content = agentConversationInput.trim(); + if (!agent || !content || agentConversationSavingRef.current) { + return; + } + void saveAgentConversationMessage(agent, content); + } + + function showChatHelp() { + setCommandLog((current) => [...current, 'help.show']); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: `可用命令:\n${chatCommandHelp.join('\n')}`, + }, + ]); } async function handleChatSubmit(event: FormEvent) { @@ -1464,23 +4757,17 @@ export function App() { setChatInput(''); setMessages((current) => [...current, { role: 'user', text: prompt }]); if (prompt === '/help') { - setCommandLog((current) => [...current, 'help.show']); - setMessages((current) => [ - ...current, - { - role: 'assistant', - text: `可用命令:\n${chatCommandHelp.join('\n')}`, - }, - ]); + showChatHelp(); + return; + } + + if (prompt === '/config') { + handleRuntimeConfigOpen(); return; } if (prompt === '/capabilities' || prompt === '/能力') { - setCommandLog((current) => [...current, 'agent.capabilities']); - setMessages((current) => [ - ...current, - { role: 'assistant', text: summarizeAgentCapabilities() }, - ]); + void executeAgentCapabilitiesChat(); return; } @@ -1489,6 +4776,15 @@ export function App() { return; } + const missingArgumentMessage = missingChatCommandArgumentMessage(prompt); + if (missingArgumentMessage) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: missingArgumentMessage }, + ]); + return; + } + if (prompt.startsWith('/project ')) { const nextProjectPath = prompt.slice('/project '.length).trim(); if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { @@ -1498,6 +4794,13 @@ export function App() { ]); return; } + if (projectPathHasControlCharacter(nextProjectPath)) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '本地项目路径不能包含控制字符。' }, + ]); + return; + } queuePendingCommand({ id: 'project.create', projectPath: nextProjectPath, @@ -1515,7 +4818,7 @@ export function App() { } if (prompt === '/index') { - void executeProjectIndex(true); + void queueOrExecuteProjectIndex(); return; } @@ -1531,6 +4834,11 @@ export function App() { return; } + if (prompt === '/checkpoints') { + void executeProjectCheckpoints(true); + return; + } + if (prompt.startsWith('/diff ')) { const checkpointId = prompt.slice('/diff '.length).trim(); if (!checkpointId) { @@ -1540,6 +4848,13 @@ export function App() { ]); return; } + if (!isSafeCheckpointId(checkpointId)) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: 'checkpoint id 非法。' }, + ]); + return; + } void executeProjectDiff(checkpointId, true); return; } @@ -1556,10 +4871,17 @@ export function App() { ]); return; } + if (!isSafeCheckpointId(checkpointId)) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: 'checkpoint id 非法。' }, + ]); + return; + } queuePendingCommand({ id: 'project.restore', checkpointId }); setMessages((current) => [ ...current, - { role: 'assistant', text: `准备恢复 checkpoint:${checkpointId}` }, + { role: 'assistant', text: `准备回滚到 checkpoint:${checkpointId}` }, ]); return; } @@ -1571,23 +4893,63 @@ export function App() { if ( prompt.startsWith('/policy-deny ') || - prompt.startsWith('/policy-allow ') + prompt.startsWith('/policy-allow ') || + prompt.startsWith('/policy-confirm ') || + prompt.startsWith('/policy-auto ') ) { if (!requireChatProjectForUserAction()) { return; } - const deny = prompt.startsWith('/policy-deny '); - const commandId = prompt - .slice(deny ? '/policy-deny '.length : '/policy-allow '.length) - .trim(); + const mode = prompt.startsWith('/policy-deny ') + ? 'deny' + : prompt.startsWith('/policy-allow ') + ? 'allow' + : prompt.startsWith('/policy-confirm ') + ? 'confirm' + : 'auto'; + const commandPrefix = + mode === 'deny' + ? '/policy-deny ' + : mode === 'allow' + ? '/policy-allow ' + : mode === 'confirm' + ? '/policy-confirm ' + : '/policy-auto '; + const commandId = prompt.slice(commandPrefix.length).trim(); if (!commandId) { setMessages((current) => [ ...current, - { role: 'assistant', text: '格式:/policy-deny file.write' }, + { + role: 'assistant', + text: + mode === 'deny' || mode === 'allow' + ? '格式:/policy-deny file.write' + : '格式:/policy-confirm project.index', + }, ]); return; } - void queueProjectPolicyMutation(commandId, deny); + if (!isRegisteredGameCreationCommandId(commandId)) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: `未知内置命令:${commandId}` }, + ]); + return; + } + if ( + (mode === 'confirm' || mode === 'auto') && + !isProjectPolicyConfirmableCommandId(commandId) + ) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、file.list、file.read、memory.read、asset.register、asset.list、task.list、agent.run_status、agent.kill、agent.retry、agent.resume、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', + }, + ]); + return; + } + void queueProjectPolicyMutation(commandId, mode); return; } @@ -1596,6 +4958,19 @@ export function App() { return; } + if (prompt === '/open-project' || prompt === '/show-project') { + if (!requireChatProjectForUserAction()) { + return; + } + void handleRevealCurrentProjectDirectory(); + return; + } + + if (prompt === '/switch-project') { + void handleOpenLauncherWindow(); + return; + } + if (prompt === '/files') { void executeProjectFiles(true); return; @@ -1606,6 +4981,37 @@ export function App() { return; } + if (prompt.startsWith('/asset-register ')) { + if (!requireChatProjectForUserAction()) { + return; + } + const [localPath, kind = 'asset', mediaType = 'application/octet-stream'] = + prompt.slice('/asset-register '.length).trim().split(/\s+/); + if (!localPath) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: '格式:/asset-register assets/hero.png [kind] [mediaType]', + }, + ]); + return; + } + if (!isSafeProjectRelativePath(localPath)) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '资产路径必须是项目内相对路径。' }, + ]); + return; + } + queuePendingCommand({ id: 'asset.register', localPath, kind, mediaType }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: `准备登记项目资产:${localPath}` }, + ]); + return; + } + if (prompt.startsWith('/read ')) { const relativePath = prompt.slice('/read '.length).trim(); if (!relativePath) { @@ -1615,6 +5021,13 @@ export function App() { ]); return; } + if (!isSafeProjectRelativePath(relativePath)) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '文件路径必须是项目内相对路径。' }, + ]); + return; + } void executeProjectFileReadChat(relativePath); return; } @@ -1653,7 +5066,7 @@ export function App() { queuePendingCommand({ id: 'agent.retry' }); setMessages((current) => [ ...current, - { role: 'assistant', text: '准备把最近 run 标记为 pending。' }, + { role: 'assistant', text: '准备重新运行最近 run 的目标。' }, ]); return; } @@ -1673,6 +5086,20 @@ export function App() { return; } + if (prompt === '/history') { + const nextProjectPath = requireChatProjectForUserAction(); + if (!nextProjectPath) { + return; + } + void loadProjectConversation(nextProjectPath, false, 'replace'); + return; + } + + if (prompt === '/commands' || prompt === '/limited-commands') { + void executeLimitedCommandList(); + return; + } + if (prompt === '/smoke') { if (!requireChatProjectForUserAction()) { return; @@ -1741,9 +5168,18 @@ export function App() { if (!requireChatProjectForUserAction()) { return; } - void executeMemoryReadChat( - parseMemoryScope(prompt.slice('/memory'.length)), - ); + const scope = parseOptionalMemoryScope(prompt.slice('/memory'.length)); + if (!scope) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: '格式:/memory [short|long|blackboard]', + }, + ]); + return; + } + void executeMemoryReadChat(scope); return; } @@ -1808,7 +5244,19 @@ export function App() { if (!requireChatProjectForUserAction()) { return; } - const scope = parseMemoryScope(prompt.slice('/forget-memory'.length)); + const scope = parseOptionalMemoryScope( + prompt.slice('/forget-memory'.length), + ); + if (!scope) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: '格式:/forget-memory [short|long|blackboard]', + }, + ]); + return; + } queuePendingCommand({ id: 'memory.delete', scope }); setMessages((current) => [ ...current, @@ -1826,6 +5274,13 @@ export function App() { ]); return; } + if (!isSafeCanvasProjectId(canvasProjectId)) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '画板项目 ID 不能包含控制字符。' }, + ]); + return; + } queuePendingCommand({ id: 'canvas.project_open', canvasProjectId }); setMessages((current) => [ ...current, @@ -1848,6 +5303,13 @@ export function App() { ]); return; } + if (!isSafeCanvasProjectId(canvasProjectId)) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '画板项目 ID 不能包含控制字符。' }, + ]); + return; + } queuePendingCommand({ id: 'canvas.project_sync', canvasProjectId }); setMessages((current) => [ ...current, @@ -1856,6 +5318,29 @@ export function App() { return; } + if (prompt.startsWith('/generate-art ')) { + if (!requireChatProjectForUserAction()) { + return; + } + const generationPrompt = prompt.slice('/generate-art '.length).trim(); + if (!generationPrompt) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '请提供美术生成提示词。' }, + ]); + return; + } + queuePendingCommand({ + id: 'canvas.asset_generate', + prompt: generationPrompt, + }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备生成首版美术素材。' }, + ]); + return; + } + if (prompt.startsWith('/import-canvas-asset ')) { if (!requireChatProjectForUserAction()) { return; @@ -1872,6 +5357,23 @@ export function App() { ]); return; } + if (!isSafeCanvasProjectId(canvasProjectId)) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '画板项目 ID 不能包含控制字符。' }, + ]); + return; + } + if (!isSafeProjectRelativePath(localPath)) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: '画板资产路径必须是项目内相对路径。', + }, + ]); + return; + } const canvasAssetObjectId = canvasAssetId.startsWith('object:') ? canvasAssetId.slice('object:'.length).trim() : ''; @@ -1919,6 +5421,26 @@ export function App() { ]); return; } + if (!isSafeCanvasProjectId(canvasProjectId)) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '画板项目 ID 不能包含控制字符。' }, + ]); + return; + } + if ( + !isAbsoluteProjectPath(exportPath) || + projectPathHasControlCharacter(exportPath) + ) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: '画板导出 ZIP 路径必须是绝对路径。', + }, + ]); + return; + } queuePendingCommand({ id: 'canvas.export_import', exportPath, @@ -1931,6 +5453,17 @@ export function App() { return; } + if (prompt.startsWith('/')) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: `未知命令:${prompt}。输入 /help 查看可用命令。`, + }, + ]); + return; + } + if (!requireChatProjectForUserAction()) { return; } @@ -1956,17 +5489,23 @@ export function App() { 'check_game_creator_llm_config', ); setCommandLog((current) => [...current, 'llm.config_check']); + const agentLines = (status.agents ?? []).map(formatLlmAgentStatusLine); + const summary = status.configured + ? `LLM 已配置:${status.model ?? '未命名模型'} @ ${ + status.baseUrl ?? '未设置 base_url' + },${status.apiKind},流式 ${ + status.stream ? '开启' : '关闭' + },API Key ${ + status.apiKeyPresent ? '已读取' : '未读取' + }。` + : `LLM 未就绪:${status.error ?? '配置不完整'}。API Key:${ + status.apiKeyPresent ? '已读取' : '未读取' + }。`; setMessages((current) => [ ...current, { role: 'assistant', - text: status.configured - ? `LLM 已配置:${status.model ?? '未命名模型'} @ ${ - status.baseUrl ?? '未设置 base_url' - },${status.apiKind},API Key 已读取。` - : `LLM 未就绪:${status.error ?? '配置不完整'}。API Key:${ - status.apiKeyPresent ? '已读取' : '未读取' - }。`, + text: [summary, ...agentLines].join('\n'), }, ]); } catch (error) { @@ -1980,6 +5519,30 @@ export function App() { } } + async function executeAgentCapabilitiesChat() { + const invoke = resolveTauriInvoke(); + let capabilities: readonly GameCreationAgentCapabilityDescriptor[] = + GAME_CREATION_AGENT_CAPABILITIES; + if (invoke) { + try { + const nativeCapabilities = + await invoke( + 'get_game_creation_agent_capabilities', + ); + if (nativeCapabilities.length > 0) { + capabilities = nativeCapabilities; + } + } catch { + capabilities = GAME_CREATION_AGENT_CAPABILITIES; + } + } + setCommandLog((current) => [...current, 'agent.capabilities']); + setMessages((current) => [ + ...current, + { role: 'assistant', text: summarizeAgentCapabilities(capabilities) }, + ]); + } + async function executeGameDraft(prompt: string) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -2005,9 +5568,23 @@ export function App() { 'generate_local_game_draft', { projectPath: nextProjectPath, prompt }, ); + const generatedProjectPath = result.projectPath.trim(); + if ( + !generatedProjectPath || + !isAbsoluteProjectPath(generatedProjectPath) || + projectPathHasControlCharacter(generatedProjectPath) + ) { + const message = '生成结果项目路径无效'; + setProjectStatus(message); + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + return; + } setLocalProject({ - projectPath: result.projectPath, - manifestPath: `${result.projectPath}/.agent/manifest.json`, + projectPath: generatedProjectPath, + manifestPath: `${generatedProjectPath}/.agent/manifest.json`, manifest: result.manifest, }); setManifest(result.manifest); @@ -2017,20 +5594,20 @@ export function App() { 'memory.write', 'file.write game/index.html', ]); - const completionSummary = await refreshAgentRunTrace(result.projectPath); + const completionSummary = await refreshAgentRunTrace(generatedProjectPath); try { const previewResult = await invoke( 'start_local_game_preview', - { projectPath: result.projectPath }, + { projectPath: generatedProjectPath }, ); setPreview(previewResult); setPreviewStatus(`运行中:127.0.0.1:${previewResult.port}`); setCommandLog((current) => [...current, 'preview.start']); - void refreshManifest(result.projectPath); + void refreshManifest(generatedProjectPath); const openMessage = await openPreviewInExternalBrowser( invoke, - result.projectPath, + generatedProjectPath, ); setMessages((current) => [ ...current, @@ -2039,10 +5616,11 @@ export function App() { text: [ `已保存并启动本地预览:${previewResult.url}`, openMessage, - completionSummary, + completionSummary?.text, ] .filter(Boolean) .join('\n\n'), + draftCommand: completionSummary?.draftCommand, }, ]); } catch (previewError) { @@ -2056,29 +5634,116 @@ export function App() { ? previewError.message : String(previewError) }`, - completionSummary, + completionSummary?.text, ] .filter(Boolean) .join('\n\n'), + draftCommand: completionSummary?.draftCommand, }, ]); } } catch (error) { void refreshAgentRunTrace(nextProjectPath); + const message = error instanceof Error ? error.message : String(error); + if (isRuntimeConfigMissingError(message)) { + setRuntimeConfigOpen(true); + } setMessages((current) => [ ...current, { role: 'assistant', - text: error instanceof Error ? error.message : String(error), + text: message, }, ]); } } + function markPendingCommandCanceled(command: PendingCommand) { + if (command.id === 'project.create') { + setWorkspaceStatus('已取消'); + setProjectStatus('已取消'); + return; + } + if (command.id === 'game.generate_draft') { + setProjectStatus('已取消生成游戏草案'); + return; + } + if (command.id === 'game.run_local' || command.id === 'command.run_limited') { + setLimitedCommandStatus('已取消运行'); + return; + } + if (command.id === 'project.checkpoint') { + setFileStatus('已取消保存 checkpoint'); + return; + } + if (command.id === 'project.index') { + setFileStatus('已取消索引项目'); + setWorkspaceStatus('已取消索引项目'); + return; + } + if (command.id === 'project.restore') { + setFileStatus('已取消回滚项目'); + return; + } + if (command.id === 'project.policy_write') { + setProjectStatus('已取消修改权限策略'); + return; + } + if (command.id === 'preview.start' || command.id === 'preview.open') { + setPreviewStatus('已取消预览操作'); + setWorkspaceStatus('已取消预览操作'); + return; + } + if ( + command.id === 'agent.kill' || + command.id === 'agent.retry' || + command.id === 'agent.resume' + ) { + setAgentRunStatus('已取消 Agent run 操作'); + return; + } + if (command.id === 'memory.write') { + setMemoryStatus('已取消写入项目记忆'); + return; + } + if (command.id === 'memory.delete') { + setMemoryStatus('已取消删除项目记忆'); + return; + } + if (command.id === 'asset.upload') { + setAssetStatus('已取消上传资产'); + return; + } + if (command.id === 'asset.register') { + setAssetStatus('已取消登记资产'); + return; + } + if (command.id === 'canvas.project_open') { + setAssetStatus('已取消打开画板'); + return; + } + if (command.id === 'canvas.project_sync') { + setAssetStatus('已取消同步画板项目'); + return; + } + if (command.id === 'canvas.asset_import') { + setAssetStatus('已取消导入画板资产'); + return; + } + if (command.id === 'canvas.asset_generate') { + setAssetStatus('已取消生成美术素材'); + return; + } + if (command.id === 'canvas.export_import') { + setAssetStatus('已取消导入画板导出包'); + } + } + function handlePendingCommandCancel() { const command = pendingCommand; setPendingCommand(null); if (command) { + markPendingCommandCanceled(command); appendLocalPermissionLog( resolvePermissionLogProjectPath(command), 'permission.cancel', @@ -2095,7 +5760,7 @@ export function App() { ]); } - function handlePendingCommandConfirm() { + async function handlePendingCommandConfirm() { const command = pendingCommand; if (!command) { return; @@ -2117,6 +5782,15 @@ export function App() { requireChatProjectForUserAction(); return; } + if ( + await denyPendingCommandIfNeeded( + command.id, + resolvePermissionLogProjectPath(command), + ) + ) { + setPendingCommand(null); + return; + } setPendingCommand(null); appendLocalPermissionLog( resolvePermissionLogProjectPath(command), @@ -2129,6 +5803,18 @@ export function App() { ]); if (command.id === 'asset.upload') { void executeAssetUpload(command.file); + } else if (command.id === 'asset.register') { + void executeAssetRegister( + resolveChatProjectPath(localProject) ?? '', + command.localPath, + command.kind, + command.mediaType, + 'generated', + '', + '', + '', + true, + ); } else if (command.id === 'game.run_local') { void executeRunLocal(true); } else if (command.id === 'command.run_limited') { @@ -2137,6 +5823,8 @@ export function App() { void executeProjectCreate(command.projectPath, true); } else if (command.id === 'project.checkpoint') { void executeProjectCheckpoint(true); + } else if (command.id === 'project.index') { + void executeProjectIndex(true); } else if (command.id === 'project.restore') { void executeProjectRestore(command.checkpointId, true); } else if (command.id === 'project.policy_write') { @@ -2169,6 +5857,7 @@ export function App() { void executeCanvasProjectSync(command.canvasProjectId, true); } else if (command.id === 'canvas.asset_import') { void executeCanvasAssetImport( + null, command.localPath, command.kind, command.mediaType, @@ -2177,6 +5866,8 @@ export function App() { command.canvasAssetObjectId ?? '', true, ); + } else if (command.id === 'canvas.asset_generate') { + void executeCanvasAssetGenerate(command.prompt, true); } else if (command.id === 'canvas.export_import') { void executeCanvasExportImport( command.exportPath, @@ -2192,56 +5883,64 @@ export function App() { nextProjectPath: string, announceToChat: boolean, ) { + const trimmedProjectPath = nextProjectPath.trim(); const invoke = resolveTauriInvoke(); - if (!invoke) { - setProjectStatus('需要在 Tauri App 内运行'); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: '需要在 Tauri App 内运行。' }, - ]); + if ( + invoke && + trimmedProjectPath && + isAbsoluteProjectPath(trimmedProjectPath) && + !projectPathHasControlCharacter(trimmedProjectPath) + ) { + try { + const nonEmpty = await invoke( + 'is_local_project_directory_non_empty', + { projectPath: trimmedProjectPath }, + ); + if (nonEmpty) { + setPendingNonEmptyProjectCreate({ + projectPath: trimmedProjectPath, + announceToChat, + }); + setWorkspaceStatus('目标文件夹不是空的'); + setProjectStatus('等待确认'); + return; + } + } catch { + // ponytail: stale test doubles and older shells may miss this helper; init still validates. } - return; } + await openWorkspace(nextProjectPath, announceToChat); + } - setProjectStatus('正在初始化'); - try { - const result = await invoke( - 'init_local_game_project', + function cancelProjectCreateInNonEmptyFolder() { + const pendingCreate = pendingNonEmptyProjectCreate; + setPendingNonEmptyProjectCreate(null); + setWorkspaceStatus('已取消'); + setProjectStatus('已取消'); + if (pendingCreate?.announceToChat) { + setMessages((current) => [ + ...current, { - projectPath: nextProjectPath, - projectId: seedManifest.projectId, - name: seedManifest.name, + role: 'assistant', + text: `已取消在非空文件夹中新建项目:${pendingCreate.projectPath}`, }, - ); - setProjectPath(result.projectPath); - setLocalProject(result); - setManifest(result.manifest); - setProjectStatus('已初始化'); - appendLocalPermissionLog( - result.projectPath, - 'permission.confirm', - 'project.create', - ); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: `已设置本地项目:${result.projectPath}` }, - ]); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - setProjectStatus(message); - if (announceToChat) { - setMessages((current) => [ - ...current, - { role: 'assistant', text: message }, - ]); - } + ]); } } - async function executeProjectStatus(announceToChat: boolean) { + function confirmProjectCreateInNonEmptyFolder() { + const pendingCreate = pendingNonEmptyProjectCreate; + if (!pendingCreate) { + return; + } + setPendingNonEmptyProjectCreate(null); + void openWorkspace(pendingCreate.projectPath, pendingCreate.announceToChat); + } + + async function executeProjectStatus( + announceToChat: boolean, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setProjectStatus('需要在 Tauri App 内运行'); @@ -2253,7 +5952,8 @@ export function App() { } return; } - if (!localProject) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { setProjectStatus('请先初始化本地项目'); if (announceToChat) { setMessages((current) => [ @@ -2265,9 +5965,23 @@ export function App() { } try { + if ( + announceToChat && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'project.status', + nextProjectPath, + `读取 ${nextProjectPath} 的项目状态`, + '准备读取项目状态。', + () => void executeProjectStatus(true, true), + )) + ) { + return; + } const nextManifest = await invoke( 'get_local_game_manifest', - { projectPath: localProject.projectPath }, + { projectPath: nextProjectPath, commandId: 'project.status' }, ); setManifest(nextManifest); setProjectStatus('已读取状态'); @@ -2277,10 +5991,7 @@ export function App() { ...current, { role: 'assistant', - text: summarizeProjectStatus( - nextManifest, - localProject.projectPath, - ), + text: summarizeProjectStatus(nextManifest, nextProjectPath), }, ]); } @@ -2310,8 +6021,9 @@ export function App() { } const nextProjectPath = announceToChat ? requireChatProjectForUserAction() - : projectPath; + : resolveChatProjectPath(localProject); if (!nextProjectPath) { + setLimitedCommandStatus('请先初始化本地项目'); return; } @@ -2340,6 +6052,40 @@ export function App() { } } + async function queueOrExecuteProjectIndex() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + void executeProjectIndex(true); + return; + } + const nextProjectPath = requireChatProjectForUserAction(); + if (!nextProjectPath) { + return; + } + + try { + const result = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + if (result.policy.confirmCommands.includes('project.index')) { + queuePendingCommand({ id: 'project.index' }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备刷新本地项目索引。' }, + ]); + return; + } + void executeProjectIndex(true); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + async function executeProjectCheckpoint(announceToChat: boolean) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -2384,9 +6130,113 @@ export function App() { } } + async function executeProjectCheckpoints( + announceToChat: boolean, + skipListPolicyConfirm = false, + skipReadPolicyConfirm = false, + ) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setFileStatus('需要在 Tauri App 内运行'); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内运行。' }, + ]); + } + return; + } + const nextProjectPath = announceToChat + ? requireChatProjectForUserAction() + : resolveChatProjectPath(localProject); + if (!nextProjectPath) { + return; + } + + try { + if ( + announceToChat && + !skipListPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'file.list', + nextProjectPath, + `列出 ${nextProjectPath} 的 checkpoint`, + '准备列出项目 checkpoint。', + () => void executeProjectCheckpoints(true, true, false), + )) + ) { + return; + } + const result = await invoke( + 'list_local_project_files', + { projectPath: nextProjectPath }, + ); + const manifestFiles = sortCheckpointManifestFiles(result.files); + const visibleFiles = manifestFiles.slice(0, 5); + if ( + announceToChat && + visibleFiles.length > 0 && + !skipReadPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'file.read', + nextProjectPath, + `读取 ${nextProjectPath} 的 checkpoint manifest`, + '准备读取 checkpoint manifest。', + () => void executeProjectCheckpoints(true, true, true), + )) + ) { + return; + } + const checkpoints: LocalProjectCheckpointSummary[] = []; + for (const file of visibleFiles) { + const manifest = await invoke( + 'read_local_project_file', + { + projectPath: nextProjectPath, + relativePath: file.path, + commandId: 'file.read', + }, + ); + checkpoints.push(checkpointSummaryFromManifest(file, manifest.content)); + } + setProjectFiles(result.files); + setProjectCheckpoints(checkpoints); + setFileStatus(`已列出 ${manifestFiles.length} 个 checkpoint`); + setCommandLog((current) => [ + ...current, + 'file.list', + ...(visibleFiles.length > 0 ? ['file.read checkpoint manifests'] : []), + ]); + if (announceToChat) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: summarizeProjectCheckpoints( + checkpoints, + manifestFiles.length - visibleFiles.length, + ), + }, + ]); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setFileStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + async function executeProjectDiff( checkpointId: string, announceToChat: boolean, + skipPolicyConfirm = false, ) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -2407,6 +6257,20 @@ export function App() { } try { + if ( + announceToChat && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'project.diff', + nextProjectPath, + `对比 ${nextProjectPath} 的 checkpoint:${checkpointId}`, + '准备对比项目 checkpoint。', + () => void executeProjectDiff(checkpointId, true, true), + )) + ) { + return; + } const result = await invoke( 'diff_local_project_checkpoint', { projectPath: nextProjectPath, checkpointId }, @@ -2458,7 +6322,9 @@ export function App() { 'restore_local_project_checkpoint', { projectPath: nextProjectPath, checkpointId }, ); - setFileStatus(`已恢复 ${result.restoredCount} 个文件`); + setFileStatus( + `已回滚 ${result.restoredCount} 个文件,删除 ${result.deletedCount} 个新增文件`, + ); setCommandLog((current) => [...current, 'project.restore']); void refreshManifest(nextProjectPath); if (announceToChat) { @@ -2466,7 +6332,7 @@ export function App() { ...current, { role: 'assistant', - text: `已恢复 ${result.restoredCount} 个文件:${result.checkpointId}`, + text: `已回滚 ${result.restoredCount} 个文件到 ${nextProjectPath},删除 ${result.deletedCount} 个新增文件:${result.checkpointId}`, }, ]); } @@ -2526,7 +6392,10 @@ export function App() { } } - async function queueProjectPolicyMutation(commandId: string, deny: boolean) { + async function queueProjectPolicyMutation( + commandId: string, + mode: 'deny' | 'allow' | 'confirm' | 'auto', + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setMessages((current) => [ @@ -2545,18 +6414,70 @@ export function App() { 'read_project_permission_policy', { projectPath: nextProjectPath }, ); - const deniedCommands = deny - ? [...new Set([...result.policy.deniedCommands, commandId])] - : result.policy.deniedCommands.filter((value) => value !== commandId); + const alreadyDenied = result.policy.deniedCommands.includes(commandId); + const alreadyConfirmed = + result.policy.confirmCommands.includes(commandId); + if (mode === 'deny' && alreadyDenied) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: `命令已在拒绝列表中:${commandId}` }, + ]); + return; + } + if (mode === 'allow' && !alreadyDenied) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: `命令不在拒绝列表中:${commandId}` }, + ]); + return; + } + if (mode === 'confirm' && alreadyConfirmed) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: `命令已在确认列表中:${commandId}` }, + ]); + return; + } + if (mode === 'auto' && !alreadyConfirmed) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: `命令不在确认列表中:${commandId}` }, + ]); + return; + } + const deniedCommands = + mode === 'deny' + ? [...new Set([...result.policy.deniedCommands, commandId])] + : mode === 'allow' + ? result.policy.deniedCommands.filter((value) => value !== commandId) + : mode === 'confirm' + ? result.policy.deniedCommands.filter((value) => value !== commandId) + : result.policy.deniedCommands; + const confirmCommands = + mode === 'confirm' + ? [...new Set([...result.policy.confirmCommands, commandId])] + : mode === 'deny' + ? result.policy.confirmCommands.filter((value) => value !== commandId) + : mode === 'auto' + ? result.policy.confirmCommands.filter((value) => value !== commandId) + : result.policy.confirmCommands; queuePendingCommand({ id: 'project.policy_write', - policy: { ...result.policy, deniedCommands }, + policy: { ...result.policy, deniedCommands, confirmCommands }, }); + const action = + mode === 'deny' + ? '拒绝' + : mode === 'allow' + ? '允许' + : mode === 'confirm' + ? '确认' + : '自动执行'; setMessages((current) => [ ...current, { role: 'assistant', - text: `准备${deny ? '拒绝' : '允许'}命令:${commandId}`, + text: `准备${action}命令:${commandId}`, }, ]); } catch (error) { @@ -2577,6 +6498,12 @@ export function App() { const invoke = resolveTauriInvoke(); if (!invoke) { setProjectStatus('需要在 Tauri App 内运行'); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内运行。' }, + ]); + } return; } const nextProjectPath = announceToChat @@ -2611,7 +6538,10 @@ export function App() { } } - async function executeProjectFiles(announceToChat: boolean) { + async function executeProjectFiles( + announceToChat: boolean, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setFileStatus('需要在 Tauri App 内运行'); @@ -2623,7 +6553,8 @@ export function App() { } return; } - if (!localProject) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { setFileStatus('请先初始化本地项目'); if (announceToChat) { setMessages((current) => [ @@ -2635,9 +6566,23 @@ export function App() { } try { + if ( + announceToChat && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'file.list', + nextProjectPath, + `列出 ${nextProjectPath} 的项目文件`, + '准备列出项目文件。', + () => void executeProjectFiles(true, true), + )) + ) { + return; + } const result = await invoke( 'list_local_project_files', - { projectPath: localProject.projectPath }, + { projectPath: nextProjectPath }, ); setProjectFiles(result.files); setFileStatus(`已列出 ${result.files.length} 项`); @@ -2660,7 +6605,10 @@ export function App() { } } - async function executeProjectAssets(announceToChat: boolean) { + async function executeProjectAssets( + announceToChat: boolean, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setAssetStatus('需要在 Tauri App 内运行'); @@ -2672,7 +6620,8 @@ export function App() { } return; } - if (!localProject) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { setAssetStatus('请先初始化本地项目'); if (announceToChat) { setMessages((current) => [ @@ -2684,17 +6633,41 @@ export function App() { } try { + if ( + announceToChat && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'asset.list', + nextProjectPath, + `列出 ${nextProjectPath} 的项目资产`, + '准备列出项目资产。', + () => void executeProjectAssets(true, true), + )) + ) { + return; + } const nextManifest = await invoke( 'get_local_game_manifest', - { projectPath: localProject.projectPath }, + { projectPath: nextProjectPath, commandId: 'asset.list' }, ); setManifest(nextManifest); setAssetStatus(`已列出 ${nextManifest.assets.length} 个资产`); setCommandLog((current) => [...current, 'asset.list']); if (announceToChat) { + const firstReadableAsset = firstReadableProjectAssetPath(nextManifest); setMessages((current) => [ ...current, - { role: 'assistant', text: summarizeProjectAssets(nextManifest) }, + { + role: 'assistant', + text: summarizeProjectAssets(nextManifest), + draftCommand: firstReadableAsset + ? `/read ${firstReadableAsset}` + : undefined, + draftCommandLabel: firstReadableAsset + ? '读取首个资产' + : undefined, + }, ]); } } catch (error) { @@ -2709,7 +6682,10 @@ export function App() { } } - async function executeProjectFileReadChat(relativePath: string) { + async function executeProjectFileReadChat( + relativePath: string, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setFileStatus('需要在 Tauri App 内运行'); @@ -2719,7 +6695,8 @@ export function App() { ]); return; } - if (!localProject) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { setFileStatus('请先初始化本地项目'); setMessages((current) => [ ...current, @@ -2729,9 +6706,25 @@ export function App() { } try { + const commandId = isAgentTraceFilePath(relativePath) + ? 'agent.trace_read' + : 'file.read'; + if ( + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + commandId, + nextProjectPath, + `读取 ${nextProjectPath} 的 ${relativePath}`, + '准备读取项目文件。', + () => void executeProjectFileReadChat(relativePath, true), + )) + ) { + return; + } const result = await invoke( 'read_local_project_file', - { projectPath: localProject.projectPath, relativePath }, + { projectPath: nextProjectPath, relativePath, commandId }, ); setFilePath(result.path); setFileDraft(result.content); @@ -2751,7 +6744,10 @@ export function App() { } } - async function executeProjectTasks(announceToChat: boolean) { + async function executeProjectTasks( + announceToChat: boolean, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setProjectStatus('需要在 Tauri App 内运行'); @@ -2763,7 +6759,8 @@ export function App() { } return; } - if (!localProject) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { setProjectStatus('请先初始化本地项目'); if (announceToChat) { setMessages((current) => [ @@ -2775,9 +6772,23 @@ export function App() { } try { + if ( + announceToChat && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'task.list', + nextProjectPath, + `读取 ${nextProjectPath} 的任务拆分`, + '准备读取任务拆分。', + () => void executeProjectTasks(true, true), + )) + ) { + return; + } const nextManifest = await invoke( 'get_local_game_manifest', - { projectPath: localProject.projectPath }, + { projectPath: nextProjectPath, commandId: 'task.list' }, ); setManifest(nextManifest); setProjectStatus('已读取任务拆分'); @@ -2800,7 +6811,7 @@ export function App() { } } - async function executeAgentTraceChat() { + async function executeAgentTraceChat(skipPolicyConfirm = false) { const invoke = resolveTauriInvoke(); if (!invoke) { setAgentRunStatus('需要在 Tauri App 内运行'); @@ -2810,7 +6821,8 @@ export function App() { ]); return; } - if (!localProject) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { setAgentRunStatus('请先初始化本地项目'); setMessages((current) => [ ...current, @@ -2820,29 +6832,45 @@ export function App() { } try { + if ( + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'agent.trace_read', + nextProjectPath, + `读取 ${nextProjectPath} 的最近 Agent run trace`, + '准备读取 Agent run trace。', + () => void executeAgentTraceChat(true), + )) + ) { + return; + } const result = await invoke( 'read_local_project_file', { - projectPath: localProject.projectPath, + projectPath: nextProjectPath, relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', }, ); const trace = parseAgentRunTrace(result.content); setAgentRunTrace(trace); - setAgentRunStatus( - `${trace.status} · ${trace.passes}/${trace.maxPasses} 轮 · ${trace.stopReason}`, - ); + setAgentRunStatus(formatAgentRunStatus(trace)); setCommandLog((current) => [ ...current, 'agent.trace_read', 'file.read .agent/run.latest.json', ]); + const traceSummary = summarizeAgentRunCompletionForChat(trace); setMessages((current) => [ ...current, - { role: 'assistant', text: summarizeAgentRunTrace(trace) }, + { role: 'assistant', ...traceSummary }, ]); } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const rawMessage = error instanceof Error ? error.message : String(error); + const message = isMissingAgentRunTraceError(rawMessage) + ? '暂无最近 Agent trace。先生成一次游戏草案后再查看。' + : rawMessage; setAgentRunStatus(message); setMessages((current) => [ ...current, @@ -2855,6 +6883,7 @@ export function App() { action: string, detail: string | undefined, announceToChat: boolean, + skipPolicyConfirm = false, ) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -2871,8 +6900,35 @@ export function App() { if (!nextProjectPath) { return; } + const commandId = + action === 'status' + ? 'agent.run_status' + : (`agent.${action}` as GameCreationAppCommandDescriptor['id']); try { + if ( + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + commandId, + nextProjectPath, + action === 'status' + ? `查看 ${nextProjectPath} 的最近 Agent run 状态` + : `执行 ${nextProjectPath} 的 ${commandId}`, + action === 'status' + ? '准备查看 Agent run 状态。' + : '准备执行 Agent run 操作。', + () => + void executeAgentRunControl( + action, + detail, + announceToChat, + true, + ), + )) + ) { + return; + } const result = await invoke('control_agent_run', { projectPath: nextProjectPath, action, @@ -2881,10 +6937,6 @@ export function App() { setAgentRunStatus( `${result.status} · ${result.lifecycleStatus} · ${result.nextStep}`, ); - const commandId = - action === 'status' - ? 'agent.run_status' - : (`agent.${action}` as GameCreationAppCommandDescriptor['id']); setCommandLog((current) => [ ...current, commandId, @@ -2899,7 +6951,25 @@ export function App() { 'agent.run_status', ); } - await refreshAgentRunTrace(nextProjectPath); + let traceReadQueued = false; + if (action === 'status') { + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + if (policyView.policy.confirmCommands.includes('agent.trace_read')) { + requestProjectPolicyConfirmation( + 'agent.trace_read', + nextProjectPath, + `读取 ${nextProjectPath} 的最近 Agent run trace`, + () => void refreshAgentRunTrace(nextProjectPath), + ); + traceReadQueued = true; + } + } + if (!traceReadQueued) { + await refreshAgentRunTrace(nextProjectPath); + } if (announceToChat) { setMessages((current) => [ ...current, @@ -2907,17 +6977,21 @@ export function App() { role: 'assistant', text: [ result.message, + `run:${result.runId}`, `状态:${result.status} / ${result.lifecycleStatus}`, `下一步:${result.nextStep}`, `事件:${result.activityPath}`, `输出:${result.outputPath}`, `上下文包:${result.contextBundlePath}`, ].join('\n'), + draftCommand: '/read .agent/output.jsonl', + draftCommandLabel: '读取 Run 输出', }, ]); } } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const rawMessage = error instanceof Error ? error.message : String(error); + const message = formatAgentRunControlError(action, rawMessage); setAgentRunStatus(message); if (announceToChat) { setMessages((current) => [ @@ -2928,7 +7002,19 @@ export function App() { } } - async function executeAgentAuditChat() { + function queueAgentRunControlFromPanel( + action: 'kill' | 'retry' | 'resume', + ) { + if (!requireChatProjectForUserAction()) { + return; + } + queuePendingCommand({ id: `agent.${action}` }); + setAgentRunStatus('等待确认'); + } + + async function executeAgentAuditChat( + confirmedPolicyCommands: GameCreationAppCommandDescriptor['id'][] = [], + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setAgentRunStatus('需要在 Tauri App 内运行'); @@ -2938,7 +7024,8 @@ export function App() { ]); return; } - if (!localProject) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { setAgentRunStatus('请先初始化本地项目'); setMessages((current) => [ ...current, @@ -2948,21 +7035,67 @@ export function App() { } try { + if ( + !confirmedPolicyCommands.includes('agent.audit') && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'agent.audit', + nextProjectPath, + `审计 ${nextProjectPath} 的 Agent 能力证据`, + '准备审计 Agent 能力证据。', + () => void executeAgentAuditChat(['agent.audit']), + )) + ) { + return; + } + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + const auditReadCommands: Array = [ + 'project.status', + 'file.list', + 'file.read', + 'agent.trace_read', + ]; + const confirmCommandId = auditReadCommands.find( + (commandId) => + policyView.policy.confirmCommands.includes(commandId) && + !confirmedPolicyCommands.includes(commandId), + ); + if (confirmCommandId) { + requestProjectPolicyConfirmation( + confirmCommandId, + nextProjectPath, + `审计 ${nextProjectPath} 需要读取 ${confirmCommandId}`, + () => + void executeAgentAuditChat([ + ...confirmedPolicyCommands, + confirmCommandId, + ]), + ); + setMessages((current) => [ + ...current, + { role: 'assistant', text: `准备确认审计读取:${confirmCommandId}` }, + ]); + return; + } const nextManifest = await invoke( 'get_local_game_manifest', - { projectPath: localProject.projectPath }, + { projectPath: nextProjectPath, commandId: 'agent.audit' }, ); const fileResult = await invoke( 'list_local_project_files', - { projectPath: localProject.projectPath }, + { projectPath: nextProjectPath }, ); let commandLogContent = ''; try { const commandLogResult = await invoke( 'read_local_project_file', { - projectPath: localProject.projectPath, + projectPath: nextProjectPath, relativePath: '.agent/logs/command.log', + commandId: 'file.read', }, ); commandLogContent = commandLogResult.content; @@ -2974,8 +7107,9 @@ export function App() { const traceResult = await invoke( 'read_local_project_file', { - projectPath: localProject.projectPath, + projectPath: nextProjectPath, relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', }, ); trace = parseAgentRunTrace(traceResult.content); @@ -2987,9 +7121,7 @@ export function App() { setProjectFiles(fileResult.files); setAgentRunTrace(trace); setAgentRunStatus( - trace - ? `${trace.status} · ${trace.passes}/${trace.maxPasses} 轮 · ${trace.stopReason}` - : '还没有最近一次 Agent run', + trace ? formatAgentRunStatus(trace) : '还没有最近一次 Agent run', ); setCommandLog((current) => [ ...current, @@ -3005,7 +7137,7 @@ export function App() { role: 'assistant', text: summarizeAgentAudit( nextManifest, - localProject.projectPath, + nextProjectPath, fileResult.files, trace, commandLogContent, @@ -3023,13 +7155,18 @@ export function App() { } async function handlePreviewStart() { - if (!confirmCommand('preview.start')) { - return; - } - void executePreviewStart(false); + const nextProjectPath = resolveChatProjectPath(localProject) ?? projectPath; + requestCommandConfirmation( + 'preview.start', + `启动 ${nextProjectPath}/game/ 并交给外部浏览器`, + () => void executePreviewStart(false), + ); } - async function executePreviewStart(announceToChat: boolean) { + async function executePreviewStart( + announceToChat: boolean, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setPreviewStatus('需要在 Tauri App 内运行'); @@ -3041,7 +7178,8 @@ export function App() { } return; } - if (!localProject) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { setPreviewStatus('请先初始化本地项目'); if (announceToChat) { setMessages((current) => [ @@ -3054,18 +7192,32 @@ export function App() { setPreviewStatus('正在启动'); try { + if ( + announceToChat && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'preview.start', + nextProjectPath, + `启动 ${nextProjectPath}/game/ 并交给外部浏览器`, + '准备启动本地预览。', + () => void executePreviewStart(true, true), + )) + ) { + return; + } const result = await invoke( 'start_local_game_preview', - { projectPath: localProject.projectPath }, + { projectPath: nextProjectPath }, ); setPreview(result); setPreviewStatus(`运行中:127.0.0.1:${result.port}`); - void refreshManifest(localProject.projectPath); + void refreshManifest(nextProjectPath); setCommandLog((current) => [...current, 'preview.start']); if (announceToChat) { const openMessage = await openPreviewInExternalBrowser( invoke, - localProject.projectPath, + nextProjectPath, ); setMessages((current) => [ ...current, @@ -3089,7 +7241,10 @@ export function App() { } } - async function executePreviewOpen(announceToChat: boolean) { + async function executePreviewOpen( + announceToChat: boolean, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setPreviewStatus('需要在 Tauri App 内运行'); @@ -3109,6 +7264,21 @@ export function App() { } try { + if ( + announceToChat && + nextProjectPath && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'preview.open', + nextProjectPath, + `打开 ${nextProjectPath} 的本地预览`, + '准备打开当前本地预览。', + () => void executePreviewOpen(true, true), + )) + ) { + return; + } const result = await invoke( 'open_local_game_preview', nextProjectPath ? { projectPath: nextProjectPath } : undefined, @@ -3126,7 +7296,12 @@ export function App() { if (announceToChat) { setMessages((current) => [ ...current, - { role: 'assistant', text: '已打开当前本地预览。' }, + { + role: 'assistant', + text: result.url + ? `已打开当前本地预览:${result.url}` + : '已打开当前本地预览。', + }, ]); } } catch (error) { @@ -3145,7 +7320,10 @@ export function App() { void executePreviewStatus(false); } - async function executePreviewStatus(announceToChat: boolean) { + async function executePreviewStatus( + announceToChat: boolean, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setPreviewStatus('需要在 Tauri App 内运行'); @@ -3165,6 +7343,21 @@ export function App() { } try { + if ( + announceToChat && + nextProjectPath && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'preview.status', + nextProjectPath, + `查看 ${nextProjectPath} 的预览状态`, + '准备查看预览状态。', + () => void executePreviewStatus(true, true), + )) + ) { + return; + } const result = await invoke( 'get_local_game_preview_status', nextProjectPath ? { projectPath: nextProjectPath } : undefined, @@ -3190,14 +7383,16 @@ export function App() { ); } if (announceToChat) { + const previewRunning = result.status === 'running' && result.url; setMessages((current) => [ ...current, { role: 'assistant', - text: - result.status === 'running' && result.url - ? `预览运行中:${result.url}` - : '预览未启动。', + text: previewRunning ? `预览运行中:${result.url}` : '预览未启动。', + draftCommand: previewRunning ? '/open-preview' : '/preview', + draftCommandLabel: previewRunning + ? '填入打开预览命令' + : '填入启动预览命令', }, ]); } @@ -3217,7 +7412,10 @@ export function App() { void executePreviewStop(false); } - async function executePreviewStop(announceToChat: boolean) { + async function executePreviewStop( + announceToChat: boolean, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setPreviewStatus('需要在 Tauri App 内运行'); @@ -3231,18 +7429,40 @@ export function App() { } const nextProjectPath = announceToChat ? requireChatProjectForUserAction() - : projectPath; + : resolveChatProjectPath(localProject); if (!nextProjectPath) { + setPreviewStatus('请先初始化本地项目'); return; } try { + if ( + announceToChat && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'preview.stop', + nextProjectPath, + `停止 ${nextProjectPath} 的本地预览`, + '准备停止本地预览。', + () => void executePreviewStop(true, true), + )) + ) { + return; + } await invoke('stop_local_game_preview', { projectPath: nextProjectPath, }); setPreview(null); setPreviewStatus('已停止'); setCommandLog((current) => [...current, 'preview.stop']); + if (announceToChat) { + appendLocalPermissionLog( + nextProjectPath, + 'command.auto', + 'preview.stop', + ); + } if (announceToChat) { setMessages((current) => [ ...current, @@ -3261,7 +7481,11 @@ export function App() { } } - async function handleMemoryRead() { + async function handleMemoryRead(skipPolicyConfirm = false) { + const nextProjectPath = validateMemoryPanelProjectPath(); + if (!nextProjectPath) { + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setMemoryStatus('需要在 Tauri App 内运行'); @@ -3270,9 +7494,25 @@ export function App() { setMemoryStatus('正在读取'); try { + if (!skipPolicyConfirm) { + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + if (policyView.policy.confirmCommands.includes('memory.read')) { + requestProjectPolicyConfirmation( + 'memory.read', + nextProjectPath, + `读取 ${nextProjectPath}/${memoryScopePath(memoryScope)}`, + () => void handleMemoryRead(true), + ); + setMemoryStatus('等待确认'); + return; + } + } const result = await invoke( 'read_local_game_memory', - { projectPath, scope: memoryScope }, + { projectPath: nextProjectPath, scope: memoryScope }, ); setMemoryDraft(result.content); setMemoryStatus( @@ -3284,10 +7524,32 @@ export function App() { } } + function validateMemoryPanelProjectPath() { + const nextProjectPath = projectPath.trim(); + if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { + setMemoryStatus('请提供本地项目绝对路径。'); + return null; + } + if (projectPathHasControlCharacter(nextProjectPath)) { + setMemoryStatus('本地项目路径不能包含控制字符。'); + return null; + } + return nextProjectPath; + } + async function handleMemoryWrite() { - if (!confirmCommand('memory.write')) { + const nextProjectPath = validateMemoryPanelProjectPath(); + if (!nextProjectPath) { return; } + requestCommandConfirmation( + 'memory.write', + `保存 ${nextProjectPath}/${memoryScopePath(memoryScope)}`, + () => void executeMemoryWritePanel(nextProjectPath), + ); + } + + async function executeMemoryWritePanel(nextProjectPath: string) { const invoke = resolveTauriInvoke(); if (!invoke) { setMemoryStatus('需要在 Tauri App 内运行'); @@ -3298,7 +7560,11 @@ export function App() { try { const result = await invoke( 'write_local_game_memory', - { projectPath, scope: memoryScope, content: memoryDraft }, + { + projectPath: nextProjectPath, + scope: memoryScope, + content: memoryDraft, + }, ); setMemoryStatus(`已保存:${result.path}`); setCommandLog((current) => [...current, 'memory.write']); @@ -3308,9 +7574,18 @@ export function App() { } async function handleMemoryDelete() { - if (!confirmCommand('memory.delete')) { + const nextProjectPath = validateMemoryPanelProjectPath(); + if (!nextProjectPath) { return; } + requestCommandConfirmation( + 'memory.delete', + `删除 ${nextProjectPath}/${memoryScopePath(memoryScope)}`, + () => void executeMemoryDeletePanel(nextProjectPath), + ); + } + + async function executeMemoryDeletePanel(nextProjectPath: string) { const invoke = resolveTauriInvoke(); if (!invoke) { setMemoryStatus('需要在 Tauri App 内运行'); @@ -3321,7 +7596,7 @@ export function App() { try { const result = await invoke( 'delete_local_game_memory', - { projectPath, scope: memoryScope }, + { projectPath: nextProjectPath, scope: memoryScope }, ); setMemoryDraft(''); setMemoryStatus(`已删除:${result.path}`); @@ -3331,7 +7606,10 @@ export function App() { } } - async function executeMemoryReadChat(scope: MemoryScope) { + async function executeMemoryReadChat( + scope: MemoryScope, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setMemoryStatus('需要在 Tauri App 内运行'); @@ -3347,6 +7625,19 @@ export function App() { } try { + if ( + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'memory.read', + nextProjectPath, + `读取 ${nextProjectPath}/${memoryScopePath(scope)}`, + '准备读取项目记忆。', + () => void executeMemoryReadChat(scope, true), + )) + ) { + return; + } const result = await invoke( 'read_local_game_memory', { projectPath: nextProjectPath, scope }, @@ -3480,11 +7771,67 @@ export function App() { } } - async function handleLimitedCommandRun(commandId: string) { - if (!confirmCommand('command.run_limited')) { + async function handleLimitedCommandRun( + command: GameCreationAppLimitedRunCommandDescriptor, + ) { + const nextProjectPath = resolveChatProjectPath(localProject) ?? projectPath; + requestCommandConfirmation( + 'command.run_limited', + `运行 ${command.title} 于 ${nextProjectPath}`, + () => void executeLimitedCommand(command.id, false), + ); + } + + async function refreshLimitedLocalCommands() { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setLimitedCommandStatus('需要在 Tauri App 内运行'); return; } - void executeLimitedCommand(commandId, false); + setLimitedCommandStatus('正在读取内置命令'); + try { + const commands = await invoke( + 'get_limited_local_commands', + ); + setLimitedLocalCommands( + commands.length > 0 + ? commands + : [...GAME_CREATION_APP_LIMITED_RUN_COMMANDS], + ); + setLimitedCommandStatus(`已读取 ${commands.length} 个内置命令`); + setCommandLog((current) => [...current, 'command.list_limited']); + } catch (error) { + setLimitedCommandStatus( + error instanceof Error ? error.message : String(error), + ); + } + } + + async function executeLimitedCommandList() { + const invoke = resolveTauriInvoke(); + let commands: GameCreationAppLimitedRunCommandDescriptor[] = [ + ...GAME_CREATION_APP_LIMITED_RUN_COMMANDS, + ]; + if (invoke) { + try { + const nativeCommands = + await invoke( + 'get_limited_local_commands', + ); + if (nativeCommands.length > 0) { + commands = nativeCommands; + } + } catch { + commands = [...GAME_CREATION_APP_LIMITED_RUN_COMMANDS]; + } + } + setLimitedLocalCommands(commands); + setLimitedCommandStatus(`已读取 ${commands.length} 个内置命令`); + setCommandLog((current) => [...current, 'command.list_limited']); + setMessages((current) => [ + ...current, + { role: 'assistant', text: summarizeLimitedLocalCommands(commands) }, + ]); } async function executeRunLocal(announceToChat: boolean) { @@ -3499,7 +7846,8 @@ export function App() { } return; } - if (!localProject) { + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { setLimitedCommandStatus('请先初始化本地项目'); if (announceToChat) { setMessages((current) => [ @@ -3515,13 +7863,13 @@ export function App() { const smoke = await invoke( 'run_limited_local_command', { - projectPath: localProject.projectPath, + projectPath: nextProjectPath, commandId: 'game.static_smoke', }, ); const previewResult = await invoke( 'start_local_game_preview', - { projectPath: localProject.projectPath }, + { projectPath: nextProjectPath }, ); setPreview(previewResult); setPreviewStatus(`运行中:127.0.0.1:${previewResult.port}`); @@ -3532,12 +7880,12 @@ export function App() { `command.run_limited ${smoke.commandId}`, 'preview.start', ]); - void refreshManifest(localProject.projectPath); - void refreshAgentRunTrace(localProject.projectPath); + void refreshManifest(nextProjectPath); + void refreshAgentRunTrace(nextProjectPath); if (announceToChat) { const openMessage = await openPreviewInExternalBrowser( invoke, - localProject.projectPath, + nextProjectPath, ); setMessages((current) => [ ...current, @@ -3595,8 +7943,9 @@ export function App() { } const nextProjectPath = announceToChat ? requireChatProjectForUserAction() - : projectPath; + : resolveChatProjectPath(localProject); if (!nextProjectPath) { + setLimitedCommandStatus('请先初始化本地项目'); return; } @@ -3606,6 +7955,7 @@ export function App() { 'run_limited_local_command', { projectPath: nextProjectPath, commandId }, ); + const summary = `${result.output}\n日志:${result.logPath}`; setLimitedCommandStatus(result.output); setCommandLog((current) => [ ...current, @@ -3614,7 +7964,7 @@ export function App() { if (announceToChat) { setMessages((current) => [ ...current, - { role: 'assistant', text: result.output }, + { role: 'assistant', text: summary }, ]); } void refreshManifest(nextProjectPath); @@ -3630,7 +7980,11 @@ export function App() { } } - async function handleFileList() { + async function handleFileList(skipPolicyConfirm = false) { + const nextProjectPath = validateFilePanelProjectPath(); + if (!nextProjectPath) { + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setFileStatus('需要在 Tauri App 内运行'); @@ -3639,9 +7993,25 @@ export function App() { setFileStatus('正在列出'); try { + if (!skipPolicyConfirm) { + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + if (policyView.policy.confirmCommands.includes('file.list')) { + requestProjectPolicyConfirmation( + 'file.list', + nextProjectPath, + `列出 ${nextProjectPath} 的项目文件`, + () => void handleFileList(true), + ); + setFileStatus('等待确认'); + return; + } + } const result = await invoke( 'list_local_project_files', - { projectPath }, + { projectPath: nextProjectPath }, ); setProjectFiles(result.files); setFileStatus(`已列出 ${result.files.length} 项`); @@ -3651,7 +8021,36 @@ export function App() { } } - async function handleFileRead() { + function validateFilePanelProjectPath() { + const nextProjectPath = projectPath.trim(); + if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { + setFileStatus('请提供本地项目绝对路径。'); + return null; + } + if (projectPathHasControlCharacter(nextProjectPath)) { + setFileStatus('本地项目路径不能包含控制字符。'); + return null; + } + return nextProjectPath; + } + + function validateFilePanelPath() { + if (!isSafeProjectRelativePath(filePath)) { + setFileStatus('文件路径必须是项目内相对路径。'); + return null; + } + return filePath.trim(); + } + + async function handleFileRead(skipPolicyConfirm = false) { + const nextProjectPath = validateFilePanelProjectPath(); + if (!nextProjectPath) { + return; + } + const relativePath = validateFilePanelPath(); + if (!relativePath) { + return; + } const invoke = resolveTauriInvoke(); if (!invoke) { setFileStatus('需要在 Tauri App 内运行'); @@ -3660,9 +8059,32 @@ export function App() { setFileStatus('正在读取'); try { + const commandId = isAgentTraceFilePath(relativePath) + ? 'agent.trace_read' + : 'file.read'; + if (!skipPolicyConfirm) { + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + if (policyView.policy.confirmCommands.includes(commandId)) { + requestProjectPolicyConfirmation( + commandId, + nextProjectPath, + `读取 ${nextProjectPath} 的 ${relativePath}`, + () => void handleFileRead(true), + ); + setFileStatus('等待确认'); + return; + } + } const result = await invoke( 'read_local_project_file', - { projectPath, relativePath: filePath }, + { + projectPath: nextProjectPath, + relativePath, + commandId, + }, ); setFilePath(result.path); setFileDraft(result.content); @@ -3674,9 +8096,22 @@ export function App() { } async function handleFileWrite() { - if (!confirmCommand('file.write')) { + const nextProjectPath = validateFilePanelProjectPath(); + if (!nextProjectPath) { return; } + const relativePath = validateFilePanelPath(); + if (!relativePath) { + return; + } + requestCommandConfirmation( + 'file.write', + `保存 ${nextProjectPath}/${relativePath}`, + () => void executeFileWrite(nextProjectPath, relativePath), + ); + } + + async function executeFileWrite(nextProjectPath: string, relativePath: string) { const invoke = resolveTauriInvoke(); if (!invoke) { setFileStatus('需要在 Tauri App 内运行'); @@ -3687,7 +8122,7 @@ export function App() { try { const result = await invoke( 'write_local_project_file', - { projectPath, relativePath: filePath, content: fileDraft }, + { projectPath: nextProjectPath, relativePath, content: fileDraft }, ); setFileStatus(`已保存:${result.path}`); setCommandLog((current) => [...current, 'file.write']); @@ -3697,9 +8132,22 @@ export function App() { } async function handleFileDelete() { - if (!confirmCommand('file.delete')) { + const nextProjectPath = validateFilePanelProjectPath(); + if (!nextProjectPath) { return; } + const relativePath = validateFilePanelPath(); + if (!relativePath) { + return; + } + requestCommandConfirmation( + 'file.delete', + `删除 ${nextProjectPath}/${relativePath}`, + () => void executeFileDelete(nextProjectPath, relativePath), + ); + } + + async function executeFileDelete(nextProjectPath: string, relativePath: string) { const invoke = resolveTauriInvoke(); if (!invoke) { setFileStatus('需要在 Tauri App 内运行'); @@ -3710,7 +8158,7 @@ export function App() { try { const result = await invoke( 'delete_local_project_file', - { projectPath, relativePath: filePath }, + { projectPath: nextProjectPath, relativePath }, ); setFileDraft(''); setFileStatus(result.deleted ? `已删除:${result.path}` : '文件不存在'); @@ -3721,65 +8169,65 @@ export function App() { } async function handleAssetRegister() { - const invoke = resolveTauriInvoke(); - if (!invoke) { - setAssetStatus('需要在 Tauri App 内运行'); + const nextProjectPath = projectPath.trim(); + const localPath = assetLocalPath.trim(); + const canvasProjectId = assetCanvasProjectId.trim(); + const resourceId = assetResourceId.trim(); + const nextAssetObjectId = assetObjectId.trim(); + if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { + setAssetStatus('请提供本地项目绝对路径。'); return; } - - setAssetStatus('正在登记'); - try { - const result = await invoke( - 'register_local_asset', - { - projectPath, - localPath: assetLocalPath, - kind: assetKind, - mediaType: assetMediaType, - sourceKind: assetSourceKind, - canvasProjectId: assetCanvasProjectId, - resourceId: assetResourceId, - assetObjectId, - taskId: '', - prompt: '', - model: '', - }, - ); - setUploadedAssets((current) => { - const filtered = current.filter((asset) => asset.id !== result.id); - return [...filtered, result]; - }); - setAssetStatus(`已登记:${result.localPath}`); - setCommandLog((current) => [...current, 'asset.register']); - void refreshManifest(projectPath); - } catch (error) { - setAssetStatus(error instanceof Error ? error.message : String(error)); - } - } - - async function handleCanvasAssetImport() { - if (!confirmCommand('canvas.asset_import')) { + if (projectPathHasControlCharacter(nextProjectPath)) { + setAssetStatus('本地项目路径不能包含控制字符。'); return; } - void executeCanvasAssetImport( - assetLocalPath, - assetKind, - assetMediaType, - assetCanvasProjectId, - assetResourceId, - assetObjectId, - false, + if (!isSafeProjectRelativePath(localPath)) { + setAssetStatus('资产路径必须是项目内相对路径。'); + return; + } + if (assetSourceKind === 'canvas') { + if (!canvasProjectId) { + setAssetStatus('请提供画板项目 ID。'); + return; + } + if (!isSafeCanvasProjectId(canvasProjectId)) { + setAssetStatus('画板项目 ID 不能包含控制字符。'); + return; + } + if (!resourceId && !nextAssetObjectId) { + setAssetStatus('请提供资源 ID 或资产对象 ID。'); + return; + } + } + requestCommandConfirmation( + 'asset.register', + `登记 ${nextProjectPath}/${localPath}`, + () => + void executeAssetRegister( + nextProjectPath, + localPath, + assetKind, + assetMediaType, + assetSourceKind, + canvasProjectId, + resourceId, + nextAssetObjectId, + ), ); } - async function executeCanvasAssetImport( + async function executeAssetRegister( + nextProjectPath: string, localPath: string, kind: string, mediaType: string, + sourceKind: string, canvasProjectId: string, resourceId: string, - assetObjectId: string, - announceToChat: boolean, + nextAssetObjectId: string, + announceToChat = false, + skipPolicyConfirm = false, ) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -3792,19 +8240,183 @@ export function App() { } return; } - const nextProjectPath = announceToChat + + setAssetStatus('正在登记'); + try { + if ( + announceToChat && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'asset.register', + nextProjectPath, + `登记 ${nextProjectPath}/${localPath}`, + '准备登记项目资产。', + () => + void executeAssetRegister( + nextProjectPath, + localPath, + kind, + mediaType, + sourceKind, + canvasProjectId, + resourceId, + nextAssetObjectId, + true, + true, + ), + )) + ) { + return; + } + const result = await invoke( + 'register_local_asset', + { + projectPath: nextProjectPath, + localPath, + kind, + mediaType, + sourceKind, + canvasProjectId, + resourceId, + assetObjectId: nextAssetObjectId, + taskId: '', + prompt: '', + model: '', + }, + ); + setUploadedAssets((current) => { + const filtered = current.filter((asset) => asset.id !== result.id); + return [...filtered, result]; + }); + setAssetStatus(`已登记:${result.localPath}`); + setCommandLog((current) => [...current, 'asset.register']); + void refreshManifest(nextProjectPath); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: `已登记资产:${result.localPath}` }, + ]); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setAssetStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + + async function handleCanvasAssetImport() { + const nextProjectPath = projectPath.trim(); + const localPath = assetLocalPath.trim(); + const canvasProjectId = assetCanvasProjectId.trim(); + const resourceId = assetResourceId.trim(); + const nextAssetObjectId = assetObjectId.trim(); + if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { + setAssetStatus('请提供本地项目绝对路径。'); + return; + } + if (projectPathHasControlCharacter(nextProjectPath)) { + setAssetStatus('本地项目路径不能包含控制字符。'); + return; + } + if (!isSafeProjectRelativePath(localPath)) { + setAssetStatus('画板资产路径必须是项目内相对路径。'); + return; + } + if (!canvasProjectId) { + setAssetStatus('请提供画板项目 ID。'); + return; + } + if (!isSafeCanvasProjectId(canvasProjectId)) { + setAssetStatus('画板项目 ID 不能包含控制字符。'); + return; + } + if (!resourceId && !nextAssetObjectId) { + setAssetStatus('请提供资源 ID 或资产对象 ID。'); + return; + } + requestCommandConfirmation( + 'canvas.asset_import', + `导入 ${nextProjectPath}/${localPath}`, + () => + void executeCanvasAssetImport( + nextProjectPath, + localPath, + assetKind, + assetMediaType, + canvasProjectId, + resourceId, + nextAssetObjectId, + false, + ), + ); + } + + async function executeCanvasAssetImport( + nextProjectPath: string | null, + localPath: string, + kind: string, + mediaType: string, + canvasProjectId: string, + resourceId: string, + assetObjectId: string, + announceToChat: boolean, + skipPolicyConfirm = false, + ) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAssetStatus('需要在 Tauri App 内运行'); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内运行。' }, + ]); + } + return; + } + const targetProjectPath = announceToChat ? requireChatProjectForUserAction() - : projectPath; - if (!nextProjectPath) { + : nextProjectPath; + if (!targetProjectPath) { return; } - setAssetStatus('正在导入画板资产'); try { + if ( + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'canvas.asset_import', + targetProjectPath, + `导入 ${targetProjectPath}/${localPath} 的画板来源资产`, + '准备导入画板资产。', + () => + void executeCanvasAssetImport( + nextProjectPath, + localPath, + kind, + mediaType, + canvasProjectId, + resourceId, + assetObjectId, + announceToChat, + true, + ), + )) + ) { + setAssetStatus('等待确认导入画板资产'); + return; + } + setAssetStatus('正在导入画板资产'); const result = await invoke( 'import_canvas_asset', { - projectPath: nextProjectPath, + projectPath: targetProjectPath, localPath, kind, mediaType, @@ -3820,13 +8432,21 @@ export function App() { const filtered = current.filter((asset) => asset.id !== result.id); return [...filtered, result]; }); - setAssetStatus(`已导入画板资产:${result.localPath}`); + const canvasSource = formatCanvasAssetSource({ + canvasProjectId, + canvasAssetId: resourceId, + canvasAssetObjectId: assetObjectId, + }); + setAssetStatus(`已导入画板资产 ${canvasSource}:${result.localPath}`); setCommandLog((current) => [...current, 'canvas.asset_import']); - void refreshManifest(nextProjectPath); + void refreshManifest(targetProjectPath); if (announceToChat) { setMessages((current) => [ ...current, - { role: 'assistant', text: `已导入画板资产:${result.localPath}` }, + { + role: 'assistant', + text: `已导入画板资产 ${canvasSource}:${result.localPath}`, + }, ]); } } catch (error) { @@ -3841,9 +8461,138 @@ export function App() { } } + function handleCanvasAssetGenerate() { + const nextProjectPath = projectPath.trim(); + const prompt = assetGenerationPrompt.trim(); + if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { + setAssetStatus('请提供本地项目绝对路径。'); + return; + } + if (projectPathHasControlCharacter(nextProjectPath)) { + setAssetStatus('本地项目路径不能包含控制字符。'); + return; + } + if (!prompt) { + setAssetStatus('请提供美术生成提示词。'); + return; + } + requestCommandConfirmation( + 'canvas.asset_generate', + `生成 ${nextProjectPath} 的首版美术素材`, + () => void executeCanvasAssetGenerate(prompt, false), + ); + } + + async function executeCanvasAssetGenerate( + prompt: string, + announceToChat: boolean, + skipPolicyConfirm = false, + ) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAssetStatus('需要在 Tauri App 内运行'); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内运行。' }, + ]); + } + return; + } + const nextProjectPath = announceToChat + ? requireChatProjectForUserAction() + : projectPath.trim(); + if (!nextProjectPath) { + return; + } + + try { + if ( + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'canvas.asset_generate', + nextProjectPath, + `生成 ${nextProjectPath} 的首版美术素材`, + '准备生成首版美术素材。', + () => void executeCanvasAssetGenerate(prompt, announceToChat, true), + )) + ) { + setAssetStatus('等待确认生成美术素材'); + return; + } + setAssetStatus('正在生成美术素材'); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '正在生成首版美术素材。' }, + ]); + } + const result = await invoke( + 'generate_platform_art_asset', + { + projectPath: nextProjectPath, + prompt, + }, + ); + setUploadedAssets((current) => { + const filtered = current.filter((asset) => asset.id !== result.id); + return [...filtered, result]; + }); + setAssetStatus(`已生成美术素材:${result.localPath}`); + setCommandLog((current) => [...current, 'canvas.asset_generate']); + void refreshManifest(nextProjectPath); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: `已生成美术素材:${result.localPath}` }, + ]); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setAssetStatus(message); + if (isRuntimeConfigMissingError(message)) { + setRuntimeConfigOpen(true); + } + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + + function handleCanvasProjectSync() { + const nextProjectPath = projectPath.trim(); + const canvasProjectId = assetCanvasProjectId.trim(); + if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { + setAssetStatus('请提供本地项目绝对路径。'); + return; + } + if (projectPathHasControlCharacter(nextProjectPath)) { + setAssetStatus('本地项目路径不能包含控制字符。'); + return; + } + if (!canvasProjectId) { + setAssetStatus('请提供画板项目 ID。'); + return; + } + if (!isSafeCanvasProjectId(canvasProjectId)) { + setAssetStatus('画板项目 ID 不能包含控制字符。'); + return; + } + requestCommandConfirmation( + 'canvas.project_sync', + `同步画板项目资源:${canvasProjectId}`, + () => void executeCanvasProjectSync(canvasProjectId, false), + ); + } + async function executeCanvasProjectSync( canvasProjectId: string, announceToChat: boolean, + skipPolicyConfirm = false, ) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -3863,8 +8612,22 @@ export function App() { return; } - setAssetStatus('正在同步画板项目资源'); try { + if ( + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'canvas.project_sync', + nextProjectPath, + `同步 ${nextProjectPath} 的画板项目资源:${canvasProjectId}`, + '准备同步画板项目资源。', + () => void executeCanvasProjectSync(canvasProjectId, announceToChat, true), + )) + ) { + setAssetStatus('等待确认同步画板项目'); + return; + } + setAssetStatus('正在同步画板项目资源'); const result = await invoke( 'sync_canvas_project_assets', { @@ -3889,7 +8652,7 @@ export function App() { ...current, { role: 'assistant', - text: `已同步 ${result.importedCount} 个画板资产:${result.importRoot}`, + text: `已同步 ${result.importedCount} 个画板资产自 ${result.canvasProjectId}:${result.importRoot}`, }, ]); } @@ -3905,10 +8668,45 @@ export function App() { } } + function handleCanvasExportImport() { + const nextProjectPath = projectPath.trim(); + const exportPath = canvasExportPath.trim(); + const canvasProjectId = assetCanvasProjectId.trim(); + if (!nextProjectPath || !isAbsoluteProjectPath(nextProjectPath)) { + setAssetStatus('请提供本地项目绝对路径。'); + return; + } + if (projectPathHasControlCharacter(nextProjectPath)) { + setAssetStatus('本地项目路径不能包含控制字符。'); + return; + } + if (!canvasProjectId) { + setAssetStatus('请提供画板项目 ID。'); + return; + } + if (!isSafeCanvasProjectId(canvasProjectId)) { + setAssetStatus('画板项目 ID 不能包含控制字符。'); + return; + } + if ( + !isAbsoluteProjectPath(exportPath) || + projectPathHasControlCharacter(exportPath) + ) { + setAssetStatus('画板导出 ZIP 路径必须是绝对路径。'); + return; + } + requestCommandConfirmation( + 'canvas.export_import', + `导入画板导出包:${exportPath}`, + () => void executeCanvasExportImport(exportPath, canvasProjectId, false), + ); + } + async function executeCanvasExportImport( exportPath: string, canvasProjectId: string, announceToChat: boolean, + skipPolicyConfirm = false, ) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -3928,8 +8726,28 @@ export function App() { return; } - setAssetStatus('正在导入画板导出包'); try { + if ( + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'canvas.export_import', + nextProjectPath, + `导入 ${nextProjectPath} 的画板导出包:${exportPath}`, + '准备导入画板导出包。', + () => + void executeCanvasExportImport( + exportPath, + canvasProjectId, + announceToChat, + true, + ), + )) + ) { + setAssetStatus('等待确认导入画板导出包'); + return; + } + setAssetStatus('正在导入画板导出包'); const result = await invoke( 'import_canvas_export', { @@ -3955,7 +8773,7 @@ export function App() { ...current, { role: 'assistant', - text: `已导入 ${result.importedCount} 个画板资产:${result.importRoot}`, + text: `已导入 ${result.importedCount} 个画板资产自 ${canvasProjectId}:${result.importRoot}`, }, ]); } @@ -3972,10 +8790,20 @@ export function App() { } async function handleCanvasProjectOpen() { - if (!confirmCommand('canvas.project_open')) { + const canvasProjectId = assetCanvasProjectId.trim(); + if (!canvasProjectId) { + setAssetStatus('请提供画板项目 ID。'); return; } - void executeCanvasProjectOpen(assetCanvasProjectId, false); + if (!isSafeCanvasProjectId(canvasProjectId)) { + setAssetStatus('画板项目 ID 不能包含控制字符。'); + return; + } + requestCommandConfirmation( + 'canvas.project_open', + `打开本机画板项目 ${canvasProjectId}`, + () => void executeCanvasProjectOpen(canvasProjectId, false), + ); } async function executeCanvasProjectOpen( @@ -3995,6 +8823,12 @@ export function App() { } setAssetStatus('正在打开画板'); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: `正在打开画板:${canvasProjectId}` }, + ]); + } try { const result = await invoke( 'open_canvas_project', @@ -4008,7 +8842,12 @@ export function App() { if (announceToChat) { setMessages((current) => [ ...current, - { role: 'assistant', text: `已打开画板:${result.url}` }, + { + role: 'assistant', + text: `已打开画板:${result.url}`, + draftCommand: `/sync-canvas-project ${canvasProjectId}`, + draftCommandLabel: '同步此画板', + }, ]); } } catch (error) { @@ -4097,9 +8936,11 @@ export function App() { } } - async function refreshManifest(nextProjectPath = projectPath) { + async function refreshManifest( + nextProjectPath = resolveChatProjectPath(localProject) ?? '', + ) { const invoke = resolveTauriInvoke(); - if (!invoke) { + if (!invoke || !nextProjectPath) { return; } @@ -4116,37 +8957,125 @@ export function App() { async function loadAgentRunTraceFile( relativePath: string, - nextProjectPath = projectPath, + nextProjectPath = resolveChatProjectPath(localProject) ?? '', ) { const invoke = resolveTauriInvoke(); if (!invoke) { setAgentRunStatus('需要在 Tauri App 内运行'); return null; } + if (!nextProjectPath) { + setAgentRunStatus('请先初始化本地项目'); + return null; + } try { const result = await invoke( 'read_local_project_file', - { projectPath: nextProjectPath, relativePath }, + { + projectPath: nextProjectPath, + relativePath, + commandId: isAgentTraceFilePath(relativePath) + ? 'agent.trace_read' + : 'file.read', + }, ); const trace = parseAgentRunTrace(result.content); setAgentRunTrace(trace); - setAgentRunStatus( - `${trace.status} · ${trace.passes}/${trace.maxPasses} 轮 · ${trace.stopReason}`, - ); + setAgentRunStatus(formatAgentRunStatus(trace)); setCommandLog((current) => [...current, `file.read ${relativePath}`]); return summarizeAgentRunCompletionForChat(trace); } catch (error) { setAgentRunTrace(null); - setAgentRunStatus(error instanceof Error ? error.message : String(error)); + setAgentRunStatus( + relativePath === '.agent/run.latest.json' && + isMissingProjectFileError(error) + ? '还没有最近一次 Agent run' + : error instanceof Error + ? error.message + : String(error), + ); return null; } } - async function refreshAgentRunHistory(nextProjectPath = projectPath) { + async function handleAgentRunTraceFileOpen( + relativePath: string, + skipPolicyConfirm = false, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { + setAgentRunStatus('需要在 Tauri App 内运行'); + return; + } + const nextProjectPath = resolveChatProjectPath(localProject) ?? ''; + if (!nextProjectPath) { + setAgentRunStatus('请先初始化本地项目'); + return; + } + + if (!skipPolicyConfirm) { + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + if (policyView.policy.confirmCommands.includes('agent.trace_read')) { + requestProjectPolicyConfirmation( + 'agent.trace_read', + nextProjectPath, + `读取 ${nextProjectPath} 的 ${relativePath}`, + () => void handleAgentRunTraceFileOpen(relativePath, true), + ); + setAgentRunStatus('等待确认读取 Agent trace'); + return; + } + } + + await loadAgentRunTraceFile(relativePath, nextProjectPath); + } + + async function readAgentRunHistoryItems( + invoke: TauriInvoke, + runFiles: LocalProjectFileEntry[], + nextProjectPath: string, + ) { + const history = await Promise.all( + runFiles.map(async (file) => { + try { + const traceResult = await invoke( + 'read_local_project_file', + { + projectPath: nextProjectPath, + relativePath: file.path, + commandId: 'agent.trace_read', + }, + ); + return { + path: file.path, + size: file.size, + trace: parseAgentRunTrace(traceResult.content), + } satisfies AgentRunHistoryItem; + } catch { + return null; + } + }), + ); + return history + .filter((item): item is AgentRunHistoryItem => item !== null) + .sort((left, right) => right.trace.updatedAt - left.trace.updatedAt); + } + + async function refreshAgentRunHistory( + nextProjectPath = resolveChatProjectPath(localProject) ?? '', + ) { + const invoke = resolveTauriInvoke(); + if (!invoke || !nextProjectPath) { setAgentRunHistory([]); + setAgentRunHistoryFiles([]); + setAgentRunHistoryOverflowCount(0); + setAgentRunHistoryVisibleCount(AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT); + setAgentRunHistoryLoadingMore(false); + agentRunHistoryLoadingMoreRef.current = false; return; } @@ -4155,22 +9084,50 @@ export function App() { 'list_local_project_files', { projectPath: nextProjectPath }, ); - setAgentRunHistory( - result.files - .filter( - (file) => - file.kind === 'file' && - file.path.startsWith('.agent/runs/') && - file.path.endsWith('.json'), - ) - .sort((left, right) => right.path.localeCompare(left.path)), + const runFiles = result.files + .filter( + (file) => + file.kind === 'file' && + file.path.startsWith('.agent/runs/') && + file.path.endsWith('.json'), + ) + .sort( + (left, right) => + (right.modifiedAt ?? 0) - (left.modifiedAt ?? 0) || + right.path.localeCompare(left.path), + ); + const runFilesToLoad = runFiles.slice(0, AGENT_RUN_HISTORY_MAX_COUNT); + const firstPage = await readAgentRunHistoryItems( + invoke, + runFilesToLoad.slice(0, AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT), + nextProjectPath, ); + setAgentRunHistory(firstPage); + setAgentRunHistoryFiles(runFilesToLoad); + setAgentRunHistoryOverflowCount( + Math.max(0, runFiles.length - runFilesToLoad.length), + ); + setAgentRunHistoryVisibleCount(AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT); + setAgentRunHistoryLoadingMore(false); + agentRunHistoryLoadingMoreRef.current = false; } catch { setAgentRunHistory([]); + setAgentRunHistoryFiles([]); + setAgentRunHistoryOverflowCount(0); + setAgentRunHistoryVisibleCount(AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT); + setAgentRunHistoryLoadingMore(false); + agentRunHistoryLoadingMoreRef.current = false; } } - async function refreshAgentRunTrace(nextProjectPath = projectPath) { + async function refreshAgentRunTrace( + nextProjectPath = resolveChatProjectPath(localProject) ?? '', + ) { + if (!nextProjectPath) { + setAgentRunStatus('请先初始化本地项目'); + await refreshAgentRunHistory(nextProjectPath); + return null; + } const summary = await loadAgentRunTraceFile( '.agent/run.latest.json', nextProjectPath, @@ -4179,25 +9136,534 @@ export function App() { return summary; } + async function handleAgentRunTraceRefresh(skipPolicyConfirm = false) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAgentRunStatus('需要在 Tauri App 内运行'); + return; + } + const nextProjectPath = resolveChatProjectPath(localProject); + if (!nextProjectPath) { + setAgentRunStatus('请先初始化本地项目'); + return; + } + try { + if (!skipPolicyConfirm) { + const policyView = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + if (policyView.policy.confirmCommands.includes('agent.trace_read')) { + requestProjectPolicyConfirmation( + 'agent.trace_read', + nextProjectPath, + `读取 ${nextProjectPath} 的最近 Agent run trace`, + () => void handleAgentRunTraceRefresh(true), + ); + setAgentRunStatus('等待确认'); + return; + } + } + await refreshAgentRunTrace(nextProjectPath); + } catch (error) { + setAgentRunStatus(error instanceof Error ? error.message : String(error)); + } + } + + const agentStatusCards = deriveAgentStatusCards( + manifest, + agentRunTrace ?? agentRunHistory[0]?.trace ?? null, + ); + const visibleAgentRunHistory = agentRunHistory.slice( + 0, + agentRunHistoryVisibleCount, + ); + const visibleMainProjectFiles = projectFiles + .filter((file) => file.kind === 'file' && !file.path.startsWith('.agent/')) + .slice(0, 8); + const visibleMainProjectAssets = manifest.assets + .filter( + (asset) => asset.localPath && !asset.localPath.startsWith('.agent/'), + ) + .slice(0, 8); + const visibleMainProjectCheckpoints = projectCheckpoints.slice(0, 5); + const currentProjectTitle = localProject + ? manifest.name.trim() || projectNameFromPath(localProject.projectPath) + : seedManifest.name; + const agentRunHistoryOmittedCount = + Math.max(0, agentRunHistoryFiles.length - agentRunHistoryVisibleCount) + + agentRunHistoryOverflowCount; + const canShowMoreAgentRunHistory = + agentRunHistoryVisibleCount < agentRunHistoryFiles.length; + + async function showMoreAgentRunHistory() { + const invoke = resolveTauriInvoke(); + const nextProjectPath = resolveChatProjectPath(localProject); + if (!invoke || agentRunHistoryLoadingMoreRef.current) { + return; + } + if (!nextProjectPath) { + setAgentRunStatus('请先初始化本地项目'); + return; + } + const nextVisibleCount = Math.min( + agentRunHistoryVisibleCount + AGENT_RUN_HISTORY_VISIBLE_STEP, + agentRunHistoryFiles.length, + ); + const nextFiles = agentRunHistoryFiles.slice( + agentRunHistoryVisibleCount, + nextVisibleCount, + ); + agentRunHistoryLoadingMoreRef.current = true; + setAgentRunHistoryLoadingMore(true); + try { + const nextItems = await readAgentRunHistoryItems( + invoke, + nextFiles, + nextProjectPath, + ); + setAgentRunHistory((current) => + [...current, ...nextItems].sort( + (left, right) => right.trace.updatedAt - left.trace.updatedAt, + ), + ); + setAgentRunHistoryVisibleCount(nextVisibleCount); + } finally { + agentRunHistoryLoadingMoreRef.current = false; + setAgentRunHistoryLoadingMore(false); + } + } + + function handleAgentRunHistoryScroll(event: UIEvent) { + if (!canShowMoreAgentRunHistory) { + return; + } + const target = event.currentTarget; + if (target.scrollHeight - target.scrollTop - target.clientHeight <= 24) { + void showMoreAgentRunHistory(); + } + } + + const visibleMessages = latestVisibleItems( + messages, + conversationVisibleCount, + ); + const hiddenConversationCount = Math.max( + 0, + messages.length - visibleMessages.length, + ); + const visibleAgentConversationMessages = latestVisibleItems( + agentConversationMessages, + agentConversationVisibleCount, + ); + const hiddenAgentConversationCount = Math.max( + 0, + agentConversationMessages.length - visibleAgentConversationMessages.length, + ); + + function showEarlierConversationMessages() { + setConversationVisibleCount((current) => + Math.min(messages.length, current + CONVERSATION_VISIBLE_STEP), + ); + } + + function showEarlierAgentConversationMessages() { + setAgentConversationVisibleCount((current) => + Math.min( + agentConversationMessages.length, + current + CONVERSATION_VISIBLE_STEP, + ), + ); + } + + function handleConversationScroll(event: UIEvent) { + if (hiddenConversationCount === 0) { + return; + } + if (event.currentTarget.scrollTop <= 24) { + showEarlierConversationMessages(); + } + } + + function handleAgentConversationScroll(event: UIEvent) { + if (hiddenAgentConversationCount === 0) { + return; + } + if (event.currentTarget.scrollTop <= 24) { + showEarlierAgentConversationMessages(); + } + } + + useEffect(() => { + if (!selectedAgent) { + return; + } + const refreshedAgent = agentStatusCards.find( + (agent) => agent.id === selectedAgent.id, + ); + if (!refreshedAgent) { + closeAgentConversation(); + return; + } + if (!sameAgentStatusCard(selectedAgent, refreshedAgent)) { + setSelectedAgent(refreshedAgent); + } + }, [agentStatusCards, selectedAgent]); + return (
-
+

AI 游戏创作

- {devMode ? '开发模式' : seedManifest.name} + {devMode ? '开发模式' : currentProjectTitle} + {!devMode && localProject ? ( + {localProject.projectPath} + ) : null} + run: {agentRunStatus} + {workspaceStatus} +
+
+ + + + + + + + + +
-
-
- {messages.map((message, index) => ( -

- {message.text} -

+
+ + + + + + + + + + + + + + + + + + + + + + +
+ {visibleMainProjectFiles.length > 0 ? ( +
+ {visibleMainProjectFiles.map((file) => ( + + + + + + ))} +
+ ) : null} + {visibleMainProjectAssets.length > 0 ? ( +
+ {visibleMainProjectAssets.map((asset) => ( + + ))} +
+ ) : null} + {visibleMainProjectCheckpoints.length > 0 ? ( +
+ {visibleMainProjectCheckpoints.map((checkpoint) => ( +
+ + {checkpoint.checkpointId} + + {`${checkpoint.fileCount} 个文件 · ${checkpoint.totalBytes}B${ + checkpoint.createdAt + ? ` · createdAt ${checkpoint.createdAt}` + : '' + }`} + + + + +
+ ))} +
+ ) : null} +
+ {hiddenConversationCount > 0 ? ( + + ) : null} + {visibleMessages.map((message, index) => ( + +

+ {message.text} +

+ {message.draftCommand ? ( + + ) : null} +
))}
{pendingCommand ? ( @@ -4223,12 +9689,68 @@ export function App() {
) : null} + {pendingUiConfirmation ? ( +
+ + {pendingUiConfirmation.commandId} + {pendingUiConfirmation.detail} + + + +
+ ) : null} + {pendingNonEmptyProjectCreate ? ( +
{ + if (event.target === event.currentTarget) { + cancelProjectCreateInNonEmptyFolder(); + } + }} + > +
+ closeDialogOnEscape( + event, + cancelProjectCreateInNonEmptyFolder, + ) + } + > +

文件夹不是空的

+

{pendingNonEmptyProjectCreate.projectPath}

+
+ + +
+
+
+ ) : null}
+
+
+

Agent 状态

+
+ + + + + +
+
+
+ {agentStatusCards.map((agent) => ( + + ))} +
+
{runtimeConfigOpen ? ( -
-
setRuntimeConfigOpen(false)} + onLog={(entry) => setCommandLog((current) => [...current, entry])} + /> + ) : null} + + {selectedAgent ? ( +
+ closeDialogOnBackdropMouseDown(event, closeAgentConversation) + } + > +
+ closeDialogOnEscape(event, closeAgentConversation) + } >
-

运行时配置

+
+

{selectedAgent.title}

+

+ {`${taskGroupLabels[selectedAgent.group]} / ${ + selectedAgent.role + } · ${taskStatusLabels[selectedAgent.status]}`} +

+ {selectedAgent.pass !== null || selectedAgent.phase ? ( +

+ {`pass ${selectedAgent.pass ?? '-'} · ${ + selectedAgent.phase ?? '-' + }`} +

+ ) : null} + {selectedAgent.lifecycleStatus ? ( +

+ {`run: ${selectedAgent.lifecycleStatus}`} +

+ ) : null} + {selectedAgent.taskGraphState ? ( +

+ {`编排:${ + agentTaskGraphStateLabels[selectedAgent.taskGraphState] + }`} +

+ ) : null} +

{selectedAgent.summary}

+
- + -
- {runtimeConfigPath ? ( -

{runtimeConfigPath}

- ) : null} -
- - - - - - - - - - +
+ {agentConversationMessages.length > 0 ? ( + <> + {hiddenAgentConversationCount > 0 ? ( + + ) : null} + {visibleAgentConversationMessages.map((message, index) => ( +

+ {message.content} +

+ ))} + + ) : ( +

暂无对话

+ )}
-

{runtimeConfigStatus}

- + {!selectedAgent.hasRecentEvidence || + selectedAgent.inputPaths.length > 0 || + selectedAgent.outputPaths.length > 0 || + selectedAgent.toolCalls.length > 0 ? ( +
+ 最近证据 + {!selectedAgent.hasRecentEvidence ? ( + 暂无最近运行证据 + ) : null} + {selectedAgent.inputPaths.length > 0 ? ( + + in: + {selectedAgent.inputPaths.map((path) => ( + + {` ${path}`} + {isSafeProjectRelativePath(path) ? ( + + ) : null} + + ))} + + ) : null} + {selectedAgent.outputPaths.length > 0 ? ( + + out: + {selectedAgent.outputPaths.map((path) => ( + + {` ${path}`} + {isSafeProjectRelativePath(path) ? ( + + ) : null} + + ))} + + ) : null} + {selectedAgent.toolCalls.slice(0, 5).map((toolCall) => { + const commandDraft = commandDraftFromSuggestedToolCall(toolCall); + return ( + + {[ + `tool: ${toolCall.toolId}`, + toolCall.status, + toolCall.summary || '无摘要', + toolCall.inputPaths.length > 0 + ? `in ${toolCall.inputPaths.join(', ')}` + : null, + toolCall.outputPaths.length > 0 + ? `out ${toolCall.outputPaths.join(', ')}` + : null, + ] + .filter(Boolean) + .join(' · ')} + {commandDraft ? ( + + ) : null} + + ); + })} + {selectedAgent.toolCalls.length > 5 ? ( + {`还有 ${selectedAgent.toolCalls.length - 5} 个工具调用`} + ) : null} +
+ ) : null} +
+ 私有记忆 +

{agentMemoryStatus}

+ {agentMemoryContent ?
{agentMemoryContent}
: null} +
+
+ + setAgentConversationInput(event.currentTarget.value) + } + /> + +
+

{agentConversationStatus}

+
) : null} @@ -4442,7 +10062,10 @@ export function App() { >

编排 Trace

-
@@ -4460,18 +10083,46 @@ export function App() {

{agentRunTrace.error}

) : null} {agentRunHistory.length > 0 ? ( -
- {agentRunHistory.slice(0, 6).map((runFile) => ( +
+ {visibleAgentRunHistory.map((runFile) => ( ))} + {agentRunHistoryOmittedCount > 0 ? ( + canShowMoreAgentRunHistory ? ( + + ) : ( + {`还有 ${agentRunHistoryOmittedCount} 个历史 run`} + ) + ) : null}
) : null}
@@ -4480,22 +10131,33 @@ export function App() { {`${artifact.path} · ${artifact.sizeBytes}B · ${artifact.checksum}`} ))} + {agentRunTrace.artifacts.length > 8 ? ( + {`还有 ${agentRunTrace.artifacts.length - 8} 个产物`} + ) : null}
{`active: ${ - agentRunTrace.taskGraph.activeTaskIds.join(', ') || 'none' + formatTraceTaskIds( + agentRunTrace.taskGraph.activeTaskIds, + agentRunTrace.taskGraph.tasks, + ) }`} {`carry-over: ${ - agentRunTrace.taskGraph.carriedTaskIds.join(', ') || - 'none' + formatTraceTaskIds( + agentRunTrace.taskGraph.carriedTaskIds, + agentRunTrace.taskGraph.tasks, + ) }`} {`ready: ${ - agentRunTrace.taskGraph.readyTaskIds.join(', ') || 'none' + formatTraceTaskIds( + agentRunTrace.taskGraph.readyTaskIds, + agentRunTrace.taskGraph.tasks, + ) }`} {agentRunTrace.taskGraph.repairFocus.length > 0 ? ( @@ -4507,7 +10169,10 @@ export function App() { ) : null} {agentRunTrace.taskGraph.repairRoutes.map((route) => ( - {`route: ${route.taskIds.join(', ')} · ${route.reason}`} + {`route: ${formatTraceTaskIds( + route.taskIds, + agentRunTrace.taskGraph.tasks, + )} · ${route.reason}`} ))}
@@ -4518,13 +10183,32 @@ export function App() { > {agentRunTrace.passPlans.map((plan) => ( - {`pass ${plan.pass}: ${plan.mode} · active ${ - plan.activeTaskIds.length - } · carry ${plan.carriedTaskIds.length} · waves ${ - plan.dependencyWaves - .map((wave) => wave.join('+')) - .join(' / ') || 'none' - }`} + {[ + `pass ${plan.pass}: ${plan.mode}`, + `active ${formatTraceTaskIds( + plan.activeTaskIds, + agentRunTrace.taskGraph.tasks, + )}`, + `carry ${formatTraceTaskIds( + plan.carriedTaskIds, + agentRunTrace.taskGraph.tasks, + )}`, + `waves ${formatTraceTaskWaves( + plan.dependencyWaves, + agentRunTrace.taskGraph.tasks, + )}`, + plan.repairFocus.length > 0 + ? `repair ${plan.repairFocus.join(';')}` + : null, + plan.repairRoutes.length > 0 + ? `routes ${formatTraceRepairRoutes( + plan.repairRoutes, + agentRunTrace.taskGraph.tasks, + )}` + : null, + ] + .filter(Boolean) + .join(' · ')} ))}
@@ -4536,8 +10220,12 @@ export function App() { {[ step.phase, - step.taskId, - step.group && step.role + step.taskId + ? formatTraceTaskId( + step.taskId, + agentRunTrace.taskGraph.tasks, + ) + : step.group && step.role ? `${taskGroupLabels[step.group]} / ${step.role}` : null, ] @@ -4557,6 +10245,44 @@ export function App() { ))} ) : null} + {!agentRunTrace && agentRunHistory.length > 0 ? ( +
+ {visibleAgentRunHistory.map((runFile) => ( + + ))} + {agentRunHistoryOmittedCount > 0 ? ( + canShowMoreAgentRunHistory ? ( + + ) : ( + {`还有 ${agentRunHistoryOmittedCount} 个历史 run`} + ) + ) : null} +
+ ) : null}
+ + setCanvasExportPath(event.currentTarget.value) + } + /> + + setAssetGenerationPrompt(event.currentTarget.value) + } + /> + + +
game/ assets/ @@ -4662,10 +10411,10 @@ export function App() {

项目文件

- - +
- {GAME_CREATION_APP_LIMITED_RUN_COMMANDS.map((command) => ( + {limitedLocalCommands.map((command) => ( diff --git a/apps/ai-game-creator-shell/src/main.tsx b/apps/ai-game-creator-shell/src/main.tsx index bea8b1746..f9a3c8efd 100644 --- a/apps/ai-game-creator-shell/src/main.tsx +++ b/apps/ai-game-creator-shell/src/main.tsx @@ -1,11 +1,18 @@ import React from 'react'; import { createRoot } from 'react-dom/client'; -import { App } from './App'; +import { App, WorkspaceLauncher } from './App'; import './styles.css'; +function shouldRenderMainApp() { + const params = new URLSearchParams(window.location.search); + return ( + params.has('main') || params.has('dev') || window.location.hash === '#dev' + ); +} + createRoot(document.getElementById('root') as HTMLElement).render( - + {shouldRenderMainApp() ? : } , ); diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index c5296b4f9..2974d0bd3 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -19,6 +19,302 @@ textarea { font: inherit; } +.launcher-shell { + display: grid; + grid-template-columns: 224px minmax(0, 1fr); + min-height: 100vh; + background: #15171b; + color: #e7edf7; +} + +.launcher-sidebar { + display: flex; + flex-direction: column; + gap: 26px; + padding: 28px 12px 18px; + border-right: 1px solid #2a2f3a; + background: #121418; +} + +.launcher-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 10px; +} + +.launcher-brand small { + display: block; + margin-top: 2px; + color: #8995a8; +} + +.launcher-logo { + display: grid; + width: 34px; + height: 34px; + border-radius: 8px; + background: linear-gradient(135deg, #ffcc32, #f23872 48%, #2677ff); + color: #fff; + font-weight: 800; + place-items: center; +} + +.launcher-sidebar nav { + display: grid; + gap: 6px; +} + +.launcher-sidebar button, +.launcher-toolbar button, +.launcher-project-list button, +.launcher-actions button { + border: 0; + cursor: pointer; +} + +.launcher-sidebar nav button, +.launcher-footer button { + height: 36px; + padding: 0 12px; + border-radius: 6px; + background: transparent; + color: #d9e2f1; + text-align: left; +} + +.launcher-sidebar nav .launcher-nav-active { + background: #2f528f; + color: #fff; +} + +.launcher-config-button { + height: 36px; + margin-top: auto; + padding: 0 12px; + border: 0; + border-radius: 6px; + background: transparent; + color: #d9e2f1; + cursor: pointer; + text-align: left; +} + +.launcher-config-button:hover { + background: #20242c; +} + +.launcher-main { + position: relative; + display: grid; + align-content: start; + gap: 18px; + padding: 30px 28px; +} + +.launcher-toolbar { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto auto; + gap: 8px; + padding-bottom: 18px; + border-bottom: 1px solid #2a2f3a; +} + +.launcher-toolbar input { + min-width: 0; + height: 34px; + padding: 0 12px; + border: 1px solid #323947; + border-radius: 6px; + background: #111317; + color: #e7edf7; +} + +.launcher-toolbar button { + height: 34px; + padding: 0 14px; + border: 1px solid #3b4658; + border-radius: 6px; + background: #20242c; + color: #e7edf7; +} + +.launcher-project-list { + display: grid; + gap: 6px; +} + +.launcher-project-list header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding-bottom: 6px; +} + +.launcher-project-list h2 { + margin: 0; + color: #e7edf7; +} + +.launcher-project-list header button, +.launcher-project-open, +.launcher-project-remove { + border: 0; + cursor: pointer; +} + +.launcher-project-list header button, +.launcher-project-remove { + height: 32px; + padding: 0 10px; + border-radius: 6px; + background: #20242c; + color: #d9e2f1; +} + +.launcher-project-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + gap: 8px; + align-items: center; +} + +.launcher-project-open { + display: grid; + gap: 4px; + padding: 12px 16px; + border-radius: 6px; + background: transparent; + color: #e7edf7; + text-align: left; +} + +.launcher-project-open:disabled { + cursor: default; + opacity: 0.62; +} + +.launcher-project-open:hover, +.launcher-project-remove:hover, +.launcher-project-list header button:hover { + background: #20242c; +} + +.launcher-project-open:disabled:hover { + background: transparent; +} + +.launcher-project-list small, +.launcher-project-list em, +.launcher-status { + color: #8995a8; +} + +.launcher-project-list em { + font-style: normal; +} + +.launcher-empty { + display: grid; + justify-items: center; + gap: 28px; + padding-top: 70px; + text-align: center; +} + +.launcher-empty h1 { + font-size: 28px; +} + +.launcher-actions { + display: flex; + gap: 64px; +} + +.launcher-actions button { + display: grid; + gap: 12px; + justify-items: center; + background: transparent; + color: #d9e2f1; +} + +.launcher-action-icon { + display: grid; + width: 56px; + height: 56px; + margin-bottom: 2px; + border: 1px solid #3b4658; + border-radius: 6px; + background: #242832; + color: #4a8cff; + place-items: center; +} + +.launcher-actions button:hover .launcher-action-icon { + border-color: #4a8cff; +} + +.launcher-status { + position: absolute; + right: 28px; + bottom: 18px; + left: 28px; + overflow-wrap: anywhere; +} + +.launcher-dialog-backdrop { + position: fixed; + inset: 0; + display: grid; + padding: 24px; + background: rgb(0 0 0 / 56%); + place-items: center; +} + +.launcher-dialog { + width: min(420px, 100%); + padding: 20px; + border: 1px solid #3b4658; + border-radius: 8px; + background: #171a20; + box-shadow: 0 18px 52px rgb(0 0 0 / 42%); +} + +.launcher-dialog h2 { + margin: 0 0 10px; + font-size: 18px; +} + +.launcher-dialog p { + margin: 0; + color: #aeb8c8; + overflow-wrap: anywhere; +} + +.launcher-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 18px; +} + +.launcher-dialog-actions button { + height: 34px; + padding: 0 14px; + border: 1px solid #3b4658; + border-radius: 6px; + background: #20242c; + color: #e7edf7; + cursor: pointer; +} + +.launcher-dialog-actions button:last-child { + border-color: #4a8cff; + background: #2f528f; +} + .app-shell { min-height: 100vh; background: #f5f7fb; @@ -64,9 +360,22 @@ textarea { gap: 12px; } -.chat-header div { +.chat-header-title { display: grid; gap: 4px; + min-width: 0; +} + +.chat-header-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.chat-quick-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; } .panel-header { @@ -81,6 +390,7 @@ textarea { .panel-header button, .chat-header button, +.chat-quick-actions button, .local-project-form button, .panel-actions select { height: 32px; @@ -91,6 +401,7 @@ textarea { .panel-header button, .chat-header button, +.chat-quick-actions button, .local-project-form button { color: #fff; background: #1f6feb; @@ -116,11 +427,52 @@ h2 { } .chat-header span, +.chat-header small, .task-pane span, .log-pane p { color: #647084; } +.chat-header small { + overflow-wrap: anywhere; +} + +.run-history { + display: flex; + flex-wrap: wrap; + gap: 8px; + max-height: 190px; + overflow: auto; +} + +.run-history button { + display: grid; + gap: 2px; + min-width: 150px; + max-width: 100%; + padding: 7px 9px; + border: 1px solid #dce3ee; + border-radius: 6px; + background: #f8fafc; + color: #18202f; + text-align: left; +} + +.run-history button[aria-current='true'], +.trace-history button[aria-current='true'] { + border-color: #1f6feb; + background: #eef5ff; +} + +.run-history small { + color: #647084; + overflow-wrap: anywhere; +} + +.run-history-more { + align-self: center; +} + .settings-overlay { position: fixed; inset: 0; @@ -190,6 +542,16 @@ h2 { margin-top: 10px; } +.message-history-more { + width: 100%; + min-height: 32px; + margin-bottom: 10px; + border: 1px solid #cfd7e6; + border-radius: 6px; + color: #647084; + background: #f8fafd; +} + .message { overflow-wrap: anywhere; white-space: pre-wrap; @@ -199,6 +561,15 @@ h2 { color: #1f6feb; } +.message-action { + min-height: 32px; + margin-top: 6px; + border: 1px solid #cfd7e6; + border-radius: 6px; + color: #334155; + background: #f8fafd; +} + .pending-command { display: flex; align-items: center; @@ -264,6 +635,143 @@ h2 { display: none; } +.agent-status-pane { + display: grid; + gap: 10px; + padding-top: 4px; +} + +.agent-status-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 8px; + max-height: 220px; + overflow: auto; +} + +.agent-status-list button { + display: grid; + gap: 4px; + min-width: 0; + padding: 9px; + border: 1px solid #dce3ee; + border-radius: 6px; + background: #fff; + color: #18202f; + text-align: left; +} + +.agent-status-list span, +.agent-status-list small { + color: #647084; + font-size: 12px; +} + +.agent-status-list small { + overflow-wrap: anywhere; +} + +.agent-conversation-panel { + display: grid; + gap: 10px; + width: min(640px, 100%); + max-height: calc(100vh - 36px); + padding: 18px; + overflow: auto; + border: 1px solid #cfd7e6; + background: #fff; +} + +.agent-conversation-list { + min-height: 220px; + max-height: 420px; + padding: 12px; + overflow: auto; + border: 1px solid #dde3ee; +} + +.agent-memory-box { + display: grid; + gap: 6px; + max-height: 180px; + padding: 10px; + overflow: auto; + border: 1px solid #dde3ee; + background: #f8fafd; +} + +.agent-memory-box pre { + margin: 0; + overflow-wrap: anywhere; + white-space: pre-wrap; +} + +.agent-memory-box small button { + height: 24px; + margin-left: 8px; + padding: 0 8px; + border: 1px solid #cfd7e6; + border-radius: 6px; + background: #fff; + color: #1f6feb; + cursor: pointer; +} + +.agent-conversation-panel .composer { + grid-template-columns: minmax(0, 1fr) auto; +} + +.workspace-panel { + display: grid; + gap: 12px; + width: min(680px, 100%); + max-height: calc(100vh - 36px); + padding: 18px; + overflow: auto; + border: 1px solid #cfd7e6; + background: #fff; +} + +.workspace-picker { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; +} + +.workspace-picker input, +.workspace-picker button, +.workspace-list button { + min-width: 0; + height: 36px; + border: 1px solid #cfd7e6; + border-radius: 6px; +} + +.workspace-picker input { + padding: 0 10px; +} + +.workspace-picker button { + padding: 0 14px; + color: #fff; + background: #1f6feb; +} + +.workspace-list { + display: grid; + gap: 8px; +} + +.workspace-list button { + padding: 0 10px; + overflow: hidden; + background: #fff; + color: #18202f; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + .developer-pane { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); @@ -351,7 +859,9 @@ h2 { display: flex; flex-wrap: wrap; gap: 6px; + max-height: 180px; margin-bottom: 10px; + overflow: auto; } .trace-history button { @@ -459,10 +969,38 @@ h2 { } .file-list button { + display: grid; + gap: 2px; padding: 6px 10px; border: 1px solid #cfd7e6; border-radius: 6px; background: #fff; + text-align: left; +} + +.file-list button small { + color: #647084; +} + +.checkpoint-list-item { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border: 1px solid #cfd7e6; + border-radius: 6px; + background: #fff; +} + +.checkpoint-summary { + display: inline-flex; + min-width: 160px; + flex-direction: column; + gap: 2px; +} + +.checkpoint-summary small { + color: #64748b; } .limited-command-list { diff --git a/apps/ai-game-creator-shell/tests/agentTraceSummary.test.ts b/apps/ai-game-creator-shell/tests/agentTraceSummary.test.ts index b70a33af4..6b296af00 100644 --- a/apps/ai-game-creator-shell/tests/agentTraceSummary.test.ts +++ b/apps/ai-game-creator-shell/tests/agentTraceSummary.test.ts @@ -18,6 +18,7 @@ describe('AI 游戏创作 Agent loop 摘要', () => { runId: 'run-test', commandId: 'game.generate_draft', status: 'needs-revision', + lifecycleStatus: 'pending', passes: 2, maxPasses: 3, toolCallCount: 12, @@ -26,6 +27,27 @@ describe('AI 游戏创作 Agent loop 摘要', () => { goal: '做一个弹幕厨房游戏', coordination: 'Planner -> Orchestrator -> Generator -> Evaluator', steps: [ + ...Array.from({ length: 7 }, (_, index) => ({ + pass: 1, + agent: `LLM-${index + 1}`, + phase: 'llm', + taskId: null, + group: null, + role: null, + status: 'completed', + inputPaths: [], + outputPaths: [], + summary: `LLM 调用 ${index + 1}`, + toolCalls: [ + { + toolId: `llm.call.${index + 1}`, + status: 'ok', + inputPaths: [], + outputPaths: [], + summary: `LLM 工具 ${index + 1}`, + }, + ], + })), { pass: 2, agent: 'Orchestrator', @@ -39,6 +61,19 @@ describe('AI 游戏创作 Agent loop 摘要', () => { summary: '重跑程序链路及下游发布包装', toolCalls: [], }, + ...Array.from({ length: 4 }, (_, index) => ({ + pass: 2, + agent: `Bridge-${index + 1}`, + phase: 'handoff', + taskId: null, + group: null, + role: null, + status: 'completed', + inputPaths: [], + outputPaths: [], + summary: `中间步骤 ${index + 1}`, + toolCalls: [], + })), { pass: 2, agent: '美术组 / Asset', @@ -66,15 +101,32 @@ describe('AI 游戏创作 Agent loop 摘要', () => { summary: '项目还没有画板回流资产;建议用户确认 /sync-canvas-project <画板项目ID>。', }, + ...Array.from({ length: 5 }, (_, index) => ({ + toolId: `agent.tool.suggest.extra.${index + 1}`, + status: 'suggested', + inputPaths: ['.agent/manifest.json'], + outputPaths: [], + summary: `额外建议命令 ${index + 1}`, + })), ], }, ], artifacts: [ + { + path: '.agent/older-artifact.json', + sizeBytes: 64, + checksum: 'fnv1a64:older', + }, { path: '.agent/passes/pass-2/task-graph.json', sizeBytes: 128, checksum: 'fnv1a64:test', }, + ...Array.from({ length: 4 }, (_, index) => ({ + path: `.agent/passes/pass-2/artifact-${index + 1}.json`, + sizeBytes: 128 + index, + checksum: `fnv1a64:artifact-${index + 1}`, + })), ], taskGraph: { goal: '做一个弹幕厨房游戏', @@ -93,6 +145,21 @@ describe('AI 游戏创作 Agent loop 摘要', () => { ], reason: 'code-repair+dependency-impact', }, + { + issue: '缺少输入绑定', + taskIds: ['code-director'], + reason: 'input-binding', + }, + { + issue: '缺少胜负条件', + taskIds: ['quality-review'], + reason: 'win-condition', + }, + { + issue: '缺少发布说明', + taskIds: ['publish-package'], + reason: 'publish-readme', + }, ], tasks, }, @@ -130,8 +197,43 @@ describe('AI 游戏创作 Agent loop 摘要', () => { ], reason: 'code-repair+dependency-impact', }, + { + issue: '缺少输入绑定', + taskIds: ['code-director'], + reason: 'input-binding', + }, + { + issue: '缺少胜负条件', + taskIds: ['quality-review'], + reason: 'win-condition', + }, + { + issue: '缺少发布说明', + taskIds: ['publish-package'], + reason: 'publish-readme', + }, ], }, + { + pass: 3, + mode: 'repair', + summary: '第 3 轮复核', + activeTaskIds: ['quality-review'], + carriedTaskIds: ['design-director'], + dependencyWaves: [['quality-review']], + repairFocus: [], + repairRoutes: [], + }, + { + pass: 4, + mode: 'repair', + summary: '第 4 轮收尾', + activeTaskIds: ['publish-package'], + carriedTaskIds: [], + dependencyWaves: [['publish-package']], + repairFocus: [], + repairRoutes: [], + }, ], nextStep: 'repair-next-pass', error: null, @@ -140,7 +242,9 @@ describe('AI 游戏创作 Agent loop 摘要', () => { const summary = summarizeAgentRunTrace(trace); - expect(summary).toContain('needs-revision · 2/3 轮 · max-passes-exhausted'); + expect(summary).toContain( + 'needs-revision / pending · 2/3 轮 · max-passes-exhausted', + ); expect(summary).toContain('工具调用:12/128'); expect(summary).toContain('任务:已完成 2'); expect(summary).toContain( @@ -153,14 +257,28 @@ describe('AI 游戏创作 Agent loop 摘要', () => { expect(summary).toContain( '返工路线:code-repair+dependency-impact: 程序组 / Director 拆解程序实现(code-director), 程序组 / Review 执行质量评审(quality-review), 程序组 / Preview 执行静态自检(preview-readiness), 运营组 / Publish 整理发布包装(publish-package)', ); + expect(summary).toContain('还有 1 条路线'); + expect(summary).not.toContain('publish-readme:'); expect(summary).toContain('建议命令:'); expect(summary).toContain('agent.tool.suggest.canvas.project_sync'); expect(summary).toContain('/sync-canvas-project <画板项目ID>'); + expect(summary).toContain('agent.tool.suggest.extra.4'); + expect(summary).not.toContain('agent.tool.suggest.extra.5'); + expect(summary).toContain('还有 1 个建议命令'); expect(summary).toContain('编排轮次:'); expect(summary).toContain( - 'pass 2 · repair · active 3 · carry 1 · waves 程序组 / Director 拆解程序实现(code-director) / 程序组 / Review 执行质量评审(quality-review) / 运营组 / Publish 整理发布包装(publish-package)', + 'pass 2 · repair · active 程序组 / Director 拆解程序实现(code-director), 程序组 / Review 执行质量评审(quality-review), 运营组 / Publish 整理发布包装(publish-package) · carry 策划组 / Director 拆解创作方向(design-director) · waves 程序组 / Director 拆解程序实现(code-director) / 程序组 / Review 执行质量评审(quality-review) / 运营组 / Publish 整理发布包装(publish-package) · repair gameHtml 缺少 canvas · routes code-repair+dependency-impact: 程序组 / Director 拆解程序实现(code-director), 程序组 / Review 执行质量评审(quality-review), 程序组 / Preview 执行静态自检(preview-readiness), 运营组 / Publish 整理发布包装(publish-package)', ); + expect(summary).not.toContain('pass 1 · initial'); + expect(summary).toContain('还有 1 个较早轮次'); expect(summary).toContain('.agent/passes/pass-2/task-graph.json'); - expect(summary).toContain('Orchestrator #2 · completed · plan'); + expect(summary).not.toContain('.agent/older-artifact.json'); + expect(summary).toContain('还有 1 个较早产物'); + expect(summary).not.toContain('Orchestrator #2 · completed · plan'); + expect(summary).toContain('Bridge-1 #2 · completed · handoff'); + expect(summary).toContain('还有 8 个较早步骤'); + expect(summary).toContain('LLM-2 #1 · completed · llm · llm.call.2'); + expect(summary).not.toContain('LLM-1 #1 · completed · llm · llm.call.1'); + expect(summary).toContain('还有 1 个较早 LLM 步骤'); }); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index f9792f00f..f81c86aa6 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -5,6 +5,8 @@ import { fireEvent, render, screen, + waitFor, + within, } from '@testing-library/react'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; @@ -17,13 +19,18 @@ import { GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, type GameCreationAgentRunTrace, } from '../../../packages/shared/src/contracts/gameCreationApp'; -import { App } from '../src/App'; +import { App, WorkspaceLauncher, deriveAgentStatusCards } from '../src/App'; function renderAppAt(path: string) { window.history.pushState({}, '', path); render(React.createElement(App)); } +function renderLauncherAt(path: string) { + window.history.pushState({}, '', path); + render(React.createElement(WorkspaceLauncher)); +} + function submitChat(value: string) { fireEvent.change(screen.getByLabelText('创作想法'), { target: { value }, @@ -31,28 +38,6077 @@ function submitChat(value: string) { fireEvent.click(screen.getByRole('button', { name: '发送' })); } +function emptyProjectPolicy() { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: [], + }, + }; +} + afterEach(() => { cleanup(); window.history.pushState({}, '', '/'); + window.localStorage.clear(); delete window.__TAURI__; vi.restoreAllMocks(); }); describe('AI 游戏创作 App 界面边界', () => { - it('keeps the user surface to chat, upload, config and command confirmation', () => { - renderAppAt('/'); + it('derives agent card status from the latest run trace step', () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const taskGraphTasks = createGameCreationAppSeedTasks().map((task) => + task.id === 'audio-director' + ? { ...task, status: 'waiting-for-confirmation' as const } + : task, + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-agent-status-cards', + commandId: 'game.generate_draft', + status: 'running', + lifecycleStatus: 'pending', + passes: 1, + maxPasses: 3, + toolCallCount: 3, + maxToolCalls: 128, + stopReason: 'running', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner -> Orchestrator', + steps: [ + { + pass: 1, + agent: 'Planner', + phase: 'plan', + taskId: 'design-director', + group: 'design', + role: 'Director', + status: 'running', + inputPaths: ['memory/session.md'], + outputPaths: ['.agent/spec.md'], + summary: '正在拆解创作方向', + toolCalls: [ + { + toolId: 'llm.planner', + status: 'ok', + inputPaths: ['memory/session.md'], + outputPaths: ['.agent/spec.md'], + summary: 'Planner 已读取短期记忆', + }, + ], + }, + { + pass: 1, + agent: 'Asset', + phase: 'role', + taskId: null, + group: 'art', + role: 'Asset', + status: 'failed', + inputPaths: [], + outputPaths: [], + summary: '美术资产生成失败', + toolCalls: [], + }, + { + pass: 1, + agent: 'Generator', + phase: 'write', + taskId: 'code-prototype', + group: 'code', + role: 'Code', + status: 'passed', + inputPaths: [], + outputPaths: [], + summary: '代码已通过生成', + toolCalls: [], + }, + ], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: ['audio-director'], + activeTaskIds: ['design-director'], + carriedTaskIds: ['balance-director'], + repairFocus: [], + repairRoutes: [], + tasks: taskGraphTasks, + }, + passPlans: [], + nextStep: 'continue', + error: null, + updatedAt: 1, + }; + + const cards = deriveAgentStatusCards(manifest, trace); + + expect(cards.find((card) => card.id === 'design-director')).toMatchObject({ + taskId: 'design-director', + status: 'running', + summary: '正在拆解创作方向', + pass: 1, + phase: 'plan', + lifecycleStatus: 'pending', + hasRecentEvidence: true, + taskGraphState: 'active', + inputPaths: ['memory/session.md'], + outputPaths: ['.agent/spec.md'], + toolCalls: [ + expect.objectContaining({ + toolId: 'llm.planner', + status: 'ok', + summary: 'Planner 已读取短期记忆', + }), + ], + }); + expect(cards.find((card) => card.id === 'art-asset')).toMatchObject({ + status: 'failed', + summary: '美术资产生成失败', + hasRecentEvidence: true, + }); + expect(cards.find((card) => card.id === 'code-code')).toMatchObject({ + taskId: 'code-prototype', + status: 'completed', + summary: '代码已通过生成', + }); + expect(cards.find((card) => card.id === 'audio-director')).toMatchObject({ + taskId: 'audio-director', + status: 'waiting-for-confirmation', + taskGraphState: 'ready', + hasRecentEvidence: false, + }); + expect(cards.find((card) => card.id === 'balance-director')).toMatchObject({ + taskId: 'balance-director', + taskGraphState: 'carried', + }); + }); + + it('keeps the user surface to chat, upload, config and command confirmation', async () => { + renderAppAt('/?main'); expect(screen.getByLabelText('聊天')).not.toBeNull(); - expect(screen.getByLabelText('创作想法')).not.toBeNull(); + expect(screen.getByLabelText('Agent 状态')).not.toBeNull(); + const composerInput = screen.getByLabelText('创作想法'); + expect(composerInput).not.toBeNull(); expect(screen.getByText('上传')).not.toBeNull(); + expect(screen.getByRole('button', { name: '命令' })).not.toBeNull(); + expect(screen.getByRole('button', { name: '能力' })).not.toBeNull(); expect(screen.getByRole('button', { name: '配置' })).not.toBeNull(); + expect(screen.getByRole('button', { name: 'LLM状态' })).not.toBeNull(); + expect(screen.getByRole('button', { name: '显示目录' })).not.toBeNull(); + expect( + (screen.getByRole('button', { name: '项目状态' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '权限' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '审计' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '运行' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '资产' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '打开画板' }) as HTMLButtonElement) + .disabled, + ).toBe(false); + expect( + (screen.getByRole('button', { name: '任务' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: 'Trace' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '文件' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '索引' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '记忆' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '短期记忆' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '黑板' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '快照' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '快照列表' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '历史' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '白名单' }) as HTMLButtonElement) + .disabled, + ).toBe(false); + expect( + (screen.getByRole('button', { name: '静态自检' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '启动预览' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '打开预览' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '预览状态' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '停止预览' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '刷新状态' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '刷新 Agent' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '状态' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '终止' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '重试' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + (screen.getByRole('button', { name: '继续' }) as HTMLButtonElement) + .disabled, + ).toBe(true); + expect( + ( + screen.getByRole('button', { + name: /拆解创作方向/, + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + fireEvent.click(screen.getByRole('button', { name: '打开画板' })); + expect(composerInput).toHaveProperty('value', '/canvas '); + fireEvent.click(screen.getByRole('button', { name: '白名单' })); + expect(await screen.findByText(/可运行受限命令:/)).not.toBeNull(); + expect(screen.getByRole('button', { name: '切换项目' })).not.toBeNull(); expect(screen.getByText('想做什么游戏?')).not.toBeNull(); + expect(screen.getAllByText('暂无最近运行证据').length).toBeGreaterThan(0); + expect(screen.queryByLabelText('工作区管理')).toBeNull(); expect(screen.queryByLabelText('开发环境')).toBeNull(); expect(screen.queryByLabelText('运行时配置')).toBeNull(); expect(screen.queryByText('Agent 能力')).toBeNull(); expect(screen.queryByText('编排 Trace')).toBeNull(); }); + it('starts from a launcher window and asks Tauri to open the main project window', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath: String(args?.projectPath ?? ''), + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: 'authorized-game', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'open_game_creator_workspace_window') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + expect(screen.getByLabelText('项目启动器')).not.toBeNull(); + expect(screen.queryByLabelText('聊天')).toBeNull(); + expect(screen.queryByRole('button', { name: '帮助' })).toBeNull(); + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + { + projectPath: '/tmp/authorized-game', + }, + ); + }); + expect(window.localStorage.length).toBe(1); + expect( + window.localStorage.getItem(window.localStorage.key(0) ?? ''), + ).toContain('/tmp/authorized-game'); + }); + + it('refreshes launcher recent project status after opening a workspace', async () => { + let inspectCount = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + inspectCount += 1; + return { + projectPath: String(args?.projectPath ?? ''), + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: + inspectCount === 1 ? 'authorized-game' : 'authorized-game-fresh', + recentRunStatus: inspectCount === 1 ? null : 'passed', + recentRunStopReason: inspectCount === 1 ? null : 'evaluator-passed', + }; + } + if (command === 'open_game_creator_workspace_window') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/authorized-game']), + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + expect(await screen.findByText('authorized-game')).not.toBeNull(); + fireEvent.click(screen.getByText('authorized-game')); + + expect(await screen.findByText('authorized-game-fresh')).not.toBeNull(); + expect(screen.getByText('run: passed · evaluator-passed')).not.toBeNull(); + expect(inspectCount).toBeGreaterThanOrEqual(2); + }); + + it('rejects unsafe main-window projectPath query values before Tauri calls', async () => { + const invoke = vi.fn(async () => undefined); + window.__TAURI__ = { core: { invoke } }; + + renderAppAt('/?main&projectPath=relative-game'); + + expect(await screen.findByText('请提供工作区绝对路径')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + + cleanup(); + window.history.pushState({}, '', '/'); + renderAppAt('/?main&projectPath=%2Ftmp%2Fbad%0Apath'); + + expect( + await screen.findByText('工作区路径不能包含控制字符'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('does not open a missing or non-directory path from the open action', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + const projectPath = String(args?.projectPath ?? ''); + if (projectPath === '/tmp/broken-status') { + throw new Error('status failed'); + } + return { + projectPath, + exists: projectPath !== '/tmp/missing-game', + isDirectory: projectPath !== '/tmp/not-a-folder', + isGameCreatorProject: projectPath === '/tmp/authorized-game', + projectName: + projectPath === '/tmp/authorized-game' ? 'authorized-game' : null, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'open_game_creator_workspace_window') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/missing-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + expect(await screen.findByText('项目目录不存在')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/not-a-folder' }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + expect(await screen.findByText('项目路径不是文件夹')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/plain-folder' }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + expect( + await screen.findByText('这不是已初始化的 AI 游戏项目,请使用新建项目。'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); + }); + + it('fills the project path from the native directory picker', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'pick_local_project_directory') { + return '/tmp/picked-game'; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.click(screen.getByRole('button', { name: '选择' })); + + expect(await screen.findByDisplayValue('/tmp/picked-game')).not.toBeNull(); + expect(screen.getByText('已选择项目目录')).not.toBeNull(); + }); + + it('keeps the typed project path when the native directory picker is cancelled', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'pick_local_project_directory') { + return null; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/manual-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '选择' })); + + expect(await screen.findByDisplayValue('/tmp/manual-game')).not.toBeNull(); + expect(screen.getByText('已取消')).not.toBeNull(); + }); + + it('rejects launcher project paths with control characters before Tauri calls', () => { + const invoke = vi.fn(async () => undefined); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/bad\u0007path' }, + }); + fireEvent.click(screen.getAllByRole('button', { name: '新建项目' })[0]); + + expect(screen.getByText('项目目录不能包含控制字符')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('edits runtime config directly from the launcher', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_game_creator_app_config') { + return { + path: '/home/test/AppData/game-creator.config.json', + config: { + llm: { + apiKey: 'launcher-loaded-secret', + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-launcher', + apiKind: 'legacy', + stream: false, + requestTimeoutMs: 10, + maxRetries: -2, + retryBackoffMs: 0, + }, + editorApi: { + baseUrl: 'http://127.0.0.1:8082', + apiKey: 'editor-loaded-secret', + }, + }, + }; + } + if (command === 'write_game_creator_app_config') { + return { + path: '/home/test/AppData/game-creator.config.json', + config: args?.config, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.click(screen.getByRole('button', { name: '配置' })); + + const dialog = await screen.findByRole('dialog', { name: '运行时配置' }); + expect(await screen.findByDisplayValue('gpt-launcher')).not.toBeNull(); + expect(screen.getByLabelText('LLM 超时 ms')).toHaveProperty( + 'value', + '1000', + ); + expect(screen.getByLabelText('LLM 重试次数')).toHaveProperty('value', '0'); + expect(screen.getByLabelText('LLM 退避 ms')).toHaveProperty('value', '1'); + expect(screen.getByLabelText('LLM API 类型')).toHaveProperty( + 'value', + 'openai_responses', + ); + fireEvent.change(screen.getByLabelText('LLM 模型'), { + target: { value: 'gpt-launcher-updated' }, + }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', { + config: expect.objectContaining({ + llm: expect.objectContaining({ + model: 'gpt-launcher-updated', + apiKind: 'openai_responses', + requestTimeoutMs: 1000, + maxRetries: 0, + retryBackoffMs: 1, + }), + }), + }); + }); + expect( + await screen.findByText( + '已保存:/home/test/AppData/game-creator.config.json', + ), + ).not.toBeNull(); + fireEvent.mouseDown(dialog.parentElement as HTMLElement); + expect(screen.queryByRole('dialog', { name: '运行时配置' })).toBeNull(); + }); + + it('keeps runtime config open when Escape is pressed in an input', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'read_game_creator_app_config') { + return { + path: '/home/test/AppData/game-creator.config.json', + config: { + llm: { + apiKey: '', + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-test', + apiKind: 'openai_responses', + stream: false, + requestTimeoutMs: 180000, + maxRetries: 0, + retryBackoffMs: 500, + }, + editorApi: { + baseUrl: 'http://127.0.0.1:8082', + apiKey: '', + }, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.click(screen.getByRole('button', { name: '配置' })); + + expect( + await screen.findByRole('dialog', { name: '运行时配置' }), + ).not.toBeNull(); + fireEvent.keyDown(screen.getByLabelText('LLM Base URL'), { + key: 'Escape', + }); + expect(screen.getByRole('dialog', { name: '运行时配置' })).not.toBeNull(); + + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(screen.queryByRole('dialog', { name: '运行时配置' })).toBeNull(); + }); + + it('disables runtime config actions while reading config', async () => { + let resolveRead: + | ((value: { + path: string; + config: { + llm: { + apiKey: string; + baseUrl: string; + model: string; + apiKind: string; + stream: boolean; + requestTimeoutMs: number; + maxRetries: number; + retryBackoffMs: number; + }; + editorApi: { baseUrl: string; apiKey: string }; + }; + }) => void) + | undefined; + let readCount = 0; + const invoke = vi.fn((command: string) => { + if (command === 'read_game_creator_app_config') { + readCount += 1; + return new Promise((resolve) => { + resolveRead = resolve as typeof resolveRead; + }); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.click(screen.getByRole('button', { name: '配置' })); + + expect( + await screen.findByRole('dialog', { name: '运行时配置' }), + ).not.toBeNull(); + expect(await screen.findByText('正在读取')).not.toBeNull(); + expect(screen.getByRole('button', { name: '读取' })).toHaveProperty( + 'disabled', + true, + ); + expect(screen.getByRole('button', { name: '恢复默认' })).toHaveProperty( + 'disabled', + true, + ); + expect(screen.getByRole('button', { name: '保存' })).toHaveProperty( + 'disabled', + true, + ); + + fireEvent.click(screen.getByRole('button', { name: '读取' })); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + expect(readCount).toBe(1); + await act(async () => { + resolveRead?.({ + path: '/home/test/AppData/game-creator.config.json', + config: { + llm: { + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + stream: false, + requestTimeoutMs: 180000, + maxRetries: 0, + retryBackoffMs: 500, + }, + editorApi: { + baseUrl: 'http://127.0.0.1:8082', + apiKey: '', + }, + }, + }); + }); + + expect(await screen.findByText(/已读取:/)).not.toBeNull(); + expect(screen.getByRole('button', { name: '读取' })).toHaveProperty( + 'disabled', + false, + ); + }); + + it('removes and clears recent launcher projects without opening them', () => { + const invoke = vi.fn(async () => undefined); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify([ + '/tmp/recent-one', + ' /tmp/recent-one ', + ' ', + 42, + 'relative-game', + '/tmp/recent-two', + ]), + ); + renderLauncherAt('/?launcher'); + + expect(screen.getByLabelText('最近项目')).not.toBeNull(); + expect(screen.getAllByText('/tmp/recent-one')).toHaveLength(1); + expect(screen.getByText('/tmp/recent-two')).not.toBeNull(); + expect(screen.queryByText('relative-game')).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('inspect_local_project_directory', { + projectPath: 'relative-game', + }); + fireEvent.click( + screen.getByRole('button', { name: '移除 /tmp/recent-one' }), + ); + + expect(screen.queryByText('/tmp/recent-one')).toBeNull(); + expect(screen.getByText('/tmp/recent-two')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '清空' })); + expect(screen.queryByLabelText('最近项目')).toBeNull(); + expect(screen.getByText('欢迎使用 AI 游戏创作')).not.toBeNull(); + expect(window.localStorage.length).toBe(0); + }); + + it('opens a recent launcher project directory in the system file manager', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath: String(args?.projectPath ?? ''), + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '最近的项目', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'open_local_project_directory') { + return undefined; + } + if (command === 'open_game_creator_workspace_window') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/recent-one']), + ); + renderLauncherAt('/?launcher'); + + expect(await screen.findByText('最近的项目')).not.toBeNull(); + fireEvent.click( + screen.getByRole('button', { name: '显示 /tmp/recent-one' }), + ); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('open_local_project_directory', { + projectPath: '/tmp/recent-one', + }); + }); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); + expect(screen.getByText('已打开项目目录')).not.toBeNull(); + expect(window.localStorage.length).toBe(1); + }); + + it('opens the typed launcher project directory in the system file manager', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'open_local_project_directory') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/typed-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '显示目录' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('open_local_project_directory', { + projectPath: '/tmp/typed-game', + }); + }); + expect(screen.getByText('已打开项目目录')).not.toBeNull(); + }); + + it('rejects invalid typed launcher project directories before opening them', () => { + const invoke = vi.fn(); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: 'relative-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '显示目录' })); + + expect(screen.getByText('请提供项目绝对路径')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_local_project_directory', + expect.anything(), + ); + }); + + it('does not remember a launcher project when opening the main window fails', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + return { + projectPath: String(args?.projectPath ?? ''), + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '打开失败项目', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'open_game_creator_workspace_window') { + throw new Error('window open failed'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/open-failed-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '打开' })); + + expect(await screen.findByText('window open failed')).not.toBeNull(); + expect(window.localStorage.length).toBe(0); + }); + + it('marks missing recent launcher projects and does not reopen them', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + const projectPath = String(args?.projectPath ?? ''); + if (projectPath === '/tmp/broken-status') { + throw new Error('status failed'); + } + return { + projectPath, + exists: projectPath !== '/tmp/missing-game', + isDirectory: projectPath !== '/tmp/not-a-folder', + isGameCreatorProject: projectPath === '/tmp/ok-game', + projectName: projectPath === '/tmp/ok-game' ? '厨房突围' : null, + manifestError: + projectPath === '/tmp/broken-manifest' + ? '解析 manifest 失败' + : null, + recentRunStatus: projectPath === '/tmp/ok-game' ? 'done' : null, + recentRunStopReason: + projectPath === '/tmp/ok-game' ? 'preview-running' : null, + }; + } + if (command === 'open_game_creator_workspace_window') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify([ + '/tmp/missing-game', + '/tmp/not-a-folder', + '/tmp/plain-folder', + '/tmp/broken-manifest', + '/tmp/broken-status', + '/tmp/ok-game', + ]), + ); + renderLauncherAt('/?launcher'); + + expect(await screen.findByText('未找到')).not.toBeNull(); + expect(screen.getByText('不是文件夹')).not.toBeNull(); + expect(screen.getAllByText('未初始化').length).toBeGreaterThan(0); + expect(screen.getByText('无法读取')).not.toBeNull(); + expect(screen.getByText('检查失败')).not.toBeNull(); + expect(screen.getByText('厨房突围')).not.toBeNull(); + expect(screen.getByText('run: done · preview-running')).not.toBeNull(); + const missingOpenButton = screen + .getByText('missing-game') + .closest('button') as HTMLButtonElement; + const plainFolderOpenButton = screen + .getByText('plain-folder') + .closest('button') as HTMLButtonElement; + const brokenStatusOpenButton = screen + .getByText('broken-status') + .closest('button') as HTMLButtonElement; + const brokenManifestOpenButton = screen + .getByText('broken-manifest') + .closest('button') as HTMLButtonElement; + const missingRevealButton = screen.getByRole('button', { + name: '显示 /tmp/missing-game', + }) as HTMLButtonElement; + const notAFolderRevealButton = screen.getByRole('button', { + name: '显示 /tmp/not-a-folder', + }) as HTMLButtonElement; + const plainFolderRevealButton = screen.getByRole('button', { + name: '显示 /tmp/plain-folder', + }) as HTMLButtonElement; + const brokenManifestRevealButton = screen.getByRole('button', { + name: '显示 /tmp/broken-manifest', + }) as HTMLButtonElement; + const brokenStatusRevealButton = screen.getByRole('button', { + name: '显示 /tmp/broken-status', + }) as HTMLButtonElement; + expect(missingOpenButton.disabled).toBe(true); + expect(plainFolderOpenButton.disabled).toBe(true); + expect(brokenManifestOpenButton.disabled).toBe(true); + expect(brokenStatusOpenButton.disabled).toBe(true); + expect(missingRevealButton.disabled).toBe(true); + expect(notAFolderRevealButton.disabled).toBe(true); + expect(plainFolderRevealButton.disabled).toBe(false); + expect(brokenManifestRevealButton.disabled).toBe(false); + expect(brokenStatusRevealButton.disabled).toBe(true); + fireEvent.click(missingOpenButton); + fireEvent.click(plainFolderOpenButton); + fireEvent.click(brokenManifestOpenButton); + fireEvent.click(brokenStatusOpenButton); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + { projectPath: '/tmp/missing-game' }, + ); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + { projectPath: '/tmp/plain-folder' }, + ); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + { projectPath: '/tmp/broken-manifest' }, + ); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + { projectPath: '/tmp/broken-status' }, + ); + + fireEvent.click(screen.getByText('厨房突围').closest('button')!); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + { + projectPath: '/tmp/ok-game', + }, + ); + }); + }); + + it('refreshes recent launcher project status without changing the list', async () => { + let inspectionCount = 0; + let finishRefresh: + | ((status: { + projectPath: string; + exists: boolean; + isDirectory: boolean; + isGameCreatorProject: boolean; + projectName: string; + recentRunStatus: string; + recentRunStopReason: string; + }) => void) + | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'inspect_local_project_directory') { + inspectionCount += 1; + const projectPath = String(args?.projectPath ?? ''); + if (inspectionCount > 1) { + return await new Promise((resolve) => { + finishRefresh = resolve; + }); + } + return { + projectPath, + exists: false, + isDirectory: false, + isGameCreatorProject: false, + projectName: null, + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'open_game_creator_workspace_window') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/refreshable-game']), + ); + renderLauncherAt('/?launcher'); + + expect(await screen.findByText('未找到')).not.toBeNull(); + expect( + ( + screen + .getByText('refreshable-game') + .closest('button') as HTMLButtonElement + ).disabled, + ).toBe(true); + + fireEvent.click(screen.getByRole('button', { name: '刷新' })); + expect( + (await screen.findByRole('button', { + name: '刷新中', + })) as HTMLButtonElement, + ).toHaveProperty('disabled', true); + expect(screen.getByText('检查中')).not.toBeNull(); + expect( + ( + screen + .getByText('refreshable-game') + .closest('button') as HTMLButtonElement + ).disabled, + ).toBe(true); + expect( + ( + screen.getByRole('button', { + name: '显示 /tmp/refreshable-game', + }) as HTMLButtonElement + ).disabled, + ).toBe(true); + + await act(async () => { + finishRefresh?.({ + projectPath: '/tmp/refreshable-game', + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '刷新后的项目', + recentRunStatus: 'done', + recentRunStopReason: 'preview-running', + }); + }); + + expect(await screen.findByText('刷新后的项目')).not.toBeNull(); + expect(screen.getByText('run: done · preview-running')).not.toBeNull(); + expect( + (screen.getByText('刷新后的项目').closest('button') as HTMLButtonElement) + .disabled, + ).toBe(false); + expect(window.localStorage.length).toBe(1); + }); + + it('does not restore a removed recent launcher project after a slow refresh', async () => { + let finishRefresh: + | ((status: { + projectPath: string; + exists: boolean; + isDirectory: boolean; + isGameCreatorProject: boolean; + projectName: string; + recentRunStatus: string; + recentRunStopReason: string; + }) => void) + | null = null; + const invoke = vi.fn(async (command: string) => { + if (command === 'inspect_local_project_directory') { + return await new Promise((resolve) => { + finishRefresh = resolve; + }); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + window.localStorage.setItem( + 'genarrative-ai-game-creator.recent-workspaces.v1', + JSON.stringify(['/tmp/slow-refresh-game']), + ); + renderLauncherAt('/?launcher'); + + expect(await screen.findByText('检查中')).not.toBeNull(); + fireEvent.click( + screen.getByRole('button', { name: '移除 /tmp/slow-refresh-game' }), + ); + expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull(); + + await act(async () => { + finishRefresh?.({ + projectPath: '/tmp/slow-refresh-game', + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '慢速刷新旧项目', + recentRunStatus: 'done', + recentRunStopReason: 'late', + }); + }); + + expect(screen.queryByText('/tmp/slow-refresh-game')).toBeNull(); + expect(screen.queryByText('慢速刷新旧项目')).toBeNull(); + expect(screen.queryByText('run: done · late')).toBeNull(); + expect(screen.getByText('欢迎使用 AI 游戏创作')).not.toBeNull(); + }); + + it('keeps the typed project path when native directory picking is cancelled', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'pick_local_project_directory') { + return null; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/typed-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '选择' })); + + expect(await screen.findByText('已取消')).not.toBeNull(); + expect(screen.getByDisplayValue('/tmp/typed-game')).not.toBeNull(); + }); + + it('warns when creating in a picked non-empty folder', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'pick_local_project_directory') { + return '/tmp/picked-non-empty-game'; + } + if (command === 'is_local_project_directory_non_empty') { + expect(args).toEqual({ + projectPath: '/tmp/picked-non-empty-game', + }); + return true; + } + if (command === 'init_local_game_project') { + throw new Error('should wait for explicit confirmation'); + } + if (command === 'open_game_creator_workspace_window') { + throw new Error('should wait for explicit confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.click(screen.getByRole('button', { name: '选择' })); + expect(await screen.findByText('已选择项目目录')).not.toBeNull(); + expect( + screen.getByDisplayValue('/tmp/picked-non-empty-game'), + ).not.toBeNull(); + + fireEvent.click(screen.getAllByRole('button', { name: '新建项目' })[0]); + + expect( + await screen.findByRole('dialog', { name: '文件夹不是空的' }), + ).not.toBeNull(); + expect(screen.getByText('/tmp/picked-non-empty-game')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'init_local_game_project', + expect.anything(), + ); + }); + + it('warns before creating a project in a non-empty folder', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'is_local_project_directory_non_empty') { + expect(args).toEqual({ + projectPath: '/tmp/non-empty-game', + }); + return true; + } + if (command === 'init_local_game_project') { + return { + projectPath: String(args?.projectPath ?? ''), + manifestPath: `${String(args?.projectPath ?? '')}/.agent/manifest.json`, + manifest: createGameCreationAppManifest( + 'local-project-draft', + 'non-empty-game', + ), + }; + } + if (command === 'open_game_creator_workspace_window') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/non-empty-game' }, + }); + fireEvent.click(screen.getAllByRole('button', { name: '新建项目' })[0]); + + expect( + await screen.findByRole('dialog', { name: '文件夹不是空的' }), + ).not.toBeNull(); + expect(screen.getByText('/tmp/non-empty-game')).not.toBeNull(); + fireEvent.keyDown(window, { key: 'Escape' }); + + expect(await screen.findByText('已取消')).not.toBeNull(); + expect(confirm).not.toHaveBeenCalled(); + expect(screen.queryByRole('dialog', { name: '文件夹不是空的' })).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); + expect(window.localStorage.length).toBe(0); + }); + + it('creates in a non-empty folder after the user confirms the warning', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'is_local_project_directory_non_empty') { + return true; + } + if (command === 'init_local_game_project') { + return { + projectPath: String(args?.projectPath ?? ''), + manifestPath: `${String(args?.projectPath ?? '')}/.agent/manifest.json`, + manifest: createGameCreationAppManifest( + 'local-project-draft', + 'non-empty-game', + ), + }; + } + if (command === 'open_game_creator_workspace_window') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/non-empty-game' }, + }); + fireEvent.click(screen.getAllByRole('button', { name: '新建项目' })[0]); + + expect( + await screen.findByRole('dialog', { name: '文件夹不是空的' }), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '继续新建' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('init_local_game_project', { + projectPath: '/tmp/non-empty-game', + projectId: 'local-project-draft', + name: 'non-empty-game', + }); + expect(invoke).toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + { + projectPath: '/tmp/non-empty-game', + }, + ); + }); + expect(confirm).not.toHaveBeenCalled(); + expect(window.localStorage.length).toBe(1); + }); + + it('uses the selected folder name as the default project name', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'is_local_project_directory_non_empty') { + return false; + } + if (command === 'init_local_game_project') { + return { + projectPath: String(args?.projectPath ?? ''), + manifestPath: `${String(args?.projectPath ?? '')}/.agent/manifest.json`, + manifest: createGameCreationAppManifest( + 'local-project-draft', + String(args?.name ?? ''), + ), + }; + } + if (command === 'open_game_creator_workspace_window') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/folder-named-game/' }, + }); + fireEvent.click(screen.getAllByRole('button', { name: '新建项目' })[0]); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('init_local_game_project', { + projectPath: '/tmp/folder-named-game/', + projectId: 'local-project-draft', + name: 'folder-named-game', + }); + }); + }); + + it('keeps the launcher open when project creation fails', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'is_local_project_directory_non_empty') { + return false; + } + if (command === 'init_local_game_project') { + throw new Error('初始化失败'); + } + if (command === 'open_game_creator_workspace_window') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAt('/?launcher'); + + fireEvent.change(screen.getByLabelText('项目目录'), { + target: { value: '/tmp/new-game' }, + }); + fireEvent.click(screen.getAllByRole('button', { name: '新建项目' })[0]); + + expect(await screen.findByText('初始化失败')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_game_creator_workspace_window', + expect.anything(), + ); + expect(window.localStorage.length).toBe(0); + }); + + it('opens the launcher from the main project window', async () => { + const invoke = vi.fn(async () => undefined); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main'); + + fireEvent.click(screen.getByRole('button', { name: '切换项目' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('open_game_creator_launcher_window'); + }); + + submitChat('/switch-project'); + + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'open_game_creator_launcher_window', + ), + ).toHaveLength(2); + }); + }); + + it('opens the current project directory from the main project window', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'open_local_project_directory') { + return undefined; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('已打开:/tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '显示目录' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('open_local_project_directory', { + projectPath: '/tmp/authorized-game', + }); + }); + expect(await screen.findByText('已打开项目目录。')).not.toBeNull(); + + submitChat('/open-project'); + + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'open_local_project_directory', + ), + ).toHaveLength(2); + }); + expect( + screen.getAllByText('已打开项目目录。').length, + ).toBeGreaterThanOrEqual(2); + }); + + it('fills an asset registration draft from recent project files without registering immediately', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error('missing trace'); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { + path: 'assets/uploads/hero.png', + kind: 'file', + size: 4, + modifiedAt: 1700000001, + }, + ], + }; + } + if (command === 'register_local_asset') { + throw new Error('should only fill the chat draft'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + await act(async () => { + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + }); + + await screen.findByText('已打开:/tmp/authorized-game'); + invoke.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: '文件' })); + + expect(await screen.findByText(/本地项目文件:/)).not.toBeNull(); + fireEvent.click( + within(screen.getByLabelText('最近项目文件')).getByRole('button', { + name: '登记资产 assets/uploads/hero.png', + }), + ); + await waitFor(() => + expect(document.activeElement).toBe(screen.getByLabelText('创作想法')), + ); + + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/asset-register assets/uploads/hero.png image image/png', + ); + expect( + screen.queryByText('asset.register · assets/uploads/hero.png'), + ).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'register_local_asset', + expect.anything(), + ); + }); + + it('runs preview shortcuts from the main project window', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + manifest.assets.push({ + id: 'asset-hero', + kind: 'uploaded', + mediaType: 'text/plain', + localPath: 'assets/uploads/hero.txt', + source: { kind: 'uploaded' }, + }); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-main-shortcut-trace', + commandId: 'game.generate_draft', + status: 'passed', + lifecycleStatus: 'done', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'evaluator-passed', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner -> Generator', + steps: [ + { + pass: 1, + agent: 'Generator', + phase: 'generate', + status: 'completed', + inputPaths: ['.agent/spec.md'], + outputPaths: ['.agent/passes/pass-1/draft.json'], + summary: '生成可运行草案', + toolCalls: [ + { + toolId: 'llm.chat.generator', + status: 'ok', + summary: 'Generator 生成草案', + }, + ], + }, + ], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'preview', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'run_limited_local_command') { + return { + commandId: 'game.static_smoke', + status: 'completed', + output: 'static smoke passed', + logPath: '.agent/logs/command.log', + }; + } + if (command === 'start_local_game_preview') { + return { + status: 'running', + url: 'http://127.0.0.1:3210/', + port: 3210, + root: String(args?.projectPath ?? ''), + }; + } + if (command === 'open_local_game_preview') { + return { + status: 'running', + url: 'http://127.0.0.1:3210/', + port: 3210, + root: String(args?.projectPath ?? ''), + }; + } + if (command === 'get_local_game_preview_status') { + return { + status: 'running', + url: 'http://127.0.0.1:3210/', + port: 3210, + root: String(args?.projectPath ?? ''), + }; + } + if (command === 'stop_local_game_preview') { + return { + status: 'stopped', + url: null, + port: null, + root: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_game_creation_agent_capabilities') { + return [ + { + id: 'native-only-capability', + area: 'agent-runtime', + title: 'Native Runtime 能力', + }, + ]; + } + if (command === 'get_limited_local_commands') { + return [{ id: 'game.static_smoke', title: '静态入口自检' }]; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { + path: 'game/index.html', + kind: 'file', + size: 256, + modifiedAt: 1700000001, + }, + { + path: 'assets', + kind: 'directory', + size: 0, + modifiedAt: 1700000000, + }, + { + path: '.agent/checkpoints/checkpoint-main/manifest.json', + kind: 'file', + size: 96, + modifiedAt: 1700000002, + }, + ], + }; + } + if (command === 'build_local_project_index') { + return { + projectPath: String(args?.projectPath ?? ''), + indexPath: '.agent/project.index.json', + fileCount: 2, + totalBytes: 384, + files: [ + { path: 'game/index.html', size: 256, checksum: 'fnv1a64:game' }, + { + path: 'assets/uploads/hero.png', + size: 128, + checksum: 'fnv1a64:hero', + }, + ], + }; + } + if (command === 'read_local_game_memory') { + const scope = String(args?.scope ?? 'long'); + if (scope === 'short') { + return { + scope, + path: 'memory/session.md', + content: '# 短期记忆\n- 本轮偏动作反馈\n', + exists: true, + }; + } + if (scope === 'blackboard') { + return { + scope, + path: 'memory/blackboard.md', + content: '# 项目黑板\n- 跨 agent 共享约束\n', + exists: true, + }; + } + return { + scope, + path: 'memory/project.md', + content: '# 项目长期记忆\n- 保留厨房主题\n', + exists: true, + }; + } + if (command === 'read_local_project_file') { + if (args?.relativePath === 'game/index.html') { + return { + path: 'game/index.html', + absolutePath: `${String(args?.projectPath ?? '')}/game/index.html`, + content: '', + }; + } + if (args?.relativePath === 'assets/uploads/hero.txt') { + return { + path: 'assets/uploads/hero.txt', + absolutePath: `${String(args?.projectPath ?? '')}/assets/uploads/hero.txt`, + content: 'hero asset', + }; + } + if ( + args?.relativePath === + '.agent/checkpoints/checkpoint-main/manifest.json' + ) { + return { + path: '.agent/checkpoints/checkpoint-main/manifest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/checkpoints/checkpoint-main/manifest.json`, + content: JSON.stringify({ + checkpointId: 'checkpoint-main', + fileCount: 3, + totalBytes: 256, + createdAt: 1700000002, + }), + }; + } + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String(args?.relativePath ?? '')}`, + content: JSON.stringify(trace), + }; + } + if (command === 'create_local_project_checkpoint') { + return { + checkpointId: 'checkpoint-main', + checkpointPath: '.agent/checkpoints/checkpoint-main', + fileCount: 3, + totalBytes: 256, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('已打开:/tmp/authorized-game'); + expect(screen.getByText('未命名游戏原型')).not.toBeNull(); + expect(screen.getByText('/tmp/authorized-game')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '能力' })); + expect(await screen.findByText(/Native Runtime 能力/)).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '项目状态' })); + expect(await screen.findByText(/项目:未命名游戏原型/)).not.toBeNull(); + expect(screen.getByText(/目录:\/tmp\/authorized-game/)).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '权限' })); + expect( + await screen.findByText(/策略:\.agent\/policy\.json/), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '审计' })); + expect(await screen.findByText(/Agent v1 审计:/)).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '资产' })); + expect( + await screen.findByText(/uploaded · assets\/uploads\/hero\.txt/), + ).not.toBeNull(); + expect( + within(screen.getByLabelText('最近项目资产')).getByText( + 'uploaded · text/plain · uploaded', + ), + ).not.toBeNull(); + const composerInput = screen.getByLabelText('创作想法'); + fireEvent.click(screen.getByRole('button', { name: '打开画板' })); + expect(composerInput).toHaveProperty('value', '/canvas '); + await waitFor(() => expect(document.activeElement).toBe(composerInput)); + fireEvent.click(screen.getByRole('button', { name: '同步画板' })); + expect(composerInput).toHaveProperty('value', '/sync-canvas-project '); + await waitFor(() => expect(document.activeElement).toBe(composerInput)); + fireEvent.click(screen.getByRole('button', { name: '生成美术' })); + expect(composerInput).toHaveProperty('value', '/generate-art '); + await waitFor(() => expect(document.activeElement).toBe(composerInput)); + fireEvent.click( + within(screen.getByLabelText('最近项目资产')).getByRole('button', { + name: 'assets/uploads/hero.txt', + }), + ); + expect( + await screen.findByText(/文件:assets\/uploads\/hero\.txt/), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '任务' })); + expect(await screen.findByText(/任务拆分:/)).not.toBeNull(); + expect(screen.getByText(/下一步:策划组 \/ Director/)).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: 'Trace' })); + expect( + await screen.findByText(/Run:run-main-shortcut-trace/), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '文件' })); + expect(await screen.findByText(/本地项目文件:/)).not.toBeNull(); + fireEvent.click( + within(screen.getByLabelText('最近项目文件')).getByRole('button', { + name: 'game/index.html', + }), + ); + expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull(); + fireEvent.click( + within(screen.getByLabelText('最近项目文件')).getByRole('button', { + name: '填入读取 game/index.html', + }), + ); + expect(composerInput).toHaveProperty('value', '/read game/index.html'); + fireEvent.click( + within(screen.getByLabelText('最近项目文件')).getByRole('button', { + name: '登记资产 game/index.html', + }), + ); + expect(composerInput).toHaveProperty( + 'value', + '/asset-register game/index.html document text/html', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'register_local_asset', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '索引' })); + expect(await screen.findByText(/索引:2 个文件,384B/)).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '记忆' })); + expect(await screen.findByText(/长期记忆:/)).not.toBeNull(); + expect(screen.getByText(/保留厨房主题/)).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '短期记忆' })); + expect(await screen.findByText(/短期记忆:/)).not.toBeNull(); + expect(screen.getByText(/本轮偏动作反馈/)).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '黑板' })); + expect(await screen.findByText(/黑板记忆:/)).not.toBeNull(); + expect(screen.getByText(/跨 agent 共享约束/)).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '快照' })); + expect(await screen.findByText('project.checkpoint')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText(/已保存 checkpoint:checkpoint-main/), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '快照列表' })); + const checkpointList = await screen.findByLabelText('最近 checkpoint'); + expect(within(checkpointList).getByText('checkpoint-main')).not.toBeNull(); + expect(within(checkpointList).getByText(/3 个文件 · 256B/)).not.toBeNull(); + expect( + within(checkpointList).getByText(/createdAt 1700000002/), + ).not.toBeNull(); + expect( + within(checkpointList).getByRole('button', { + name: '对比 checkpoint-main', + }), + ).not.toBeNull(); + fireEvent.click( + within(checkpointList).getByRole('button', { + name: '回滚 checkpoint-main', + }), + ); + expect(await screen.findByText(/project\.restore/)).not.toBeNull(); + expect( + screen.getByText( + '从 checkpoint-main 恢复 /tmp/authorized-game 的已跟踪项目文件', + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + + fireEvent.click(screen.getByRole('button', { name: '白名单' })); + expect(await screen.findByText(/可运行受限命令:/)).not.toBeNull(); + expect( + screen.getByText(/game\.static_smoke · 静态入口自检/), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '静态自检' })); + expect(await screen.findByText('准备运行静态入口自检。')).not.toBeNull(); + expect(screen.getByText(/command\.run_limited/)).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect(await screen.findByText(/static smoke passed/)).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '运行' })); + expect(await screen.findByText('game.run_local')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText( + /运行通过,预览已启动:http:\/\/127\.0\.0\.1:3210\//, + ), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '启动预览' })); + expect(await screen.findByText('preview.start')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText(/预览已启动:http:\/\/127\.0\.0\.1:3210\//), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '打开预览' })); + expect(await screen.findByText('preview.open')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开当前本地预览:http://127.0.0.1:3210/'), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '预览状态' })); + expect( + await screen.findByText('预览运行中:http://127.0.0.1:3210/'), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '停止预览' })); + expect(await screen.findByText('预览已停止。')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('run_limited_local_command', { + projectPath: '/tmp/authorized-game', + commandId: 'game.static_smoke', + }); + expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', { + projectPath: '/tmp/authorized-game', + commandId: 'asset.list', + }); + expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', { + projectPath: '/tmp/authorized-game', + commandId: 'task.list', + }); + expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', { + projectPath: '/tmp/authorized-game', + commandId: 'agent.audit', + }); + expect(invoke).toHaveBeenCalledWith('get_game_creation_agent_capabilities'); + expect(invoke).toHaveBeenCalledWith('list_local_project_files', { + projectPath: '/tmp/authorized-game', + }); + expect(invoke).toHaveBeenCalledWith('build_local_project_index', { + projectPath: '/tmp/authorized-game', + }); + expect(invoke).toHaveBeenCalledWith('read_local_game_memory', { + projectPath: '/tmp/authorized-game', + scope: 'long', + }); + expect(invoke).toHaveBeenCalledWith('read_local_game_memory', { + projectPath: '/tmp/authorized-game', + scope: 'short', + }); + expect(invoke).toHaveBeenCalledWith('read_local_game_memory', { + projectPath: '/tmp/authorized-game', + scope: 'blackboard', + }); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: 'game/index.html', + commandId: 'file.read', + }); + expect(invoke).toHaveBeenCalledWith('create_local_project_checkpoint', { + projectPath: '/tmp/authorized-game', + }); + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath: '/tmp/authorized-game', + }); + expect(invoke).toHaveBeenCalledWith('open_local_game_preview', { + projectPath: '/tmp/authorized-game', + }); + expect(invoke).toHaveBeenCalledWith('get_local_game_preview_status', { + projectPath: '/tmp/authorized-game', + }); + expect(invoke).toHaveBeenCalledWith('stop_local_game_preview', { + projectPath: '/tmp/authorized-game', + }); + }); + + it('shows and refreshes the current project run status in the main window header', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let runReadCount = 0; + const makeTrace = ( + status: string, + passes: number, + stopReason: string, + lifecycleStatus?: string, + ) => + ({ + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-header-status', + commandId: 'game.generate_draft', + status, + lifecycleStatus, + passes, + maxPasses: 3, + toolCallCount: 0, + maxToolCalls: 128, + stopReason, + goal: '做一个厨房弹幕游戏', + coordination: 'Planner -> Generator', + steps: [], + artifacts: Array.from({ length: 9 }, (_, index) => ({ + path: `exports/artifact-${index + 1}.json`, + sizeBytes: index + 1, + checksum: `fnv1a64:artifact-${index + 1}`, + })), + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: ['preview-playtest'], + activeTaskIds: ['code-prototype'], + carriedTaskIds: ['design-director'], + repairFocus: [], + repairRoutes: [ + { + issue: '缺少输入监听', + taskIds: ['code-prototype', 'quality-review'], + reason: 'code-runtime', + }, + ], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [ + { + pass: 1, + mode: 'repair', + summary: '继续程序返工', + activeTaskIds: ['code-prototype', 'quality-review'], + carriedTaskIds: ['design-director'], + dependencyWaves: [['code-prototype'], ['quality-review']], + repairFocus: ['缺少输入监听'], + repairRoutes: [ + { + issue: '缺少输入监听', + taskIds: ['code-prototype', 'quality-review'], + reason: 'code-runtime', + }, + ], + }, + ], + nextStep: 'preview', + error: null, + updatedAt: runReadCount, + }) satisfies GameCreationAgentRunTrace; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_project_file') { + runReadCount += 1; + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify( + runReadCount > 1 + ? makeTrace('passed', 2, 'evaluator-passed') + : makeTrace('running', 1, 'planning', 'pending'), + ), + }; + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); + + expect( + await screen.findByText('run: running / pending · 1/3 轮 · planning'), + ).not.toBeNull(); + expect(screen.getByLabelText('Agent task graph').textContent).toContain( + 'active: 程序组 / Code 生成可运行原型(code-prototype)', + ); + expect(screen.getByLabelText('Agent task graph').textContent).toContain( + 'carry-over: 策划组 / Director 拆解创作方向(design-director)', + ); + expect(screen.getByLabelText('Agent task graph').textContent).toContain( + 'ready: 程序组 / Playtest 预览并试玩验收(preview-playtest)', + ); + expect(screen.getByLabelText('Agent task graph').textContent).toContain( + 'route: 程序组 / Code 生成可运行原型(code-prototype), 程序组 / Review 执行质量评审(quality-review) · code-runtime', + ); + expect(screen.getByLabelText('Agent pass plans').textContent).toContain( + 'pass 1: repair · active 程序组 / Code 生成可运行原型(code-prototype), 程序组 / Review 执行质量评审(quality-review) · carry 策划组 / Director 拆解创作方向(design-director) · waves 程序组 / Code 生成可运行原型(code-prototype) / 程序组 / Review 执行质量评审(quality-review) · repair 缺少输入监听 · routes code-runtime: 程序组 / Code 生成可运行原型(code-prototype), 程序组 / Review 执行质量评审(quality-review)', + ); + expect(screen.getByLabelText('Agent artifacts').textContent).toContain( + 'exports/artifact-8.json', + ); + expect(screen.getByLabelText('Agent artifacts').textContent).not.toContain( + 'exports/artifact-9.json', + ); + expect(screen.getByLabelText('Agent artifacts').textContent).toContain( + '还有 1 个产物', + ); + fireEvent.click(screen.getByRole('button', { name: '刷新状态' })); + + expect( + await screen.findByText('run: passed · 2/3 轮 · evaluator-passed'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + }); + + it('requires project policy confirmation before refreshing trace from the panel', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-trace-panel-confirm', + commandId: 'game.generate_draft', + status: 'running', + lifecycleStatus: 'running', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'planning', + goal: '做一个厨房弹幕游戏', + coordination: 'filesystem', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'planner', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.trace_read'], + }, + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(trace), + }; + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); + + expect( + await screen.findByText('run: running / running · 1/3 轮 · planning'), + ).not.toBeNull(); + invoke.mockClear(); + + fireEvent.click( + within(screen.getByLabelText('Agent run trace')).getByRole('button', { + name: '刷新', + }), + ); + + expect(await screen.findByText('agent.trace_read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + }); + }); + + it('normalizes older run traces before rendering trace panels', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const legacyTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-legacy-trace', + commandId: 'game.generate_draft', + status: 'running', + passes: 1, + toolCallCount: 0, + stopReason: 'planning', + goal: '做一个厨房弹幕游戏', + coordination: 'legacy', + steps: [ + { + pass: 1, + agent: 'Generator', + phase: 'generate', + taskId: 'code-prototype', + group: 'code', + role: 'Code', + status: 'completed', + summary: '旧 trace 没有路径和工具数组', + }, + ], + taskGraph: { + goal: '做一个厨房弹幕游戏', + activeTaskIds: ['code-prototype'], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [ + { + pass: 1, + mode: 'repair', + summary: '旧 pass plan', + activeTaskIds: ['code-prototype'], + }, + ], + nextStep: 'continue', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(legacyTrace), + }; + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); + + expect( + await screen.findByText('run: running · 1/3 轮 · planning'), + ).not.toBeNull(); + expect(screen.getByLabelText('Agent task graph').textContent).toContain( + 'active: 程序组 / Code 生成可运行原型(code-prototype)', + ); + expect(screen.getByLabelText('Agent task graph').textContent).toContain( + 'carry-over: none', + ); + expect(screen.getByLabelText('Agent run trace').textContent).toContain( + 'generate · 程序组 / Code 生成可运行原型(code-prototype)', + ); + expect(screen.getByLabelText('Agent pass plans').textContent).toContain( + 'pass 1: repair · active 程序组 / Code 生成可运行原型(code-prototype) · carry none · waves none', + ); + }); + + it('reports malformed optional trace arrays before rendering panels', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const malformedTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-malformed-trace', + commandId: 'game.generate_draft', + status: 'running', + passes: 1, + goal: '做一个厨房弹幕游戏', + coordination: 'legacy', + steps: [ + { + pass: 1, + agent: 'Generator', + phase: 'generate', + taskId: 'code-prototype', + group: 'code', + role: 'Code', + status: 'completed', + summary: '坏 trace', + toolCalls: 'bad', + }, + ], + nextStep: 'continue', + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(malformedTrace), + }; + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); + + expect( + await screen.findByText('Agent run trace 格式不正确'), + ).not.toBeNull(); + }); + + it('treats a missing latest run trace as no recent run', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/new-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + `读取文件元数据失败:${String( + args?.projectPath ?? '', + )}/.agent/run.latest.json: No such file or directory (os error 2)`, + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fnew-game'); + + expect( + await screen.findByText('run: 还没有最近一次 Agent run'), + ).not.toBeNull(); + }); + + it('loads recent run history in the developer project window', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const makeTrace = ( + runId: string, + status: string, + passes: number, + stopReason: string, + lifecycleStatus?: string, + updatedAt = passes, + ) => + ({ + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId, + commandId: 'game.generate_draft', + status, + lifecycleStatus, + passes, + maxPasses: 3, + toolCallCount: 0, + maxToolCalls: 128, + stopReason, + goal: '做一个厨房弹幕游戏', + coordination: 'Planner -> Generator', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'preview', + error: null, + updatedAt, + }) satisfies GameCreationAgentRunTrace; + const traces = new Map([ + [ + '.agent/run.latest.json', + makeTrace('run-current', 'running', 1, 'planning'), + ], + [ + '.agent/runs/2026-07-02-new.json', + makeTrace('run-new', 'passed', 2, 'evaluator-passed', 'done', 600), + ], + [ + '.agent/runs/2026-07-01-old.json', + makeTrace( + 'run-old', + 'failed', + 3, + 'max-passes-exhausted', + undefined, + 700, + ), + ], + [ + '.agent/runs/2026-06-30-mid.json', + makeTrace('run-mid', 'running', 1, 'planning', undefined, 500), + ], + [ + '.agent/runs/2026-06-29-four.json', + makeTrace( + 'run-four', + 'failed', + 3, + 'max-passes-exhausted', + undefined, + 400, + ), + ], + [ + '.agent/runs/2026-06-28-five.json', + makeTrace('run-five', 'passed', 1, 'preview', 'done', 300), + ], + [ + '.agent/runs/2026-06-27-hidden.json', + makeTrace( + 'run-hidden', + 'failed', + 3, + 'max-passes-exhausted', + undefined, + 200, + ), + ], + [ + '.agent/runs/2026-06-26-hidden.json', + makeTrace( + 'run-older-hidden', + 'failed', + 3, + 'max-passes-exhausted', + undefined, + 100, + ), + ], + ]); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { + path: '.agent/runs/2026-07-02-new.json', + kind: 'file', + size: 120, + }, + { + path: '.agent/runs/2026-07-01-old.json', + kind: 'file', + size: 110, + }, + { + path: '.agent/runs/2026-06-30-mid.json', + kind: 'file', + size: 100, + }, + { + path: '.agent/runs/2026-06-29-four.json', + kind: 'file', + size: 90, + }, + { + path: '.agent/runs/2026-06-28-five.json', + kind: 'file', + size: 80, + }, + { + path: '.agent/runs/2026-06-27-hidden.json', + kind: 'file', + size: 70, + }, + { + path: '.agent/runs/2026-06-26-hidden.json', + kind: 'file', + size: 60, + }, + ], + }; + } + if (command === 'read_local_project_file') { + const trace = traces.get(String(args?.relativePath ?? '')); + if (!trace) { + throw new Error( + `missing trace ${String(args?.relativePath ?? '')}`, + ); + } + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); + + const runHistory = await screen.findByLabelText('Agent run history'); + expect(screen.getByText(/run-new/)).not.toBeNull(); + expect( + screen.getByText(/passed \/ done · 2\/3 轮 · evaluator-passed/), + ).not.toBeNull(); + const oldRunButton = within(runHistory).getByText( + 'run-old · failed · 3/3 轮 · max-passes-exhausted · updated: 700 · .agent/runs/2026-07-01-old.json · 110B', + ); + expect(oldRunButton).not.toBeNull(); + expect(screen.getByText(/updated: 700/)).not.toBeNull(); + expect( + screen.getByText(/\.agent\/runs\/2026-07-01-old\.json/), + ).not.toBeNull(); + expect(screen.queryByText('还有 2 个历史 run')).toBeNull(); + expect(screen.getByText(/run-hidden/)).not.toBeNull(); + expect(runHistory.querySelector('button')?.textContent).toContain( + 'run-old', + ); + expect(runHistory.querySelector('[aria-current="true"]')).toBeNull(); + + fireEvent.click(oldRunButton.closest('button') as Element); + + expect( + await screen.findByText('run: failed · 3/3 轮 · max-passes-exhausted'), + ).not.toBeNull(); + expect(oldRunButton.closest('button')?.getAttribute('aria-current')).toBe( + 'true', + ); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/runs/2026-07-01-old.json', + commandId: 'agent.trace_read', + }); + }); + + it('uses run history for agent status cards when the latest trace is missing', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-history-only', + commandId: 'game.generate_draft', + status: 'running', + lifecycleStatus: 'running', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'planning', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner -> Generator', + steps: [ + { + pass: 1, + agent: 'Generator', + phase: 'generate', + taskId: 'code-prototype', + group: 'code', + role: 'Code', + status: 'running', + inputPaths: ['.agent/spec.md'], + outputPaths: ['game/index.html'], + summary: 'Generator 历史草案生成', + toolCalls: [], + }, + ], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: ['code-prototype'], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'generator', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { + path: '.agent/runs/run-history-only.json', + kind: 'file', + size: 120, + }, + ], + }; + } + if (command === 'read_local_project_file') { + const relativePath = String(args?.relativePath ?? ''); + if (relativePath === '.agent/run.latest.json') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + return { + path: relativePath, + absolutePath: `/tmp/authorized-game/${relativePath}`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); + + const runHistory = await screen.findByLabelText('Agent run history'); + expect(within(runHistory).getByText(/run-history-only/)).not.toBeNull(); + expect(screen.getByText('Generator 历史草案生成')).not.toBeNull(); + }); + + it('keeps run history out of the regular project chat window', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-hidden-from-chat', + commandId: 'game.generate_draft', + status: 'passed', + lifecycleStatus: 'done', + passes: 1, + maxPasses: 3, + toolCallCount: 0, + maxToolCalls: 128, + stopReason: 'evaluator-passed', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner -> Generator', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'preview', + error: null, + updatedAt: 1, + } satisfies GameCreationAgentRunTrace; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { + path: '.agent/runs/run-hidden-from-chat.json', + kind: 'file', + size: 100, + }, + ], + }; + } + if (command === 'read_local_project_file') { + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('list_local_project_files', { + projectPath: '/tmp/authorized-game', + }); + }); + expect(screen.queryByLabelText('最近 run')).toBeNull(); + expect(screen.queryByText(/run-hidden-from-chat/)).toBeNull(); + }); + + it('confirms before opening a run history trace when policy requires it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const makeTrace = (runId: string, status: string, updatedAt: number) => + ({ + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId, + commandId: 'game.generate_draft', + status, + lifecycleStatus: status === 'passed' ? 'done' : undefined, + passes: 1, + maxPasses: 3, + toolCallCount: 0, + maxToolCalls: 128, + stopReason: status === 'passed' ? 'evaluator-passed' : 'planning', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner -> Generator', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'preview', + error: null, + updatedAt, + }) satisfies GameCreationAgentRunTrace; + const traces = new Map([ + ['.agent/run.latest.json', makeTrace('run-current', 'running', 500)], + ['.agent/runs/2026-07-01-old.json', makeTrace('run-old', 'passed', 400)], + ]); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.trace_read'], + }, + }; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { + path: '.agent/runs/2026-07-01-old.json', + kind: 'file', + size: 110, + }, + ], + }; + } + if (command === 'read_local_project_file') { + const trace = traces.get(String(args?.relativePath ?? '')); + if (!trace) { + throw new Error( + `missing trace ${String(args?.relativePath ?? '')}`, + ); + } + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); + + expect(await screen.findByText(/run-old/)).not.toBeNull(); + invoke.mockClear(); + + fireEvent.click(screen.getByText(/run-old/).closest('button') as Element); + + expect(await screen.findByText('agent.trace_read')).not.toBeNull(); + expect( + screen.getByText( + '读取 /tmp/authorized-game 的 .agent/runs/2026-07-01-old.json', + ), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/runs/2026-07-01-old.json', + commandId: 'agent.trace_read', + }); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/runs/2026-07-01-old.json', + commandId: 'agent.trace_read', + }); + }); + }); + + it('shows more run history entries on demand', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const makeTrace = (runId: string, updatedAt: number) => + ({ + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId, + commandId: 'game.generate_draft', + status: 'passed', + lifecycleStatus: 'done', + passes: 1, + maxPasses: 3, + toolCallCount: 0, + maxToolCalls: 128, + stopReason: 'evaluator-passed', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner -> Generator', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'preview', + error: null, + updatedAt, + }) satisfies GameCreationAgentRunTrace; + const runFiles = Array.from({ length: 22 }, (_, index) => { + const number = String(index + 1).padStart(2, '0'); + return { + path: `.agent/runs/run-${number}.json`, + kind: 'file', + size: 100 + index, + }; + }); + const traces = new Map([ + ['.agent/run.latest.json', makeTrace('run-current', 1000)], + ...runFiles.map( + (file, index) => + [ + file.path, + makeTrace(`run-${String(index + 1).padStart(2, '0')}`, index + 1), + ] as const, + ), + ]); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: runFiles, + }; + } + if (command === 'read_local_project_file') { + const trace = traces.get(String(args?.relativePath ?? '')); + if (!trace) { + throw new Error( + `missing trace ${String(args?.relativePath ?? '')}`, + ); + } + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); + + const runHistory = await screen.findByLabelText('Agent run history'); + expect(screen.getByText(/run-03/)).not.toBeNull(); + expect(screen.queryByText(/run-02/)).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/runs/run-02.json', + }); + expect( + screen.getByRole('button', { + name: '显示更多 · 还有 2 个历史 run', + }), + ).not.toBeNull(); + fireEvent.scroll(runHistory); + + expect(await screen.findByText(/run-02/)).not.toBeNull(); + expect(screen.getByText(/run-01/)).not.toBeNull(); + expect(runHistory.querySelector('button')?.textContent).toContain('run-22'); + expect( + screen.queryByRole('button', { + name: /显示更多/, + }), + ).toBeNull(); + }); + + it('loads the first run history page by file modified time before reading traces', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const makeTrace = (runId: string, updatedAt: number) => + ({ + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId, + commandId: 'game.generate_draft', + status: 'passed', + lifecycleStatus: 'done', + passes: 1, + maxPasses: 3, + toolCallCount: 0, + maxToolCalls: 128, + stopReason: 'evaluator-passed', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner -> Generator', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'preview', + error: null, + updatedAt, + }) satisfies GameCreationAgentRunTrace; + const runFiles = [ + { + path: '.agent/runs/000-latest.json', + kind: 'file', + size: 200, + modifiedAt: 10_000, + }, + ...Array.from({ length: 20 }, (_, index) => { + const number = String(index + 1).padStart(2, '0'); + return { + path: `.agent/runs/z-${number}.json`, + kind: 'file', + size: 100 + index, + modifiedAt: index + 1, + }; + }), + ]; + const traces = new Map([ + ['.agent/run.latest.json', makeTrace('run-current', 20_000)], + ['.agent/runs/000-latest.json', makeTrace('run-latest', 10_000)], + ...runFiles + .slice(1) + .map( + (file, index) => + [ + file.path, + makeTrace(`run-${String(index + 1).padStart(2, '0')}`, index + 1), + ] as const, + ), + ]); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: runFiles, + }; + } + if (command === 'read_local_project_file') { + const trace = traces.get(String(args?.relativePath ?? '')); + if (!trace) { + throw new Error( + `missing trace ${String(args?.relativePath ?? '')}`, + ); + } + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByLabelText('Agent run history'); + + expect(screen.getByText(/run-latest/)).not.toBeNull(); + expect(screen.queryByText(/run-01/)).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/runs/z-01.json', + }); + }); + + it('loads project conversation history in the main project window', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '历史需求:做一个厨房弹幕游戏', + agentId: null, + updatedAt: 1, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '历史回复:已生成第一版', + agentId: null, + updatedAt: 2, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error('missing trace'); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + expect( + await screen.findByText('历史需求:做一个厨房弹幕游戏'), + ).not.toBeNull(); + expect(screen.getByText('历史回复:已生成第一版')).not.toBeNull(); + expect(screen.queryByLabelText('工作区管理')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: null, + }); + expect(window.localStorage.length).toBe(1); + expect( + window.localStorage.getItem(window.localStorage.key(0) ?? ''), + ).toContain('/tmp/authorized-game'); + }); + + it('loads project conversation history after opening from chat command', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '历史需求:保留弹幕厨房', + agentId: null, + updatedAt: 1, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '历史回复:继续做第二版', + agentId: null, + updatedAt: 2, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error('missing trace'); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('历史需求:保留弹幕厨房')).not.toBeNull(); + expect(screen.getByText('历史回复:继续做第二版')).not.toBeNull(); + expect( + screen.queryByText('已设置本地项目:/tmp/authorized-game'), + ).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: null, + }); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.anything(), + ); + }); + + it('reloads project conversation history from chat on demand', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '重载历史需求', + agentId: null, + updatedAt: 1, + }, + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '重载历史回复', + agentId: null, + updatedAt: 2, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error('missing trace'); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect(await screen.findByText('重载历史需求')).not.toBeNull(); + + submitChat('临时未保存的输入'); + expect(await screen.findByText('临时未保存的输入')).not.toBeNull(); + submitChat('/history'); + + expect(await screen.findByText('重载历史回复')).not.toBeNull(); + expect(screen.queryByText('临时未保存的输入')).toBeNull(); + expect(screen.getByText('已读取项目对话历史:2 条')).not.toBeNull(); + + submitChat('另一条临时未保存的输入'); + expect(await screen.findByText('另一条临时未保存的输入')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '历史' })); + + expect(await screen.findByText('重载历史回复')).not.toBeNull(); + expect(screen.queryByText('另一条临时未保存的输入')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: null, + }); + }); + + it('does not persist the transient project open status while history is loading', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let finishConversationRead: + | ((value: { path: string; agentId: null; messages: [] }) => void) + | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return await new Promise((resolve) => { + finishConversationRead = resolve as typeof finishConversationRead; + }); + } + if (command === 'read_local_project_file') { + throw new Error('missing trace'); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', { + projectPath: '/tmp/authorized-game', + }); + }); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.objectContaining({ + message: expect.objectContaining({ + content: '已设置本地项目:/tmp/authorized-game', + }), + }), + ); + + await act(async () => { + finishConversationRead?.({ + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }); + }); + + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.objectContaining({ + message: expect.objectContaining({ + content: '已设置本地项目:/tmp/authorized-game', + }), + }), + ); + }); + + it('confirms before loading project conversation history when policy requires it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['conversation.read'], + }, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '受保护历史需求', + agentId: null, + updatedAt: 1, + }, + ], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + expect(await screen.findByText('想做什么游戏?')).not.toBeNull(); + expect(screen.queryByText('受保护历史需求')).toBeNull(); + expect(await screen.findByText('conversation.read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: null, + }); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('受保护历史需求')).not.toBeNull(); + expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: null, + }); + }); + + it('keeps the project chat usable after cancelling conversation history read', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['conversation.read'], + }, + }; + } + if (command === 'read_local_conversation') { + throw new Error('should wait for conversation confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + expect(await screen.findByText('想做什么游戏?')).not.toBeNull(); + const conversationReadCommand = + await screen.findByText('conversation.read'); + fireEvent.click( + within( + conversationReadCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + expect(await screen.findByText('已取消读取项目对话')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: null, + }); + }); + + it('reports conversation history read failure after confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['conversation.read'], + }, + }; + } + if (command === 'read_local_conversation') { + throw new Error('conversation read failed'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + expect(await screen.findByText('想做什么游戏?')).not.toBeNull(); + expect(await screen.findByText('conversation.read')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('项目对话读取失败:conversation read failed'), + ).not.toBeNull(); + expect(screen.getByText('想做什么游戏?')).not.toBeNull(); + }); + + it('shows project conversation history in recent batches', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const messages = Array.from({ length: 25 }, (_, index) => ({ + schemaVersion: 'game-creator-conversation.v1', + role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), + content: `历史对话 ${String(index + 1).padStart(2, '0')}`, + agentId: null, + updatedAt: index + 1, + })); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages, + }; + } + if (command === 'read_local_project_file') { + throw new Error('missing trace'); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + expect(await screen.findByText('历史对话 06')).not.toBeNull(); + expect(screen.getByText('历史对话 25')).not.toBeNull(); + expect(screen.queryByText('历史对话 05')).toBeNull(); + + fireEvent.click( + screen.getByRole('button', { + name: '显示更早 · 还有 5 条对话', + }), + ); + + expect(screen.getByText('历史对话 01')).not.toBeNull(); + expect(screen.getByText('历史对话 05')).not.toBeNull(); + expect(screen.queryByRole('button', { name: /显示更早/ })).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.anything(), + ); + }); + + it('retries unsaved project chat messages after conversation persistence fails', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let appendAttempts = 0; + let releaseRetryAppend: (() => void) | null = null; + const savedContents: string[] = []; + const makeConversationResult = () => ({ + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + if (command === 'append_local_conversation_message') { + appendAttempts += 1; + const message = args?.message as { content: string }; + if (appendAttempts === 1) { + throw new Error('conversation append failed once'); + } + if (appendAttempts === 2) { + return await new Promise((resolve) => { + releaseRetryAppend = () => { + savedContents.push(message.content); + resolve(makeConversationResult()); + }; + }); + } + savedContents.push(message.content); + return makeConversationResult(); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + submitChat('第一条创作需求'); + await waitFor(() => { + expect(appendAttempts).toBe(1); + }); + expect( + await screen.findByText( + '项目对话保存失败:conversation append failed once', + ), + ).not.toBeNull(); + + submitChat('第二条创作需求'); + await waitFor(() => { + expect(releaseRetryAppend).not.toBeNull(); + }); + await act(async () => { + releaseRetryAppend?.(); + }); + + await waitFor(() => { + expect(savedContents).toEqual([ + '第一条创作需求', + '准备生成本地游戏草案:第一条创作需求', + '第二条创作需求', + '准备生成本地游戏草案:第二条创作需求', + ]); + }); + expect( + screen.queryByText('项目对话保存失败:conversation append failed once'), + ).toBeNull(); + expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull(); + }); + + it('confirms before saving project conversation when policy requires it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const savedContents: string[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['conversation.write'], + }, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error('missing trace'); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + if (command === 'append_local_conversation_message') { + savedContents.push( + String((args?.message as { content?: unknown })?.content ?? ''), + ); + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + invoke.mockClear(); + submitChat('需要确认保存的需求'); + + const conversationWriteCommand = + await screen.findByText('conversation.write'); + expect(await screen.findByText('等待确认保存项目对话')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.anything(), + ); + + fireEvent.click( + within( + conversationWriteCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '确认' }), + ); + + await waitFor(() => { + expect(savedContents).toEqual([ + '需要确认保存的需求', + '准备生成本地游戏草案:需要确认保存的需求', + ]); + }); + }); + + it('does not immediately re-prompt after cancelling project conversation save', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const savedContents: string[] = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['conversation.write'], + }, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error('missing trace'); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + if (command === 'append_local_conversation_message') { + savedContents.push( + String((args?.message as { content?: unknown })?.content ?? ''), + ); + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + invoke.mockClear(); + submitChat('先不保存的需求'); + + const firstPrompt = await screen.findByText('conversation.write'); + fireEvent.click( + within(firstPrompt.closest('.pending-command') as HTMLElement).getByRole( + 'button', + { name: '取消' }, + ), + ); + + await waitFor(() => { + expect(screen.queryByText('conversation.write')).toBeNull(); + }); + expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.anything(), + ); + + submitChat('继续补充一条'); + + const secondPrompt = await screen.findByText('conversation.write'); + fireEvent.click( + within(secondPrompt.closest('.pending-command') as HTMLElement).getByRole( + 'button', + { name: '确认' }, + ), + ); + + await waitFor(() => { + expect(savedContents).toEqual([ + '先不保存的需求', + '准备生成本地游戏草案:先不保存的需求', + '继续补充一条', + '准备生成本地游戏草案:继续补充一条', + ]); + }); + }); + + it('persists project chat messages submitted while a previous write is still running', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const savedContents: string[] = []; + let releaseFirstAppend: (() => void) | null = null; + const makeConversationResult = () => ({ + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return makeConversationResult(); + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as { content: string }; + if (savedContents.length === 0 && !releaseFirstAppend) { + return await new Promise((resolve) => { + releaseFirstAppend = () => { + savedContents.push(message.content); + resolve(makeConversationResult()); + }; + }); + } + savedContents.push(message.content); + return makeConversationResult(); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + submitChat('第一条创作需求'); + await waitFor(() => { + expect(releaseFirstAppend).not.toBeNull(); + }); + submitChat('第二条创作需求'); + + releaseFirstAppend?.(); + + await waitFor(() => { + expect(savedContents).toEqual([ + '第一条创作需求', + '准备生成本地游戏草案:第一条创作需求', + '第二条创作需求', + '准备生成本地游戏草案:第二条创作需求', + ]); + }); + }); + + it('opens a specific agent conversation and persists messages to that agent', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const agentMessages: Array<{ + schemaVersion: string; + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + updatedAt: number; + }> = []; + let agentConversationReadCount = 0; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + if (args?.agentId) { + agentConversationReadCount += 1; + } + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: [...agentMessages], + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '# 策划 Director 私有记忆\n- 保留轻量像素风\n', + exists: true, + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: '/tmp/authorized-game/.agent/run.latest.json', + content: JSON.stringify({ + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-agent-dialog-evidence', + commandId: 'game.generate_draft', + status: 'running', + lifecycleStatus: 'pending', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'running', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner', + steps: [ + { + pass: 1, + agent: 'Planner', + phase: 'plan', + taskId: 'design-director', + group: 'design', + role: 'Director', + status: 'running', + inputPaths: ['memory/session.md'], + outputPaths: ['.agent/spec.md'], + summary: '正在拆解创作方向', + toolCalls: [ + { + toolId: 'llm.planner', + status: 'ok', + inputPaths: ['memory/session.md'], + outputPaths: ['.agent/spec.md'], + summary: 'Planner 已读取短期记忆', + }, + { + toolId: 'agent.tool.suggest.canvas.project_sync', + status: 'suggested', + inputPaths: ['assets/manifest.art.json'], + outputPaths: [], + summary: '建议用户确认 /sync-canvas-project <画板项目ID>', + }, + ], + }, + ], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: ['design-director'], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'continue', + error: null, + updatedAt: 1, + } satisfies GameCreationAgentRunTrace), + }; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { + path: '.agent/runs/run-agent-dialog-evidence.json', + kind: 'file', + size: 1, + }, + ], + }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as { + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + }; + if (args?.agentId) { + agentMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + role: message.role, + content: message.content, + agentId: String(args.agentId), + updatedAt: agentMessages.length + 1, + }); + } + return { + path: args?.agentId + ? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl' + : '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: args?.agentId ?? null, + messages: args?.agentId ? [...agentMessages] : [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开:/tmp/authorized-game'), + ).not.toBeNull(); + + expect(await screen.findByText('正在拆解创作方向')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + const agentDialog = await screen.findByLabelText('Agent 对话'); + expect(agentDialog).not.toBeNull(); + expect(agentDialog.textContent).toContain('正在拆解创作方向'); + expect(agentDialog.textContent).toContain('pass 1 · plan'); + expect(agentDialog.textContent).toContain('run: pending'); + expect(agentDialog.textContent).toContain('编排:本轮 active'); + expect(agentDialog.textContent).toContain( + '已读取 0 条:/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + ); + + const input = screen.getByLabelText('Agent 对话内容'); + fireEvent.change(input, { target: { value: '优先保留轻量像素风' } }); + fireEvent.submit(input.closest('form') as HTMLFormElement); + + expect( + await screen.findByText( + '已保存 2 条:/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + ), + ).not.toBeNull(); + expect( + screen.getByText( + '已记录给 拆解创作方向。下一次生成会把这条对话作为该 agent 的上下文读取。', + ), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + }); + expect(screen.getByLabelText('Agent 私有记忆').textContent).toContain( + '保留轻量像素风', + ); + expect(screen.getByLabelText('Agent 最近证据').textContent).toContain( + 'in: memory/session.md', + ); + expect(screen.getByLabelText('Agent 最近证据').textContent).toContain( + 'out: .agent/spec.md', + ); + expect(screen.getByLabelText('Agent 最近证据').textContent).toContain( + 'tool: llm.planner · ok · Planner 已读取短期记忆', + ); + expect( + screen.getByRole('button', { name: '填入读取 memory/session.md' }), + ).not.toBeNull(); + expect(screen.getByRole('button', { name: '填入同步命令' })).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_agent_memory', { + projectPath: '/tmp/authorized-game', + taskId: 'design-director', + }); + expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + message: { + role: 'user', + content: '优先保留轻量像素风', + agentId: null, + }, + }); + expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + message: { + role: 'assistant', + content: + '已记录给 拆解创作方向。下一次生成会把这条对话作为该 agent 的上下文读取。', + agentId: null, + }, + }); + agentMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '刷新后外部记录', + agentId: 'design-director', + updatedAt: 2, + }); + fireEvent.click(within(agentDialog).getByRole('button', { name: '刷新' })); + expect(await screen.findByText('刷新后外部记录')).not.toBeNull(); + expect(agentConversationReadCount).toBeGreaterThanOrEqual(2); + fireEvent.click(screen.getByRole('button', { name: '填入同步命令' })); + expect(screen.queryByLabelText('Agent 对话')).toBeNull(); + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/sync-canvas-project ', + ); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + const reopenedAgentDialog = await screen.findByLabelText('Agent 对话'); + fireEvent.click( + within(reopenedAgentDialog).getByRole('button', { + name: '填入读取 .agent/spec.md', + }), + ); + expect(screen.queryByLabelText('Agent 对话')).toBeNull(); + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/read .agent/spec.md', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'sync_canvas_project_assets', + expect.anything(), + ); + }); + + it('requires confirmation before reading a specific agent conversation when policy asks for it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['conversation.read'], + }, + }; + } + if (command === 'read_local_conversation') { + return { + path: args?.agentId + ? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl' + : '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: args?.agentId ?? null, + messages: [], + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '', + exists: false, + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + invoke.mockClear(); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + + expect(await screen.findByText('准备读取 Agent 对话。')).not.toBeNull(); + expect(screen.getByText('conversation.read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + }); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + '已读取 0 条:/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + ), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + }); + }); + + it('leaves the agent conversation panel usable after cancelling read confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['conversation.read'], + }, + }; + } + if (command === 'read_local_conversation') { + return { + path: args?.agentId + ? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl' + : '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: args?.agentId ?? null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + invoke.mockClear(); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + + const conversationReadCommand = + await screen.findByText('conversation.read'); + fireEvent.click( + within( + conversationReadCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + await waitFor(() => { + expect(screen.queryByText('conversation.read')).toBeNull(); + }); + expect(screen.getAllByText('已取消读取 Agent 对话').length).toBeGreaterThan( + 0, + ); + expect(screen.getByText('暂无对话')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + }); + }); + + it('keeps loaded agent conversation after cancelling private memory read confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['memory.read'], + }, + }; + } + if (command === 'read_local_conversation') { + return { + path: args?.agentId + ? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl' + : '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: args?.agentId ?? null, + messages: args?.agentId + ? [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'assistant', + content: '已读到 Agent 对话', + agentId: 'design-director', + updatedAt: 1, + }, + ] + : [], + }; + } + if (command === 'read_local_agent_memory') { + throw new Error('should wait for memory confirmation'); + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + invoke.mockClear(); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + + expect(await screen.findByText('已读到 Agent 对话')).not.toBeNull(); + const memoryReadCommand = await screen.findByText('memory.read'); + fireEvent.click( + within( + memoryReadCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + await waitFor(() => { + expect(screen.queryByText('memory.read')).toBeNull(); + }); + expect(screen.getByText('已读到 Agent 对话')).not.toBeNull(); + expect(screen.getByLabelText('Agent 私有记忆').textContent).toContain( + '已取消读取 Agent 私有记忆', + ); + expect(invoke).not.toHaveBeenCalledWith('read_local_agent_memory', { + projectPath: '/tmp/authorized-game', + taskId: 'design-director', + }); + }); + + it('requires confirmation before writing a specific agent conversation when policy asks for it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const agentMessages: Array<{ + schemaVersion: string; + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + updatedAt: number; + }> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['conversation.write'], + }, + }; + } + if (command === 'read_local_conversation') { + return { + path: args?.agentId + ? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl' + : '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: args?.agentId ?? null, + messages: args?.agentId ? agentMessages : [], + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '', + exists: false, + }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as { + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + }; + if (args?.agentId) { + agentMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + role: message.role, + content: message.content, + agentId: message.agentId, + updatedAt: agentMessages.length + 1, + }); + } + return { + path: args?.agentId + ? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl' + : '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: args?.agentId ?? null, + messages: args?.agentId ? agentMessages : [], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('已打开:/tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + const input = await screen.findByLabelText('Agent 对话内容'); + invoke.mockClear(); + fireEvent.change(input, { target: { value: '先记住这个方向' } }); + fireEvent.submit(input.closest('form') as HTMLFormElement); + + expect(await screen.findByText('准备保存 Agent 对话。')).not.toBeNull(); + expect(screen.getByText('conversation.write')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.objectContaining({ agentId: 'design-director' }), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/已保存 2 条/)).not.toBeNull(); + expect(screen.getByText('先记住这个方向')).not.toBeNull(); + expect( + screen.getByText( + '已记录给 拆解创作方向。下一次生成会把这条对话作为该 agent 的上下文读取。', + ), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + message: { + role: 'user', + content: '先记住这个方向', + agentId: null, + }, + }); + expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + message: { + role: 'assistant', + content: + '已记录给 拆解创作方向。下一次生成会把这条对话作为该 agent 的上下文读取。', + agentId: null, + }, + }); + }); + + it('shows agent conversation history in recent batches', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const agentMessages = Array.from({ length: 25 }, (_, index) => ({ + schemaVersion: 'game-creator-conversation.v1', + role: index % 2 === 0 ? ('user' as const) : ('assistant' as const), + content: `Agent 历史 ${String(index + 1).padStart(2, '0')}`, + agentId: 'design-director', + updatedAt: index + 1, + })); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: args?.agentId + ? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl' + : '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: args?.agentId ?? null, + messages: args?.agentId ? agentMessages : [], + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '', + exists: false, + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('/tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + + const agentDialog = await screen.findByLabelText('Agent 对话'); + expect(agentDialog.textContent).toContain('Agent 历史 06'); + expect(agentDialog.textContent).toContain('Agent 历史 25'); + expect(agentDialog.textContent).not.toContain('Agent 历史 05'); + + fireEvent.click( + screen.getByRole('button', { + name: '显示更早 · 还有 5 条对话', + }), + ); + + expect(agentDialog.textContent).toContain('Agent 历史 01'); + expect(agentDialog.textContent).toContain('Agent 历史 05'); + }); + + it('does not submit duplicate agent messages while a save is running', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const agentMessages: Array<{ + schemaVersion: string; + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + updatedAt: number; + }> = []; + let releaseUserAppend: (() => void) | null = null; + let userAppendCount = 0; + const makeAgentConversationResult = () => ({ + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: 'design-director', + messages: agentMessages, + }); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: args?.agentId + ? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl' + : '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: args?.agentId ?? null, + messages: [], + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '', + exists: false, + }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as { + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + }; + if (message.role === 'user') { + userAppendCount += 1; + return await new Promise((resolve) => { + releaseUserAppend = () => { + agentMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + role: message.role, + content: message.content, + agentId: message.agentId, + updatedAt: agentMessages.length + 1, + }); + resolve(makeAgentConversationResult()); + }; + }); + } + agentMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + role: message.role, + content: message.content, + agentId: message.agentId, + updatedAt: agentMessages.length + 1, + }); + return makeAgentConversationResult(); + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + const input = await screen.findByLabelText('Agent 对话内容'); + const form = input.closest('form') as HTMLFormElement; + fireEvent.change(input, { target: { value: '只保存一次' } }); + fireEvent.submit(form); + await screen.findByText('正在保存'); + + fireEvent.submit(form); + + expect((form.querySelector('button') as HTMLButtonElement).disabled).toBe( + true, + ); + expect(userAppendCount).toBe(1); + await act(async () => { + releaseUserAppend?.(); + }); + + expect(await screen.findByText(/已保存 2 条/)).not.toBeNull(); + expect(userAppendCount).toBe(1); + }); + + it('does not show unsaved agent messages when persistence fails', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: [], + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '', + exists: false, + }; + } + if (command === 'append_local_conversation_message') { + throw new Error('保存 Agent 对话失败'); + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + const input = await screen.findByLabelText('Agent 对话内容'); + fireEvent.change(input, { target: { value: '这条不应该显示成已保存' } }); + fireEvent.submit(input.closest('form') as HTMLFormElement); + + expect(await screen.findByText('保存 Agent 对话失败')).not.toBeNull(); + expect(screen.getByText('暂无对话')).not.toBeNull(); + expect(screen.queryByText('这条不应该显示成已保存')).toBeNull(); + expect(screen.getByLabelText('Agent 对话内容')).toHaveProperty( + 'value', + '这条不应该显示成已保存', + ); + }); + + it('keeps the saved user message visible when the local agent receipt fails', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const agentMessages: Array<{ + schemaVersion: string; + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + updatedAt: number; + }> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: agentMessages, + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '', + exists: false, + }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as { + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + }; + if (message.role === 'assistant') { + throw new Error('保存 Agent 回执失败'); + } + agentMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + role: message.role, + content: message.content, + agentId: message.agentId, + updatedAt: 1, + }); + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: agentMessages, + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + const input = await screen.findByLabelText('Agent 对话内容'); + fireEvent.change(input, { target: { value: '先保留这个方向' } }); + fireEvent.submit(input.closest('form') as HTMLFormElement); + + expect( + await screen.findByText(/已保存用户消息;Agent 回执失败/), + ).not.toBeNull(); + expect(screen.getByText('先保留这个方向')).not.toBeNull(); + expect( + screen.queryByText( + '已记录给 拆解创作方向。下一次生成会把这条对话作为该 agent 的上下文读取。', + ), + ).toBeNull(); + expect(screen.getByLabelText('Agent 对话内容')).toHaveProperty('value', ''); + }); + + it('reports Tauri availability when saving an agent conversation without invoke', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: [], + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '', + exists: false, + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + const input = await screen.findByLabelText('Agent 对话内容'); + fireEvent.change(input, { target: { value: '确认运行环境提示' } }); + delete window.__TAURI__; + fireEvent.submit(input.closest('form') as HTMLFormElement); + + expect(await screen.findByText('需要在 Tauri App 内运行')).not.toBeNull(); + expect(screen.queryByText('请先初始化本地项目')).toBeNull(); + }); + + it('keeps a saved agent user message visible after saving', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const savedAgentMessages: Array<{ + schemaVersion: string; + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + updatedAt: number; + }> = []; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: savedAgentMessages, + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '', + exists: false, + }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as { + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + }; + savedAgentMessages.push({ + schemaVersion: 'game-creator-conversation.v1', + role: message.role, + content: message.content, + agentId: message.agentId, + updatedAt: 1, + }); + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: savedAgentMessages, + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + const input = await screen.findByLabelText('Agent 对话内容'); + fireEvent.change(input, { target: { value: '先记住这个方向' } }); + fireEvent.submit(input.closest('form') as HTMLFormElement); + + expect(await screen.findByText(/已保存 2 条/)).not.toBeNull(); + expect(screen.getByText('先记住这个方向')).not.toBeNull(); + expect( + screen.getByText( + '已记录给 拆解创作方向。下一次生成会把这条对话作为该 agent 的上下文读取。', + ), + ).not.toBeNull(); + expect(screen.getByLabelText('Agent 对话内容')).toHaveProperty('value', ''); + }); + + it('clears stale agent messages when another agent conversation fails to load', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + if (args?.agentId === null) { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (args?.agentId === 'design-director') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: 'design-director', + messages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '旧 Agent 历史消息', + agentId: 'design-director', + updatedAt: 1, + }, + ], + }; + } + throw new Error('读取 Agent 对话失败'); + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: `/tmp/authorized-game/memory/agents/${String( + args?.taskId ?? '', + )}.md`, + content: '', + exists: false, + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + expect(await screen.findByText('旧 Agent 历史消息')).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '关闭' })); + fireEvent.click(screen.getByRole('button', { name: /确定视觉方向/ })); + + expect(await screen.findByText('读取 Agent 对话失败')).not.toBeNull(); + expect(screen.getByText('暂无对话')).not.toBeNull(); + expect(screen.queryByText('旧 Agent 历史消息')).toBeNull(); + }); + + it('ignores stale agent conversation reads after switching agents', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let releaseOldConversation: (() => void) | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + if (args?.agentId === null) { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (args?.agentId === 'design-director') { + return await new Promise((resolve) => { + releaseOldConversation = () => + resolve({ + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: 'design-director', + messages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '旧 Agent 慢速消息', + agentId: 'design-director', + updatedAt: 1, + }, + ], + }); + }); + } + return { + path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl', + agentId: args?.agentId, + messages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '新 Agent 历史消息', + agentId: String(args?.agentId ?? ''), + updatedAt: 2, + }, + ], + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: `/tmp/authorized-game/memory/agents/${String( + args?.taskId ?? '', + )}.md`, + content: + args?.taskId === 'art-director' + ? '新 Agent 私有记忆' + : '旧 Agent 私有记忆', + exists: true, + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + await waitFor(() => { + expect(releaseOldConversation).not.toBeNull(); + }); + fireEvent.click(screen.getByRole('button', { name: /确定视觉方向/ })); + + expect(await screen.findByText('新 Agent 历史消息')).not.toBeNull(); + expect(screen.getByLabelText('Agent 私有记忆').textContent).toContain( + '新 Agent 私有记忆', + ); + await act(async () => { + releaseOldConversation?.(); + }); + + expect(screen.queryByText('旧 Agent 慢速消息')).toBeNull(); + expect(screen.getByText('新 Agent 历史消息')).not.toBeNull(); + expect(screen.getByLabelText('Agent 私有记忆').textContent).not.toContain( + '旧 Agent 私有记忆', + ); + }); + + it('ignores stale agent conversation saves after switching agents', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let releaseOldSave: (() => void) | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + if (args?.agentId === null) { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (args?.agentId === 'art-director') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl', + agentId: 'art-director', + messages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '新 Agent 留存消息', + agentId: 'art-director', + updatedAt: 2, + }, + ], + }; + } + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: 'design-director', + messages: [], + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: `/tmp/authorized-game/memory/agents/${String( + args?.taskId ?? '', + )}.md`, + content: '', + exists: false, + }; + } + if (command === 'append_local_conversation_message') { + const message = args?.message as { + role: 'user' | 'assistant'; + content: string; + agentId: string | null; + }; + return await new Promise((resolve) => { + releaseOldSave = () => + resolve({ + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: 'design-director', + messages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: message.role, + content: message.content, + agentId: message.agentId, + updatedAt: 1, + }, + ], + }); + }); + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + const input = await screen.findByLabelText('Agent 对话内容'); + fireEvent.change(input, { target: { value: '旧 Agent 保存回包' } }); + fireEvent.submit(input.closest('form') as HTMLFormElement); + await screen.findByText('正在保存'); + fireEvent.click(screen.getByRole('button', { name: /确定视觉方向/ })); + + expect(await screen.findByText('新 Agent 留存消息')).not.toBeNull(); + await act(async () => { + releaseOldSave?.(); + }); + + expect(screen.getByText('新 Agent 留存消息')).not.toBeNull(); + expect(screen.queryByText('旧 Agent 保存回包')).toBeNull(); + expect(screen.queryByText(/已保存 1 条/)).toBeNull(); + }); + + it('ignores stale agent reads after closing the agent dialog', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let releaseConversation: (() => void) | null = null; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_conversation') { + if (args?.agentId === null) { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + return await new Promise((resolve) => { + releaseConversation = () => + resolve({ + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: 'design-director', + messages: [ + { + schemaVersion: 'game-creator-conversation.v1', + role: 'user', + content: '关闭后不该写入界面状态', + agentId: 'design-director', + updatedAt: 1, + }, + ], + }); + }); + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '关闭后不该读取私有记忆', + exists: true, + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + await waitFor(() => { + expect( + ( + screen.getByRole('button', { + name: '刷新 Agent', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + }); + + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + await waitFor(() => { + expect(releaseConversation).not.toBeNull(); + }); + fireEvent.click(screen.getByRole('button', { name: '关闭' })); + expect(screen.queryByLabelText('Agent 对话')).toBeNull(); + + await act(async () => { + releaseConversation?.(); + }); + + expect(screen.queryByText('关闭后不该写入界面状态')).toBeNull(); + expect(screen.queryByText('conversation.read')).toBeNull(); + expect(screen.queryByText('memory.agent.read')).toBeNull(); + }); + + it('updates the open agent dialog when agent status is refreshed', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let runReadCount = 0; + const makeTrace = (withAgentStep: boolean) => + ({ + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-open-agent-refresh', + commandId: 'game.generate_draft', + status: 'running', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'running', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner', + steps: withAgentStep + ? [ + { + pass: 1, + agent: 'Planner', + phase: 'plan', + taskId: 'design-director', + group: 'design', + role: 'Director', + status: 'running', + inputPaths: ['memory/session.md'], + outputPaths: ['.agent/spec.md'], + summary: '刷新后的拆解方向', + toolCalls: [ + { + toolId: 'llm.planner.refresh', + status: 'ok', + inputPaths: ['memory/session.md'], + outputPaths: ['.agent/spec.md'], + summary: '刷新后工具调用', + }, + ...Array.from({ length: 5 }, (_, index) => ({ + toolId: `llm.extra.${index + 1}`, + status: 'ok', + inputPaths: [], + outputPaths: [], + summary: `额外工具调用 ${index + 1}`, + })), + ], + }, + ] + : [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: withAgentStep ? ['design-director'] : [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'continue', + error: null, + updatedAt: runReadCount, + }) satisfies GameCreationAgentRunTrace; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: args?.agentId + ? '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl' + : '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: args?.agentId ?? null, + messages: [], + }; + } + if (command === 'read_local_agent_memory') { + return { + taskId: args?.taskId, + path: '/tmp/authorized-game/memory/agents/design/director.md', + content: '', + exists: false, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_project_file') { + runReadCount += 1; + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: JSON.stringify(makeTrace(runReadCount > 1)), + }; + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('已打开:/tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: /拆解创作方向/ })); + expect(await screen.findByLabelText('Agent 对话')).not.toBeNull(); + expect(screen.getByLabelText('Agent 最近证据').textContent).toContain( + '暂无最近运行证据', + ); + + fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' })); + + expect(await screen.findByText('刷新后的拆解方向')).not.toBeNull(); + await waitFor(() => { + expect(screen.getByLabelText('Agent 对话').textContent).toContain( + '刷新后的拆解方向', + ); + }); + expect(screen.getByLabelText('Agent 最近证据').textContent).toContain( + 'in: memory/session.md', + ); + expect(screen.getByLabelText('Agent 最近证据').textContent).toContain( + 'out: .agent/spec.md', + ); + expect(screen.getByLabelText('Agent 最近证据').textContent).toContain( + 'tool: llm.planner.refresh · ok · 刷新后工具调用', + ); + expect(screen.getByLabelText('Agent 最近证据').textContent).toContain( + 'tool: llm.planner.refresh · ok · 刷新后工具调用 · in memory/session.md · out .agent/spec.md', + ); + expect(screen.getByLabelText('Agent 最近证据').textContent).toContain( + '还有 1 个工具调用', + ); + }); + + it('confirms before refreshing agents when trace read policy requires it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-agent-refresh-confirm', + commandId: 'game.generate_draft', + status: 'running', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'running', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'continue', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.trace_read'], + }, + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(trace), + }; + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + expect(await screen.findByText('想做什么游戏?')).not.toBeNull(); + invoke.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' })); + + expect(await screen.findByText('agent.trace_read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + }); + }); + + it('cancels agent run trace refresh policy confirmation from the panel', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.trace_read'], + }, + }; + } + if (command === 'read_local_project_file') { + throw new Error('should wait for trace confirmation'); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + expect(await screen.findByText('想做什么游戏?')).not.toBeNull(); + invoke.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: '刷新 Agent' })); + + const traceReadCommand = await screen.findByText('agent.trace_read'); + fireEvent.click( + within( + traceReadCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + expect( + await screen.findByText('run: 已取消读取 Agent trace'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.anything(), + ); + }); + it('edits the published runtime config without leaking API keys into chat', async () => { const invoke = vi.fn( async (command: string, args?: Record) => { @@ -70,6 +6126,22 @@ describe('AI 游戏创作 App 界面边界', () => { maxRetries: 0, retryBackoffMs: 500, }, + agentLlm: { + planner: { + apiKey: 'planner-loaded-secret', + baseUrl: 'https://api.anthropic.com', + model: 'claude-3-5-sonnet-latest', + apiKind: 'anthropic', + stream: false, + }, + 'art-asset-plan': { + apiKey: 'art-loaded-secret', + baseUrl: 'https://api.deepseek.com', + model: 'deepseek-chat', + apiKind: 'openai_chat', + stream: true, + }, + }, editorApi: { baseUrl: 'http://127.0.0.1:8082', apiKey: 'editor-loaded-secret', @@ -98,6 +6170,53 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.getByLabelText('聊天').textContent).not.toContain( 'unit-loaded-secret-value', ); + expect(screen.getByLabelText('聊天').textContent).not.toContain( + 'planner-loaded-secret', + ); + expect(screen.getByLabelText('LLM API Key')).toHaveProperty( + 'type', + 'password', + ); + expect( + screen.getByLabelText('LLM API Key').getAttribute('autocomplete'), + ).toBe('off'); + expect(screen.getByLabelText('画板 API Key')).toHaveProperty( + 'type', + 'password', + ); + expect( + screen.getByLabelText('画板 API Key').getAttribute('autocomplete'), + ).toBe('off'); + expect(screen.getByLabelText('Planner LLM API Key')).toHaveProperty( + 'type', + 'password', + ); + expect( + screen.getByLabelText('Planner LLM API Key').getAttribute('autocomplete'), + ).toBe('off'); + expect( + screen + .getByLabelText('规划美术资产 (art/Asset) LLM API Key') + .getAttribute('autocomplete'), + ).toBe('off'); + expect(screen.getByLabelText('Planner LLM Provider')).toHaveProperty( + 'value', + 'anthropic', + ); + expect(screen.getByLabelText('Planner LLM 模型')).toHaveProperty( + 'value', + 'claude-3-5-sonnet-latest', + ); + expect(screen.getByLabelText('Planner LLM 流式请求')).toHaveProperty( + 'value', + 'false', + ); + expect( + screen.getByLabelText('规划美术资产 (art/Asset) LLM Provider'), + ).toHaveProperty('value', 'deepseek'); + expect( + screen.getByLabelText('规划美术资产 (art/Asset) LLM 流式请求'), + ).toHaveProperty('value', 'true'); fireEvent.change(screen.getByLabelText('LLM API Key'), { target: { value: 'unit-new-secret-value' }, @@ -121,6 +6240,21 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(screen.getByLabelText('LLM 退避 ms'), { target: { value: '800' }, }); + fireEvent.change(screen.getByLabelText('Generator LLM API Key'), { + target: { value: 'generator-new-secret' }, + }); + fireEvent.change(screen.getByLabelText('Generator LLM Provider'), { + target: { value: 'deepseek' }, + }); + fireEvent.change(screen.getByLabelText('Generator LLM 流式请求'), { + target: { value: 'true' }, + }); + fireEvent.change( + screen.getByLabelText('规划美术资产 (art/Asset) LLM Provider'), + { + target: { value: 'ark' }, + }, + ); fireEvent.change(screen.getByLabelText('画板 API Base URL'), { target: { value: 'http://127.0.0.1:8099' }, }); @@ -143,6 +6277,29 @@ describe('AI 游戏创作 App 界面边界', () => { maxRetries: 3, retryBackoffMs: 800, }, + agentLlm: { + planner: { + apiKey: 'planner-loaded-secret', + baseUrl: 'https://api.anthropic.com', + model: 'claude-3-5-sonnet-latest', + apiKind: 'anthropic', + stream: false, + }, + generator: { + apiKey: 'generator-new-secret', + baseUrl: 'https://api.deepseek.com', + model: 'deepseek-chat', + apiKind: 'openai_chat', + stream: true, + }, + 'art-asset-plan': { + apiKey: 'art-loaded-secret', + baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', + model: 'doubao-seed-1-6', + apiKind: 'openai_chat', + stream: true, + }, + }, editorApi: { baseUrl: 'http://127.0.0.1:8099', apiKey: 'editor-new-secret', @@ -152,6 +6309,72 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.getByLabelText('聊天').textContent).not.toContain( 'unit-new-secret-value', ); + expect(screen.getByLabelText('聊天').textContent).not.toContain( + 'generator-new-secret', + ); + + fireEvent.change(screen.getByLabelText('LLM 超时 ms'), { + target: { value: '' }, + }); + fireEvent.change(screen.getByLabelText('LLM 重试次数'), { + target: { value: '-2' }, + }); + fireEvent.change(screen.getByLabelText('LLM 退避 ms'), { + target: { value: '0' }, + }); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', { + config: expect.objectContaining({ + llm: expect.objectContaining({ + requestTimeoutMs: 1000, + maxRetries: 0, + retryBackoffMs: 1, + }), + }), + }); + }); + + fireEvent.click(screen.getByRole('button', { name: '恢复默认' })); + + expect(screen.getByText('已恢复默认配置,保存后生效')).not.toBeNull(); + expect(screen.getByLabelText('LLM API Key')).toHaveProperty('value', ''); + expect(screen.getByLabelText('LLM Base URL')).toHaveProperty( + 'value', + 'https://api.openai.com/v1', + ); + expect(screen.getByLabelText('LLM 模型')).toHaveProperty( + 'value', + 'gpt-4.1', + ); + expect(screen.getByLabelText('画板 API Base URL')).toHaveProperty( + 'value', + 'http://127.0.0.1:8082', + ); + fireEvent.click(screen.getByRole('button', { name: '保存' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_game_creator_app_config', { + config: { + llm: { + apiKey: '', + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + stream: false, + requestTimeoutMs: 180000, + maxRetries: 0, + retryBackoffMs: 500, + }, + agentLlm: {}, + editorApi: { + baseUrl: 'http://127.0.0.1:8082', + apiKey: '', + }, + }, + }); + }); }); it('keeps multiline chat evidence readable', () => { @@ -175,25 +6398,754 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.getByText('预览')).not.toBeNull(); }); + it('writes project files from the developer file panel', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'write_local_project_file') { + return { + path: String(args?.relativePath ?? ''), + absolutePath: `/tmp/genarrative-ai-game-draft/${String(args?.relativePath ?? '')}`, + deleted: false, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const confirm = vi.spyOn(window, 'confirm'); + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('项目文件路径'), { + target: { value: 'game/debug-note.txt' }, + }); + fireEvent.change(screen.getByLabelText('项目文件内容'), { + target: { value: 'hello file panel' }, + }); + fireEvent.click( + within(screen.getByLabelText('项目文件')).getByRole('button', { + name: '保存', + }), + ); + + expect(screen.getByText('file.write')).not.toBeNull(); + expect( + screen.getByText( + '保存 /tmp/genarrative-ai-game-draft/game/debug-note.txt', + ), + ).not.toBeNull(); + expect(confirm).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('已保存:game/debug-note.txt'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('write_local_project_file', { + projectPath: '/tmp/genarrative-ai-game-draft', + relativePath: 'game/debug-note.txt', + content: 'hello file panel', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/genarrative-ai-game-draft', + event: 'permission.pending', + commandId: 'file.write', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/genarrative-ai-game-draft', + event: 'permission.confirm', + commandId: 'file.write', + }); + }); + + it('blocks developer file write confirmation when project policy denies it', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: ['file.write'], + confirmCommands: [], + }, + }; + } + if (command === 'write_local_project_file') { + throw new Error('should not write file after deny'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('项目文件路径'), { + target: { value: 'game/debug-note.txt' }, + }); + fireEvent.change(screen.getByLabelText('项目文件内容'), { + target: { value: 'should not save' }, + }); + fireEvent.click( + within(screen.getByLabelText('项目文件')).getByRole('button', { + name: '保存', + }), + ); + + expect(screen.getByText('file.write')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect( + within(screen.getByLabelText('项目文件')).getByText( + '项目权限策略拒绝执行:file.write', + ), + ).not.toBeNull(); + }); + expect(invoke).not.toHaveBeenCalledWith( + 'write_local_project_file', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_permission_log', + expect.objectContaining({ + event: 'permission.confirm', + commandId: 'file.write', + }), + ); + }); + + it('keeps developer file confirmations usable when permission log append fails', async () => { + const invoke = vi.fn((command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + if (args?.event === 'permission.pending') { + throw new Error('pending log failed'); + } + return Promise.reject(new Error('confirm log failed')); + } + if (command === 'write_local_project_file') { + return { + path: String(args?.relativePath ?? ''), + absolutePath: `/tmp/genarrative-ai-game-draft/${String(args?.relativePath ?? '')}`, + deleted: false, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('项目文件路径'), { + target: { value: 'game/debug-note.txt' }, + }); + fireEvent.change(screen.getByLabelText('项目文件内容'), { + target: { value: 'hello despite log failure' }, + }); + fireEvent.click( + within(screen.getByLabelText('项目文件')).getByRole('button', { + name: '保存', + }), + ); + + expect(screen.getByText('file.write')).not.toBeNull(); + expect( + await screen.findByText( + 'permission.log.failed file.write: pending log failed', + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('已保存:game/debug-note.txt'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('write_local_project_file', { + projectPath: '/tmp/genarrative-ai-game-draft', + relativePath: 'game/debug-note.txt', + content: 'hello despite log failure', + }); + expect( + await screen.findByText( + 'permission.log.failed file.write: confirm log failed', + ), + ).not.toBeNull(); + }); + + it('cancels developer file write confirmations without mutating files', () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'append_local_permission_log') { + return {}; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('项目文件路径'), { + target: { value: 'game/debug-note.txt' }, + }); + fireEvent.change(screen.getByLabelText('项目文件内容'), { + target: { value: 'should not save' }, + }); + fireEvent.click( + within(screen.getByLabelText('项目文件')).getByRole('button', { + name: '保存', + }), + ); + + expect(screen.getByText('file.write')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + + expect(screen.queryByText('file.write')).toBeNull(); + expect(screen.getByText('已取消保存项目文件')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'write_local_project_file', + expect.anything(), + ); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/genarrative-ai-game-draft', + event: 'permission.pending', + commandId: 'file.write', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/genarrative-ai-game-draft', + event: 'permission.cancel', + commandId: 'file.write', + }); + }); + + it('rejects unsafe developer file panel paths before confirmation or invoke', () => { + const invoke = vi.fn(); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const filePanel = within(screen.getByLabelText('项目文件')); + fireEvent.change(screen.getByLabelText('项目文件路径'), { + target: { value: '../outside.txt' }, + }); + + fireEvent.click(filePanel.getByRole('button', { name: '读取' })); + expect(screen.getByText('文件路径必须是项目内相对路径。')).not.toBeNull(); + + fireEvent.click(filePanel.getByRole('button', { name: '保存' })); + fireEvent.click(filePanel.getByRole('button', { name: '删除' })); + + expect( + screen.getAllByText('文件路径必须是项目内相对路径。').length, + ).toBeGreaterThanOrEqual(1); + expect(screen.queryByText('file.write')).toBeNull(); + expect(screen.queryByText('file.delete')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('rejects unsafe developer file panel project paths before confirmation or invoke', () => { + const invoke = vi.fn(); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const filePanel = within(screen.getByLabelText('项目文件')); + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: 'relative-project' }, + }); + fireEvent.change(screen.getByLabelText('项目文件路径'), { + target: { value: 'game/debug-note.txt' }, + }); + + fireEvent.click(filePanel.getByRole('button', { name: '列出' })); + expect(screen.getByText('请提供本地项目绝对路径。')).not.toBeNull(); + + fireEvent.click(filePanel.getByRole('button', { name: '读取' })); + fireEvent.click(filePanel.getByRole('button', { name: '保存' })); + fireEvent.click(filePanel.getByRole('button', { name: '删除' })); + + expect(screen.queryByText('file.write')).toBeNull(); + expect(screen.queryByText('file.delete')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/bad\u0007project' }, + }); + fireEvent.click(filePanel.getByRole('button', { name: '读取' })); + + expect(screen.getByText('本地项目路径不能包含控制字符。')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('requires project policy confirmation before listing files from the developer panel', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['file.list'], + }, + }; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [{ path: 'game/index.html', kind: 'file', size: 128 }], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.click( + within(screen.getByLabelText('项目文件')).getByRole('button', { + name: '列出', + }), + ); + + expect(await screen.findByText('file.list')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'list_local_project_files', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('已列出 1 项')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('list_local_project_files', { + projectPath: '/tmp/genarrative-ai-game-draft', + }); + }); + + it('cancels developer file list policy confirmation without listing files', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['file.list'], + }, + }; + } + if (command === 'list_local_project_files') { + throw new Error('should wait for file list confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.click( + within(screen.getByLabelText('项目文件')).getByRole('button', { + name: '列出', + }), + ); + + const fileListCommand = await screen.findByText('file.list'); + fireEvent.click( + within( + fileListCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + expect(await screen.findByText('已取消读取项目文件')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'list_local_project_files', + expect.anything(), + ); + }); + + it('requires project policy confirmation before reading files from the developer panel', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['file.read'], + }, + }; + } + if (command === 'read_local_project_file') { + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: '', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('项目文件路径'), { + target: { value: 'game/index.html' }, + }); + fireEvent.click( + within(screen.getByLabelText('项目文件')).getByRole('button', { + name: '读取', + }), + ); + + expect(await screen.findByText('file.read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('已读取:game/index.html')).not.toBeNull(); + expect(screen.getByLabelText('项目文件内容')).toHaveProperty( + 'value', + '', + ); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/genarrative-ai-game-draft', + relativePath: 'game/index.html', + commandId: 'file.read', + }); + }); + + it('cancels developer file read policy confirmation without reading files', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['file.read'], + }, + }; + } + if (command === 'read_local_project_file') { + throw new Error('should wait for file read confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('项目文件路径'), { + target: { value: 'game/index.html' }, + }); + fireEvent.click( + within(screen.getByLabelText('项目文件')).getByRole('button', { + name: '读取', + }), + ); + + const fileReadCommand = await screen.findByText('file.read'); + fireEvent.click( + within( + fileReadCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + expect(await screen.findByText('已取消读取项目文件')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.anything(), + ); + }); + + it('deletes project files from the developer file panel', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'delete_local_project_file') { + return { + path: String(args?.relativePath ?? ''), + absolutePath: `/tmp/genarrative-ai-game-draft/${String(args?.relativePath ?? '')}`, + deleted: true, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const confirm = vi.spyOn(window, 'confirm'); + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('项目文件路径'), { + target: { value: 'game/debug-note.txt' }, + }); + fireEvent.change(screen.getByLabelText('项目文件内容'), { + target: { value: 'delete me' }, + }); + fireEvent.click( + within(screen.getByLabelText('项目文件')).getByRole('button', { + name: '删除', + }), + ); + + expect(screen.getByText('file.delete')).not.toBeNull(); + expect( + screen.getByText( + '删除 /tmp/genarrative-ai-game-draft/game/debug-note.txt', + ), + ).not.toBeNull(); + expect(confirm).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('已删除:game/debug-note.txt'), + ).not.toBeNull(); + expect( + (screen.getByLabelText('项目文件内容') as HTMLTextAreaElement).value, + ).toBe(''); + expect(invoke).toHaveBeenCalledWith('delete_local_project_file', { + projectPath: '/tmp/genarrative-ai-game-draft', + relativePath: 'game/debug-note.txt', + }); + }); + + it('reads project blackboard memory from the developer memory panel', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_game_memory') { + return { + scope: args?.scope, + path: 'memory/blackboard.md', + content: '# 项目黑板\n- 保留跨 agent 决策\n', + exists: true, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const memoryPanel = screen.getByLabelText('记忆'); + fireEvent.change(memoryPanel.querySelector('select') as HTMLSelectElement, { + target: { value: 'blackboard' }, + }); + fireEvent.click( + Array.from(memoryPanel.querySelectorAll('button')).find( + (button) => button.textContent === '读取', + ) as HTMLButtonElement, + ); + + expect( + await screen.findByText(/已读取:memory\/blackboard\.md/), + ).not.toBeNull(); + expect(screen.getByLabelText('记忆内容')).toHaveProperty( + 'value', + '# 项目黑板\n- 保留跨 agent 决策\n', + ); + expect(invoke).toHaveBeenCalledWith('read_local_game_memory', { + projectPath: '/tmp/genarrative-ai-game-draft', + scope: 'blackboard', + }); + }); + + it('requires project policy confirmation before reading memory from the developer panel', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['memory.read'], + }, + }; + } + if (command === 'read_local_game_memory') { + return { + scope: args?.scope, + path: 'memory/blackboard.md', + content: '# 项目黑板\n', + exists: true, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const memoryPanel = screen.getByLabelText('记忆'); + fireEvent.change(memoryPanel.querySelector('select') as HTMLSelectElement, { + target: { value: 'blackboard' }, + }); + fireEvent.click( + Array.from(memoryPanel.querySelectorAll('button')).find( + (button) => button.textContent === '读取', + ) as HTMLButtonElement, + ); + + expect(await screen.findByText('memory.read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_game_memory', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText(/已读取:memory\/blackboard\.md/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_game_memory', { + projectPath: '/tmp/genarrative-ai-game-draft', + scope: 'blackboard', + }); + }); + + it('cancels project memory read confirmation from the developer panel', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['memory.read'], + }, + }; + } + if (command === 'read_local_game_memory') { + throw new Error('should wait for confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const memoryPanel = screen.getByLabelText('记忆'); + fireEvent.click(within(memoryPanel).getByRole('button', { name: '读取' })); + + const memoryReadCommand = await screen.findByText('memory.read'); + fireEvent.click( + within( + memoryReadCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + await waitFor(() => { + expect(screen.queryByText('memory.read')).toBeNull(); + }); + expect(screen.getByText('已取消读取项目记忆。')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_game_memory', + expect.anything(), + ); + }); + + it('rejects unsafe developer memory project paths before confirmation or invoke', () => { + const invoke = vi.fn(); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const memoryPanel = within(screen.getByLabelText('记忆')); + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: 'relative-project' }, + }); + + fireEvent.click(memoryPanel.getByRole('button', { name: '读取' })); + expect(screen.getByText('请提供本地项目绝对路径。')).not.toBeNull(); + + fireEvent.click(memoryPanel.getByRole('button', { name: '保存' })); + fireEvent.click(memoryPanel.getByRole('button', { name: '删除' })); + + expect(screen.queryByText('memory.write')).toBeNull(); + expect(screen.queryByText('memory.delete')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/bad\u0007project' }, + }); + fireEvent.click(memoryPanel.getByRole('button', { name: '读取' })); + + expect(screen.getByText('本地项目路径不能包含控制字符。')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); + it('lists standard agent capabilities from chat without opening dev panels', () => { renderAppAt('/'); submitChat('/capabilities'); - expect(screen.getByText(/Agent 能力清单:/)).not.toBeNull(); - expect(screen.getByText(/任务拆分/)).not.toBeNull(); - expect(screen.getByText(/任务编排/)).not.toBeNull(); - expect( - screen.getByText(/Planner \/ Generator \/ Evaluator 循环/), - ).not.toBeNull(); - expect(screen.getByText(/多智能体协作/)).not.toBeNull(); - expect(screen.getByText(/短期记忆/)).not.toBeNull(); - expect(screen.getByText(/长期记忆/)).not.toBeNull(); - expect(screen.getByText(/本地 HTTP 预览/)).not.toBeNull(); + const capabilities = screen.getByText(/Agent 能力清单:/); + expect(capabilities.textContent).toMatch(/任务拆分/); + expect(capabilities.textContent).toMatch(/任务编排/); + expect(capabilities.textContent).toMatch( + /Planner \/ Generator \/ Evaluator 循环/, + ); + expect(capabilities.textContent).toMatch(/多智能体协作/); + expect(capabilities.textContent).toMatch(/短期记忆/); + expect(capabilities.textContent).toMatch(/长期记忆/); + expect(capabilities.textContent).toMatch(/本地 HTTP 预览/); expect(screen.queryByLabelText('开发环境')).toBeNull(); expect(screen.queryByText('编排 Trace')).toBeNull(); }); + it('loads agent capabilities from the native runtime when available', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'get_game_creation_agent_capabilities') { + return [ + { + id: 'native-only-capability', + area: 'agent-runtime', + title: 'Native Runtime 能力', + }, + ]; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/capabilities'); + + expect(await screen.findByText(/Native Runtime 能力/)).not.toBeNull(); + expect(screen.queryByText(/任务拆分/)).toBeNull(); + expect(invoke).toHaveBeenCalledWith('get_game_creation_agent_capabilities'); + }); + + it('falls back to standard agent capabilities when the native runtime returns none', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'get_game_creation_agent_capabilities') { + return []; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/capabilities'); + + expect(await screen.findByText(/任务拆分/)).not.toBeNull(); + expect(screen.getByText(/本地 HTTP 预览/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('get_game_creation_agent_capabilities'); + }); + it('exposes the audit command from chat help', () => { renderAppAt('/'); @@ -204,17 +7156,61 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); expect(screen.getByText(/\/checkpoint:保存本地项目快照/)).not.toBeNull(); expect( - screen.getByText(/\/restore checkpoint-id:恢复 checkpoint/), + screen.getByText(/\/checkpoints:列出最近 checkpoint/), + ).not.toBeNull(); + expect( + screen.getByText(/\/restore checkpoint-id:回滚项目文件到 checkpoint/), ).not.toBeNull(); expect( screen.getByText(/\/policy-deny 命令:拒绝项目内某个内置命令/), ).not.toBeNull(); + expect( + screen.getByText(/\/policy-confirm 命令:执行前每次确认/), + ).not.toBeNull(); + expect(screen.getByText(/\/policy-auto 命令:恢复自动执行/)).not.toBeNull(); expect( screen.getByText(/\/agent-status:查看最近 run 生命周期/), ).not.toBeNull(); expect( screen.getByText(/\/agent-kill:标记最近 run 为 killed/), ).not.toBeNull(); + expect( + screen.getByText(/\/history:重新读取当前项目对话历史/), + ).not.toBeNull(); + expect( + screen.getByText(/\/open-project:在系统文件管理器中显示项目目录/), + ).not.toBeNull(); + expect( + screen.getByText(/\/switch-project:回到项目启动器切换工作区/), + ).not.toBeNull(); + expect( + screen.getByText( + /\/asset-register 路径 \[kind\] \[mediaType\]:登记项目内已有资产/, + ), + ).not.toBeNull(); + expect( + screen.getByText(/\/canvas 画板项目ID:打开本机画板项目/), + ).not.toBeNull(); + expect( + screen.getByText(/\/commands:查看可运行的受限命令白名单/), + ).not.toBeNull(); + expect(screen.queryByLabelText('开发环境')).toBeNull(); + }); + + it('opens chat help from the header command button', () => { + renderAppAt('/'); + + fireEvent.click(screen.getByRole('button', { name: '命令' })); + + expect( + screen.getByText(/\/audit:审计当前项目的 Agent 能力证据/), + ).not.toBeNull(); + expect(screen.getByText(/\/config:打开运行时配置/)).not.toBeNull(); + expect( + screen.getByText( + /\/generate-art 提示词:通过平台 External Editor API 生成首版美术素材/, + ), + ).not.toBeNull(); expect(screen.queryByLabelText('开发环境')).toBeNull(); }); @@ -481,6 +7477,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return auditedManifest; } @@ -525,7 +7524,7 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText('已设置本地项目:/tmp/authorized-game'), + await screen.findByText('已打开:/tmp/authorized-game'), ).not.toBeNull(); submitChat('/audit'); @@ -550,6 +7549,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.queryByLabelText('开发环境')).toBeNull(); expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', { projectPath: '/tmp/authorized-game', + commandId: 'agent.audit', }); expect(invoke).toHaveBeenCalledWith('list_local_project_files', { projectPath: '/tmp/authorized-game', @@ -557,10 +7557,330 @@ describe('AI 游戏创作 App 界面边界', () => { expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', }); expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: '.agent/logs/command.log', + commandId: 'file.read', + }); + }); + + it('requires trace read confirmation before audit reads run trace', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-audit-trace-confirm', + commandId: 'game.generate_draft', + status: 'passed', + lifecycleStatus: 'done', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'evaluator-passed', + goal: '做一个厨房弹幕游戏', + coordination: 'filesystem', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'preview', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.trace_read'], + }, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } + if (command === 'read_local_project_file') { + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: + args?.relativePath === '.agent/run.latest.json' + ? JSON.stringify(trace) + : '1 command.auto preview.status\n', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/audit'); + + expect( + await screen.findByText('准备确认审计读取:agent.trace_read'), + ).not.toBeNull(); + expect(screen.getByText('agent.trace_read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/Agent v1 审计:/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + }); + + it('requires file read confirmation before audit reads command logs', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['file.read'], + }, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { path: '.agent/logs/command.log', kind: 'file', size: 64 }, + ], + }; + } + if (command === 'read_local_project_file') { + if (args?.relativePath === '.agent/logs/command.log') { + return { + path: '.agent/logs/command.log', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/logs/command.log`, + content: '1 command.auto preview.status\n', + }; + } + throw new Error('missing trace'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/audit'); + + expect( + await screen.findByText('准备确认审计读取:file.read'), + ).not.toBeNull(); + expect(screen.getByText('file.read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/logs/command.log', + commandId: 'file.read', + }); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/Agent v1 审计:/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/logs/command.log', + commandId: 'file.read', + }); + }); + + it('confirms each audit read policy instead of one confirmation unlocking all reads', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-audit-multi-confirm', + commandId: 'game.generate_draft', + status: 'passed', + lifecycleStatus: 'done', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'evaluator-passed', + goal: '做一个厨房弹幕游戏', + coordination: 'filesystem', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'preview', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['file.read', 'agent.trace_read'], + }, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { path: '.agent/logs/command.log', kind: 'file', size: 64 }, + ], + }; + } + if (command === 'read_local_project_file') { + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: + args?.relativePath === '.agent/run.latest.json' + ? JSON.stringify(trace) + : '1 command.auto preview.status\n', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/audit'); + + expect( + await screen.findByText('准备确认审计读取:file.read'), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('准备确认审计读取:agent.trace_read'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/logs/command.log', + commandId: 'file.read', + }); + expect(invoke).not.toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/Agent v1 审计:/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', }); }); @@ -594,6 +7914,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return auditedManifest; } @@ -624,7 +7947,7 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText('已设置本地项目:/tmp/authorized-game'), + await screen.findByText('已打开:/tmp/authorized-game'), ).not.toBeNull(); submitChat('/audit'); @@ -635,6 +7958,66 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); }); + it('marks permission gate as passed with durable auto command logs', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { path: '.agent/logs/command.log', kind: 'file', size: 64 }, + ], + }; + } + if (command === 'read_local_project_file') { + if (args?.relativePath === '.agent/logs/command.log') { + return { + path: '.agent/logs/command.log', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/logs/command.log`, + content: '1 command.auto preview.status\n', + }; + } + throw new Error('missing trace'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/audit'); + + expect(await screen.findByText(/权限 Gate\/命令日志:通过/)).not.toBeNull(); + expect(screen.getByText(/含 auto 权限记录/)).not.toBeNull(); + }); + it('does not mark multi-agent collaboration as passed before a run trace exists', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -653,6 +8036,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return manifest; } @@ -674,7 +8060,7 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText('已设置本地项目:/tmp/authorized-game'), + await screen.findByText('已打开:/tmp/authorized-game'), ).not.toBeNull(); submitChat('/audit'); @@ -884,6 +8270,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return manifest; } @@ -909,7 +8298,7 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText('已设置本地项目:/tmp/authorized-game'), + await screen.findByText('已打开:/tmp/authorized-game'), ).not.toBeNull(); submitChat('/audit'); @@ -935,6 +8324,40 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); }); + it('reports unknown slash commands instead of falling back to generation', () => { + renderAppAt('/?dev'); + + submitChat('/unknown-command'); + + expect( + screen.getByText('未知命令:/unknown-command。输入 /help 查看可用命令。'), + ).not.toBeNull(); + expect(screen.queryByText('game.generate_draft')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + }); + + it('reports missing slash command arguments instead of falling back to generation', () => { + renderAppAt('/'); + + submitChat('/project'); + expect(screen.getByText('格式:/project /绝对路径')).not.toBeNull(); + + submitChat('/remember'); + expect(screen.getByText('请提供要追加的记忆内容。')).not.toBeNull(); + + submitChat('/sync-canvas-project'); + expect(screen.getByText('请提供画板项目 ID。')).not.toBeNull(); + + submitChat('/import-canvas-asset'); + expect( + screen.getByText( + '格式:/import-canvas-asset assets/hero.png 画板项目ID 资源ID|object:资产对象ID', + ), + ).not.toBeNull(); + expect(screen.queryByText('game.generate_draft')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + }); + it('rejects relative project paths before confirmation', () => { const invoke = vi.fn(); window.__TAURI__ = { core: { invoke } }; @@ -946,6 +8369,103 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.queryByText('project.create')).toBeNull(); expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); expect(invoke).not.toHaveBeenCalled(); + + submitChat('/project /tmp/bad\u0007path'); + + expect(screen.getByText('本地项目路径不能包含控制字符。')).not.toBeNull(); + expect(screen.queryByText('project.create')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('warns before project.create initializes a non-empty folder from the main window', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'is_local_project_directory_non_empty') { + expect(args).toEqual({ + projectPath: '/tmp/main-non-empty-game', + }); + return true; + } + if (command === 'init_local_game_project') { + throw new Error('should wait for explicit confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false); + renderAppAt('/'); + + submitChat('/project /tmp/main-non-empty-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByRole('dialog', { name: '文件夹不是空的' }), + ).not.toBeNull(); + expect(screen.getByText('/tmp/main-non-empty-game')).not.toBeNull(); + expect(confirm).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + + expect( + await screen.findByText( + '已取消在非空文件夹中新建项目:/tmp/main-non-empty-game', + ), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'init_local_game_project', + expect.anything(), + ); + }); + + it('creates from the main window after confirming a non-empty folder warning', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + 'main-non-empty-game', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'is_local_project_directory_non_empty') { + return true; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'append_local_permission_log') { + return {}; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + const confirm = vi.spyOn(window, 'confirm').mockReturnValue(true); + renderAppAt('/'); + + submitChat('/project /tmp/main-non-empty-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByRole('dialog', { name: '文件夹不是空的' }), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'init_local_game_project', + expect.anything(), + ); + fireEvent.click(screen.getByRole('button', { name: '继续新建' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('init_local_game_project', { + projectPath: '/tmp/main-non-empty-game', + projectId: 'local-project-draft', + name: '未命名游戏原型', + }); + }); + expect(confirm).not.toHaveBeenCalled(); }); it('records a project.create confirmation after developer project init succeeds', async () => { @@ -970,7 +8490,7 @@ describe('AI 游戏创作 App 界面边界', () => { }, ); window.__TAURI__ = { core: { invoke } }; - vi.spyOn(window, 'confirm').mockReturnValue(true); + const confirm = vi.spyOn(window, 'confirm'); renderAppAt('/?dev'); fireEvent.change(screen.getByLabelText('本地项目目录'), { @@ -978,6 +8498,11 @@ describe('AI 游戏创作 App 界面边界', () => { }); fireEvent.click(screen.getByRole('button', { name: '初始化' })); + expect(screen.getByText('project.create')).not.toBeNull(); + expect(screen.getByText('创建 /tmp/dev-authorized-game')).not.toBeNull(); + expect(confirm).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect(await screen.findByText('已初始化')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { projectPath: '/tmp/dev-authorized-game', @@ -1008,8 +8533,12 @@ describe('AI 游戏创作 App 界面边界', () => { return { checkpointId: String(args?.checkpointId ?? ''), restoredCount: 3, + deletedCount: 1, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return manifest; } @@ -1029,7 +8558,9 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText(/已恢复 3 个文件:checkpoint-1/), + await screen.findByText( + /已回滚 3 个文件到 \/tmp\/authorized-game,删除 1 个新增文件:checkpoint-1/, + ), ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('restore_local_project_checkpoint', { projectPath: '/tmp/authorized-game', @@ -1042,6 +8573,1382 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('lists recent checkpoints from chat through project files', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const manifests = new Map([ + [ + '.agent/checkpoints/checkpoint-new/manifest.json', + JSON.stringify({ + checkpointId: 'checkpoint-new', + createdAt: 1700000002, + files: [ + { path: 'game/index.html', size: 10 }, + { path: 'assets/hero.png', size: 2 }, + ], + }), + ], + [ + '.agent/checkpoints/checkpoint-old/manifest.json', + JSON.stringify({ + checkpointId: 'checkpoint-old', + createdAt: 1700000001, + files: [{ path: 'game/index.html', size: 8 }], + }), + ], + ]); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { + path: '.agent/checkpoints/checkpoint-old/manifest.json', + kind: 'file', + size: 100, + modifiedAt: 100, + }, + { + path: '.agent/checkpoints/checkpoint-new/manifest.json', + kind: 'file', + size: 120, + modifiedAt: 200, + }, + { + path: '.agent/checkpoints/checkpoint-new/files/game/index.html', + kind: 'file', + size: 10, + modifiedAt: 200, + }, + ], + }; + } + if (command === 'read_local_project_file') { + const relativePath = String(args?.relativePath ?? ''); + return { + path: relativePath, + absolutePath: `${String(args?.projectPath ?? '')}/${relativePath}`, + content: manifests.get(relativePath) ?? '{}', + }; + } + if (command === 'diff_local_project_checkpoint') { + return { + checkpointId: String(args?.checkpointId ?? ''), + added: [{ path: 'game/index.html' }], + changed: [], + deleted: [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/checkpoints'); + + expect(await screen.findByText(/最近 checkpoint:/)).not.toBeNull(); + expect( + screen.getByText( + /checkpoint-new · 2 个文件 · 12B · createdAt 1700000002 · \/diff checkpoint-new · \/restore checkpoint-new/, + ), + ).not.toBeNull(); + expect( + screen.getByText( + /checkpoint-old · 1 个文件 · 8B · createdAt 1700000001 · \/diff checkpoint-old · \/restore checkpoint-old/, + ), + ).not.toBeNull(); + fireEvent.click( + within(screen.getByLabelText('最近 checkpoint')).getByRole('button', { + name: '对比 checkpoint-new', + }), + ); + expect( + await screen.findByText(/checkpoint:checkpoint-new/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('list_local_project_files', { + projectPath: '/tmp/authorized-game', + }); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/checkpoints/checkpoint-new/manifest.json', + commandId: 'file.read', + }); + expect(invoke).toHaveBeenCalledWith('diff_local_project_checkpoint', { + projectPath: '/tmp/authorized-game', + checkpointId: 'checkpoint-new', + }); + }); + + it('requires file read confirmation before reading checkpoint manifests', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['file.read'], + }, + }; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [ + { + path: '.agent/checkpoints/checkpoint-1/manifest.json', + kind: 'file', + size: 100, + modifiedAt: 100, + }, + ], + }; + } + if (command === 'read_local_project_file') { + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: JSON.stringify({ + checkpointId: 'checkpoint-1', + createdAt: 1700000000, + files: [], + }), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/checkpoints'); + + expect( + await screen.findByText('准备读取 checkpoint manifest。'), + ).not.toBeNull(); + expect(screen.getByText('file.read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => + expect(screen.getByLabelText('聊天').textContent).toContain( + 'checkpoint-1', + ), + ); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/checkpoints/checkpoint-1/manifest.json', + commandId: 'file.read', + }); + }); + + it('requires project policy confirmation before restoring checkpoints from chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['project.restore'], + }, + }; + } + if (command === 'restore_local_project_checkpoint') { + return { + checkpointId: String(args?.checkpointId ?? ''), + restoredCount: 2, + deletedCount: 0, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/restore checkpoint-1'); + + expect(await screen.findByText(/project\.restore/)).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'restore_local_project_checkpoint', + expect.anything(), + ); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + /已回滚 2 个文件到 \/tmp\/authorized-game,删除 0 个新增文件:checkpoint-1/, + ), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('restore_local_project_checkpoint', { + projectPath: '/tmp/authorized-game', + checkpointId: 'checkpoint-1', + }); + }); + + it('shows truncated checkpoint diff counts in chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const added = Array.from({ length: 22 }, (_, index) => ({ + path: `game/file-${index + 1}.ts`, + })); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'diff_local_project_checkpoint') { + return { + checkpointId: String(args?.checkpointId ?? ''), + added, + changed: [], + deleted: [], + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/diff checkpoint-1'); + + expect(await screen.findByText(/checkpoint:checkpoint-1/)).not.toBeNull(); + expect(screen.getByText(/- game\/file-20\.ts/)).not.toBeNull(); + expect(screen.queryByText(/- game\/file-21\.ts/)).toBeNull(); + expect(screen.getByText(/- 还有 2 项/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('diff_local_project_checkpoint', { + projectPath: '/tmp/authorized-game', + checkpointId: 'checkpoint-1', + }); + }); + + it('requires project policy confirmation before diffing checkpoints from chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['project.diff'], + }, + }; + } + if (command === 'diff_local_project_checkpoint') { + return { + checkpointId: String(args?.checkpointId ?? ''), + added: [], + changed: [{ path: 'game/index.html' }], + deleted: [], + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/diff checkpoint-1'); + + expect(await screen.findByText('准备对比项目 checkpoint。')).not.toBeNull(); + expect(screen.getByText('project.diff')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'diff_local_project_checkpoint', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/checkpoint:checkpoint-1/)).not.toBeNull(); + expect(screen.getByText(/- game\/index\.html/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('diff_local_project_checkpoint', { + projectPath: '/tmp/authorized-game', + checkpointId: 'checkpoint-1', + }); + }); + + it('rejects unsafe checkpoint ids before diff or restore calls', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: '1', + ...(args?.message as Record), + updatedAt: 1, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/diff ../checkpoint-1'); + expect(screen.getByText('checkpoint id 非法。')).not.toBeNull(); + + submitChat('/restore checkpoint/evil'); + expect(screen.getAllByText('checkpoint id 非法。').length).toBe(2); + expect(screen.queryByText('project.restore')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'diff_local_project_checkpoint', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'restore_local_project_checkpoint', + expect.anything(), + ); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.objectContaining({ + message: expect.objectContaining({ + content: 'checkpoint id 非法。', + }), + }), + ); + }); + }); + + it('reads project policy from chat and truncates long command lists', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const deniedCommands = Array.from( + { length: 14 }, + (_, index) => `file.write.${index + 1}`, + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands, + confirmCommands: ['game.static_smoke'], + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/policy'); + + expect( + await screen.findByText(/策略:\.agent\/policy\.json/), + ).not.toBeNull(); + expect(screen.getByText(/file\.write\.12/)).not.toBeNull(); + expect(screen.queryByText(/file\.write\.13/)).toBeNull(); + expect(screen.getByText(/拒绝:.*还有 2 项/)).not.toBeNull(); + expect(screen.getByText(/确认:game\.static_smoke/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', { + projectPath: '/tmp/authorized-game', + }); + }); + + it('updates project policy from chat after confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let policy = { + deniedCommands: ['file.delete'], + confirmCommands: ['game.static_smoke'], + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy, + }; + } + if (command === 'write_project_permission_policy') { + policy = args?.policy as typeof policy; + return { + path: '.agent/policy.json', + policy, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/policy-deny file.write'); + expect(await screen.findByText('准备拒绝命令:file.write')).not.toBeNull(); + expect(screen.getByText('project.policy_write')).not.toBeNull(); + expect( + screen.getByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.delete、file\.write · 确认:game\.static_smoke/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText(/拒绝:file\.delete、file\.write/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.delete', 'file.write'], + confirmCommands: ['game.static_smoke'], + }, + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.confirm', + commandId: 'project.policy_write', + }); + }); + + it('updates project policy confirm commands from chat after confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let policy = { + deniedCommands: ['file.delete'], + confirmCommands: [], + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy, + }; + } + if (command === 'write_project_permission_policy') { + policy = args?.policy as typeof policy; + return { + path: '.agent/policy.json', + policy, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/policy-confirm project.index'); + expect( + await screen.findByText('准备确认命令:project.index'), + ).not.toBeNull(); + expect( + screen.getByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.delete · 确认:project\.index/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/确认:project\.index/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.delete'], + confirmCommands: ['project.index'], + }, + }); + + submitChat('/policy-auto project.index'); + expect( + await screen.findByText('准备自动执行命令:project.index'), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/确认:无/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.delete'], + confirmCommands: [], + }, + }); + }); + + it('keeps project policy deny and confirm command lists mutually exclusive', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let policy = { + deniedCommands: ['project.index'], + confirmCommands: ['file.write'], + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy, + }; + } + if (command === 'write_project_permission_policy') { + policy = args?.policy as typeof policy; + return { + path: '.agent/policy.json', + policy, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/policy-deny file.write'); + expect( + await screen.findByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:project\.index、file\.write · 确认:无/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['project.index', 'file.write'], + confirmCommands: [], + }, + }); + }); + + submitChat('/policy-confirm project.index'); + expect( + await screen.findByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.write · 确认:project\.index/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.write'], + confirmCommands: ['project.index'], + }, + }); + }); + + submitChat('/policy-confirm project.diff'); + expect( + await screen.findByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.write · 确认:project\.index、project\.diff/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.write'], + confirmCommands: ['project.index', 'project.diff'], + }, + }); + }); + + submitChat('/policy-confirm preview.stop'); + expect( + await screen.findByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.write · 确认:project\.index、project\.diff、preview\.stop/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.write'], + confirmCommands: ['project.index', 'project.diff', 'preview.stop'], + }, + }); + }); + + submitChat('/policy-confirm preview.open'); + expect( + await screen.findByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.write · 确认:project\.index、project\.diff、preview\.stop、preview\.open/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.write'], + confirmCommands: [ + 'project.index', + 'project.diff', + 'preview.stop', + 'preview.open', + ], + }, + }); + }); + + submitChat('/policy-confirm preview.start'); + expect( + await screen.findByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.write · 确认:project\.index、project\.diff、preview\.stop、preview\.open、preview\.start/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.write'], + confirmCommands: [ + 'project.index', + 'project.diff', + 'preview.stop', + 'preview.open', + 'preview.start', + ], + }, + }); + }); + + submitChat('/policy-confirm agent.run_status'); + expect( + await screen.findByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.write · 确认:project\.index、project\.diff、preview\.stop、preview\.open、preview\.start、agent\.run_status/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.write'], + confirmCommands: [ + 'project.index', + 'project.diff', + 'preview.stop', + 'preview.open', + 'preview.start', + 'agent.run_status', + ], + }, + }); + }); + + submitChat('/policy-confirm agent.kill'); + expect( + await screen.findByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.write · 确认:project\.index、project\.diff、preview\.stop、preview\.open、preview\.start、agent\.run_status、agent\.kill/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.write'], + confirmCommands: [ + 'project.index', + 'project.diff', + 'preview.stop', + 'preview.open', + 'preview.start', + 'agent.run_status', + 'agent.kill', + ], + }, + }); + }); + + submitChat('/policy-confirm agent.retry'); + expect( + await screen.findByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.write · 确认:project\.index、project\.diff、preview\.stop、preview\.open、preview\.start、agent\.run_status、agent\.kill、agent\.retry/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.write'], + confirmCommands: [ + 'project.index', + 'project.diff', + 'preview.stop', + 'preview.open', + 'preview.start', + 'agent.run_status', + 'agent.kill', + 'agent.retry', + ], + }, + }); + }); + + submitChat('/policy-confirm agent.resume'); + expect( + await screen.findByText( + /写入 \/tmp\/authorized-game\/\.agent\/policy\.json · 拒绝:file\.write · 确认:project\.index、project\.diff、preview\.stop、preview\.open、preview\.start、agent\.run_status、agent\.kill、agent\.retry、agent\.resume/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: ['file.write'], + confirmCommands: [ + 'project.index', + 'project.diff', + 'preview.stop', + 'preview.open', + 'preview.start', + 'agent.run_status', + 'agent.kill', + 'agent.retry', + 'agent.resume', + ], + }, + }); + }); + + submitChat('/policy-confirm asset.register'); + expect(await screen.findByText(/确认:.*asset\.register/)).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: expect.objectContaining({ + deniedCommands: ['file.write'], + confirmCommands: expect.arrayContaining(['asset.register']), + }), + }); + }); + }); + + it('does not write project policy for no-op policy changes', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: ['file.delete'], + confirmCommands: [], + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/policy-allow file.write'); + + expect( + await screen.findByText('命令不在拒绝列表中:file.write'), + ).not.toBeNull(); + submitChat('/policy-auto project.index'); + expect( + await screen.findByText('命令不在确认列表中:project.index'), + ).not.toBeNull(); + expect(screen.queryByText('project.policy_write')).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'write_project_permission_policy', + expect.anything(), + ); + }); + + it('allows checkpoint commands to require project policy confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let policy = { + deniedCommands: [], + confirmCommands: [], + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy, + }; + } + if (command === 'write_project_permission_policy') { + policy = args?.policy as typeof policy; + return { + path: '.agent/policy.json', + policy, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/policy-confirm project.checkpoint'); + + expect(await screen.findByText(/确认:project\.checkpoint/)).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: [], + confirmCommands: ['project.checkpoint'], + }, + }); + }); + + submitChat('/policy-confirm project.restore'); + + expect( + await screen.findByText(/确认:project\.checkpoint、project\.restore/), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: [], + confirmCommands: ['project.checkpoint', 'project.restore'], + }, + }); + }); + }); + + it('allows canvas project commands to require project policy confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let policy = { + deniedCommands: [], + confirmCommands: [], + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy, + }; + } + if (command === 'write_project_permission_policy') { + policy = args?.policy as typeof policy; + return { + path: '.agent/policy.json', + policy, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/policy-confirm canvas.project_sync'); + expect( + await screen.findByText(/确认:canvas\.project_sync/), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: [], + confirmCommands: ['canvas.project_sync'], + }, + }); + }); + + submitChat('/policy-confirm canvas.asset_generate'); + expect( + await screen.findByText( + /确认:canvas\.project_sync、canvas\.asset_generate/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('write_project_permission_policy', { + projectPath: '/tmp/authorized-game', + policy: { + deniedCommands: [], + confirmCommands: ['canvas.project_sync', 'canvas.asset_generate'], + }, + }); + }); + }); + + it('rejects unsupported project policy confirm commands before reading policy', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/policy-confirm file.write'); + + expect( + await screen.findByText( + '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、file.list、file.read、memory.read、asset.register、asset.list、task.list、agent.run_status、agent.kill、agent.retry、agent.resume、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。', + ), + ).not.toBeNull(); + expect(screen.queryByText('project.policy_write')).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_project_permission_policy', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'write_project_permission_policy', + expect.anything(), + ); + }); + + it('rejects unknown project policy command ids before reading policy', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: '1', + ...(args?.message as Record), + updatedAt: 1, + }, + ], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + await waitFor(() => { + expect( + invoke.mock.calls.filter( + ([command]) => command === 'read_project_permission_policy', + ).length, + ).toBeGreaterThanOrEqual(2); + }); + invoke.mockClear(); + + submitChat('/policy-deny not.a.command'); + + expect( + await screen.findByText('未知内置命令:not.a.command'), + ).not.toBeNull(); + expect(screen.queryByText('project.policy_write')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_project_permission_policy', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'write_project_permission_policy', + expect.anything(), + ); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.objectContaining({ + message: expect.objectContaining({ + content: '未知内置命令:not.a.command', + }), + }), + ); + }); + }); + + it('reports policy write failures when Tauri is unavailable after confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: [], + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/policy-deny file.write'); + expect(await screen.findByText('准备拒绝命令:file.write')).not.toBeNull(); + delete window.__TAURI__; + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('需要在 Tauri App 内运行。')).not.toBeNull(); + }); + it('does not stop a preview before a local project is initialized', () => { const invoke = vi.fn(); window.__TAURI__ = { core: { invoke } }; @@ -1071,6 +9978,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'stop_local_game_preview') { return { status: 'stopped', @@ -1097,6 +10007,136 @@ describe('AI 游戏创作 App 界面边界', () => { expect(invoke).toHaveBeenCalledWith('stop_local_game_preview', { projectPath: '/tmp/authorized-game', }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'command.auto', + commandId: 'preview.stop', + }); + }); + + it('requires project policy confirmation before stopping preview from chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['preview.stop'], + }, + }; + } + if (command === 'stop_local_game_preview') { + return { + status: 'stopped', + url: null, + port: null, + root: null, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/preview-stop'); + + expect(await screen.findByText('准备停止本地预览。')).not.toBeNull(); + expect(screen.getByText('preview.stop')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'stop_local_game_preview', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('预览已停止。')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('stop_local_game_preview', { + projectPath: '/tmp/authorized-game', + }); + }); + + it('stops preview from the developer panel through the authorized local project path', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'stop_local_game_preview') { + return { + status: 'stopped', + url: null, + port: null, + root: null, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '初始化' })); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull(); + }); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/other-game' }, + }); + fireEvent.click( + within(screen.getByLabelText('预览')).getByRole('button', { + name: '停止', + }), + ); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('stop_local_game_preview', { + projectPath: '/tmp/authorized-game', + }); + }); + expect(invoke).not.toHaveBeenCalledWith('stop_local_game_preview', { + projectPath: '/tmp/other-game', + }); }); it('does not show preview status before a local project is initialized', () => { @@ -1141,6 +10181,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'open_local_game_preview') { return { status: 'running', @@ -1164,12 +10207,136 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/open-preview'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect(await screen.findByText('已打开当前本地预览。')).not.toBeNull(); + expect( + await screen.findByText('已打开当前本地预览:http://127.0.0.1:3210/'), + ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('open_local_game_preview', { projectPath: '/tmp/authorized-game', }); }); + it('requires project policy confirmation before opening preview from chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['preview.open'], + }, + }; + } + if (command === 'open_local_game_preview') { + return { + status: 'running', + url: 'http://127.0.0.1:3210/', + port: 3210, + root: String(args?.projectPath ?? ''), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/open-preview'); + expect(await screen.findByText('准备打开当前本地预览。')).not.toBeNull(); + expect(screen.getByText('preview.open')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', { + projectPath: '/tmp/authorized-game', + }); + }); + expect(screen.getByText('preview.open')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_local_game_preview', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('已打开当前本地预览:http://127.0.0.1:3210/'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('open_local_game_preview', { + projectPath: '/tmp/authorized-game', + }); + }); + + it('cancels pending preview open without opening preview', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'open_local_game_preview') { + throw new Error('should not open preview after cancel'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/open-preview'); + const previewCommand = screen.getByText('preview.open'); + fireEvent.click( + within( + previewCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + expect(await screen.findByText('已取消预览操作')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_local_game_preview', + expect.anything(), + ); + }); + it('starts preview from chat through the authorized local project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1204,6 +10371,9 @@ describe('AI 游戏创作 App 界面边界', () => { root: String(args?.projectPath ?? ''), }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return manifest; } @@ -1249,6 +10419,307 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('requires project policy confirmation before starting preview from chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['preview.start'], + }, + }; + } + if (command === 'start_local_game_preview') { + return { + status: 'running', + url: 'http://127.0.0.1:3210/', + port: 3210, + root: String(args?.projectPath ?? ''), + }; + } + if (command === 'open_local_game_preview') { + return { + status: 'running', + url: 'http://127.0.0.1:3210/', + port: 3210, + root: String(args?.projectPath ?? ''), + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/preview'); + expect(screen.getByText('preview.start')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', { + projectPath: '/tmp/authorized-game', + }); + }); + expect(screen.getByText('preview.start')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'start_local_game_preview', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText(/预览已启动:http:\/\/127\.0\.0\.1:3210\//), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath: '/tmp/authorized-game', + }); + }); + + it('blocks preview start before native start when project policy denies it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: ['preview.start'], + confirmCommands: [], + }, + }; + } + if (command === 'start_local_game_preview') { + throw new Error('should not start preview after deny'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/preview'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('项目权限策略拒绝执行:preview.start'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'start_local_game_preview', + expect.anything(), + ); + }); + + it('cancels preview start policy confirmation without starting preview', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['preview.start'], + }, + }; + } + if (command === 'start_local_game_preview') { + throw new Error('should wait for preview confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/preview'); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: '确认' })); + }); + + const previewCommand = await screen.findByText('preview.start'); + fireEvent.click( + within( + previewCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + expect(await screen.findByText('已取消预览操作')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'start_local_game_preview', + expect.anything(), + ); + }); + + it('starts preview from the developer panel through the authorized local project path', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'is_local_project_directory_non_empty') { + return false; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } + if (command === 'start_local_game_preview') { + return { + status: 'running', + url: 'http://127.0.0.1:3210/', + port: 3210, + root: String(args?.projectPath ?? ''), + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '初始化' })); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull(); + }); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/other-game' }, + }); + fireEvent.click( + within(screen.getByLabelText('预览')).getByRole('button', { + name: '启动', + }), + ); + + expect( + screen.getByText('启动 /tmp/authorized-game/game/ 并交给外部浏览器'), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath: '/tmp/authorized-game', + }); + }); + expect(invoke).not.toHaveBeenCalledWith('start_local_game_preview', { + projectPath: '/tmp/other-game', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.pending', + commandId: 'preview.start', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.confirm', + commandId: 'preview.start', + }); + }); + it('reads preview status through the authorized local project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1267,6 +10738,15 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: [], + }, + }; + } if (command === 'get_local_game_preview_status') { return { status: 'running', @@ -1292,6 +10772,11 @@ describe('AI 游戏创作 App 界面边界', () => { expect( await screen.findByText('预览运行中:http://127.0.0.1:3210/'), ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '填入打开预览命令' })); + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/open-preview', + ); expect(screen.getByText('preview.status')).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('get_local_game_preview_status', { projectPath: '/tmp/authorized-game', @@ -1303,6 +10788,88 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('requires confirmation for preview status when project policy asks for it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['preview.status'], + }, + }; + } + if (command === 'get_local_game_preview_status') { + return { + status: 'stopped', + url: null, + port: null, + root: null, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/preview-status'); + + expect(await screen.findByText('准备查看预览状态。')).not.toBeNull(); + expect(screen.getByText('preview.status')).not.toBeNull(); + expect( + screen.getByText('查看 /tmp/authorized-game 的预览状态'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'get_local_game_preview_status', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('预览未启动。')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '填入启动预览命令' })); + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/preview', + ); + expect(invoke).toHaveBeenCalledWith('get_local_game_preview_status', { + projectPath: '/tmp/authorized-game', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.pending', + commandId: 'preview.status', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.confirm', + commandId: 'preview.status', + }); + }); + it('runs static smoke and starts preview from chat through the authorized project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1345,6 +10912,9 @@ describe('AI 游戏创作 App 界面边界', () => { root: String(args?.projectPath ?? ''), }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return manifest; } @@ -1391,6 +10961,57 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('rejects unsafe initialized project paths before local project actions', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + return { + projectPath: 'relative-game', + manifestPath: 'relative-game/.agent/manifest.json', + manifest, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect(await screen.findAllByText('本地项目路径无效')).toHaveLength(2); + + submitChat('/run'); + + expect( + await screen.findByText('请先用 /project 设置本地项目。'), + ).not.toBeNull(); + expect(screen.queryByText('game.run_local')).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_conversation', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'run_limited_local_command', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'start_local_game_preview', + expect.anything(), + ); + }); + it('runs static smoke from chat through the authorized project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1417,6 +11038,9 @@ describe('AI 游戏创作 App 界面边界', () => { logPath: '.agent/logs/command.log', }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return manifest; } @@ -1441,13 +11065,294 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect(await screen.findByText('static smoke passed')).not.toBeNull(); + expect(await screen.findByText(/static smoke passed/)).not.toBeNull(); + expect( + screen.getByText(/日志:\.agent\/logs\/command\.log/), + ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('run_limited_local_command', { projectPath: '/tmp/authorized-game', commandId: 'game.static_smoke', }); }); + it('lists limited commands from chat without opening developer panels', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'get_limited_local_commands') { + return [{ id: 'game.custom_smoke', title: '自定义自检' }]; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/commands'); + + expect(await screen.findByText(/可运行受限命令:/)).not.toBeNull(); + expect(screen.getByText(/game\.custom_smoke · 自定义自检/)).not.toBeNull(); + expect(screen.queryByLabelText('开发环境')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('get_limited_local_commands'); + }); + + it('runs limited commands from the developer panel through the authorized local project path', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'is_local_project_directory_non_empty') { + return false; + } + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } + if (command === 'run_limited_local_command') { + return { + commandId: 'game.static_smoke', + status: 'completed', + output: 'static smoke passed', + logPath: '.agent/logs/command.log', + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '初始化' })); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull(); + }); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/other-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '静态入口自检' })); + + expect( + screen.getByText('运行 静态入口自检 于 /tmp/authorized-game'), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/static smoke passed/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('run_limited_local_command', { + projectPath: '/tmp/authorized-game', + commandId: 'game.static_smoke', + }); + expect(invoke).not.toHaveBeenCalledWith('run_limited_local_command', { + projectPath: '/tmp/other-game', + commandId: 'game.static_smoke', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.pending', + commandId: 'command.run_limited', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.confirm', + commandId: 'command.run_limited', + }); + }); + + it('cancels limited commands from the developer panel without running them', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'is_local_project_directory_non_empty') { + return false; + } + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } + if (command === 'run_limited_local_command') { + throw new Error('should not run limited command after cancel'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '初始化' })); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(screen.getByText('已打开:/tmp/authorized-game')).not.toBeNull(); + }); + + fireEvent.click(screen.getByRole('button', { name: '静态入口自检' })); + const command = screen.getByText('command.run_limited'); + fireEvent.click( + within(command.closest('.pending-command') as HTMLElement).getByRole( + 'button', + { name: '取消' }, + ), + ); + + expect(screen.getByText('已取消运行')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'run_limited_local_command', + expect.anything(), + ); + }); + + it('refreshes limited commands from the native runtime in the developer panel', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn(async (command: string) => { + if (command === 'is_local_project_directory_non_empty') { + return false; + } + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + return { + projectPath: '/tmp/authorized-game', + manifestPath: '/tmp/authorized-game/.agent/manifest.json', + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: '/tmp/authorized-game', files: [] }; + } + if (command === 'get_limited_local_commands') { + return [{ id: 'game.custom_smoke', title: '自定义自检' }]; + } + if (command === 'run_limited_local_command') { + return { + commandId: 'game.custom_smoke', + status: 'completed', + output: 'custom smoke passed', + logPath: '.agent/logs/custom.log', + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + const logPanel = within(screen.getByLabelText('日志')); + + fireEvent.click(logPanel.getByRole('button', { name: '刷新' })); + + expect( + await logPanel.findByRole('button', { name: '自定义自检' }), + ).not.toBeNull(); + expect(logPanel.queryByRole('button', { name: '静态入口自检' })).toBeNull(); + expect(screen.getByText('已读取 1 个内置命令')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('get_limited_local_commands'); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: '初始化' })); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开:/tmp/authorized-game'), + ).not.toBeNull(); + + fireEvent.click(logPanel.getByRole('button', { name: '自定义自检' })); + expect( + screen.getByText('运行 自定义自检 于 /tmp/authorized-game'), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/custom smoke passed/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('run_limited_local_command', { + projectPath: '/tmp/authorized-game', + commandId: 'game.custom_smoke', + }); + }); + it('reads project status from chat through the authorized project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1496,6 +11401,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return statusManifest; } @@ -1525,9 +11433,194 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', { projectPath: '/tmp/authorized-game', + commandId: 'project.status', }); }); + it('requires confirmation for project status when project policy asks for it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['project.status'], + }, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/status'); + + expect(await screen.findByText('准备读取项目状态。')).not.toBeNull(); + expect(screen.getByText('project.status')).not.toBeNull(); + expect( + screen.getByText('读取 /tmp/authorized-game 的项目状态'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'get_local_game_manifest', + expect.objectContaining({ commandId: 'project.status' }), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/项目:未命名游戏原型/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', { + projectPath: '/tmp/authorized-game', + commandId: 'project.status', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.pending', + commandId: 'project.status', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.confirm', + commandId: 'project.status', + }); + }); + + it('blocks project status before native read when project policy denies it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: ['project.status'], + confirmCommands: [], + }, + }; + } + if (command === 'get_local_game_manifest') { + throw new Error('should not read project status after deny'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/status'); + + expect( + await screen.findByText('项目权限策略拒绝执行:project.status'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'get_local_game_manifest', + expect.objectContaining({ commandId: 'project.status' }), + ); + }); + + it('cancels project status policy confirmation without reading status', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['project.status'], + }, + }; + } + if (command === 'get_local_game_manifest') { + throw new Error('should wait for project status confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/status'); + + const statusCommand = await screen.findByText('project.status'); + fireEvent.click( + within( + statusCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + expect(await screen.findByText('已取消读取项目状态')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'get_local_game_manifest', + expect.objectContaining({ commandId: 'project.status' }), + ); + }); + it('lists local project files from chat through the authorized project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -1546,6 +11639,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'list_local_project_files') { return { projectPath: String(args?.projectPath ?? ''), @@ -1579,16 +11675,257 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('requires confirmation for file list when project policy asks for it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['file.list'], + }, + }; + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [{ path: 'game/index.html', kind: 'file', size: 128 }], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/files'); + + expect(await screen.findByText('准备列出项目文件。')).not.toBeNull(); + expect(screen.getByText('file.list')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'list_local_project_files', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/本地项目文件:/)).not.toBeNull(); + expect(screen.getByText(/- game\/index\.html/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('list_local_project_files', { + projectPath: '/tmp/authorized-game', + }); + }); + + it('builds the local project index from chat through the authorized project path', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const files = Array.from({ length: 14 }, (_, index) => ({ + path: `game/file-${index + 1}.html`, + size: index + 10, + checksum: `fnv1a64:${index + 1}`, + })); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: [], + }, + }; + } + if (command === 'build_local_project_index') { + return { + projectPath: String(args?.projectPath ?? ''), + indexPath: '.agent/project.index.json', + fileCount: files.length, + totalBytes: 231, + files, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/index'); + + expect(await screen.findByText(/索引:14 个文件,231B/)).not.toBeNull(); + expect( + screen.getByText(/路径:\.agent\/project\.index\.json/), + ).not.toBeNull(); + expect(screen.getByText(/- game\/file-12\.html · 21B/)).not.toBeNull(); + expect(screen.queryByText(/- game\/file-13\.html/)).toBeNull(); + expect(screen.getByText(/- 还有 2 项/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('build_local_project_index', { + projectPath: '/tmp/authorized-game', + }); + }); + + it('requires confirmation for project index when project policy asks for it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['project.index'], + }, + }; + } + if (command === 'build_local_project_index') { + return { + projectPath: String(args?.projectPath ?? ''), + indexPath: '.agent/project.index.json', + fileCount: 1, + totalBytes: 12, + files: [ + { + path: 'game/index.html', + size: 12, + checksum: 'fnv1a64:index', + }, + ], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/index'); + + expect(await screen.findByText('准备刷新本地项目索引。')).not.toBeNull(); + expect(screen.getByText('project.index')).not.toBeNull(); + expect( + screen.getByText('刷新 /tmp/authorized-game/.agent/project.index.json'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'build_local_project_index', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/索引:1 个文件,12B/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('build_local_project_index', { + projectPath: '/tmp/authorized-game', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.pending', + commandId: 'project.index', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.confirm', + commandId: 'project.index', + }); + }); + it('checks LLM config from chat without leaking the API key value', async () => { const invoke = vi.fn(async (command: string) => { if (command === 'check_game_creator_llm_config') { return { configured: true, - apiKeyPresent: true, + apiKeyPresent: false, baseUrl: 'https://llm.example.test/v1', model: 'gpt-test', apiKind: 'openai_responses', + stream: false, error: null, + agents: [ + { + agentId: 'planner', + label: 'Planner', + configured: true, + apiKeyPresent: true, + baseUrl: 'https://planner.example.test/v1', + model: 'planner-model', + apiKind: 'anthropic', + stream: true, + error: null, + }, + { + agentId: 'generator', + label: 'Generator', + configured: false, + apiKeyPresent: false, + baseUrl: 'https://generator.example.test/v1', + model: 'generator-model', + apiKind: 'openai_chat', + stream: false, + error: + 'LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key', + }, + ], }; } throw new Error(`unexpected invoke ${command}`); @@ -1598,11 +11935,47 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/llm-status'); - expect( - await screen.findByText( - 'LLM 已配置:gpt-test @ https://llm.example.test/v1,openai_responses,API Key 已读取。', - ), - ).not.toBeNull(); + expect(await screen.findByText(/LLM 已配置:gpt-test/)).not.toBeNull(); + expect(screen.getByLabelText('聊天').textContent).toContain( + 'LLM 已配置:gpt-test @ https://llm.example.test/v1,openai_responses,流式 关闭,API Key 未读取。', + ); + expect(screen.getByLabelText('聊天').textContent).toContain( + 'Planner:已配置,planner-model @ https://planner.example.test/v1,anthropic,流式 开启,API Key 已读取', + ); + expect(screen.getByLabelText('聊天').textContent).toContain( + 'Generator:未就绪,generator-model @ https://generator.example.test/v1,openai_chat,流式 关闭,API Key 未读取,错误:LLM 未配置:请在 agentLlm.generator.apiKey 中设置 API Key', + ); + expect(screen.queryByText(/sk-test-secret/)).toBeNull(); + expect(screen.queryByText(/planner-secret/)).toBeNull(); + expect(screen.queryByText(/generator-secret/)).toBeNull(); + expect(invoke).toHaveBeenCalledWith('check_game_creator_llm_config'); + }); + + it('checks LLM config from the main window shortcut without leaking the API key value', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'check_game_creator_llm_config') { + return { + configured: true, + apiKeyPresent: true, + baseUrl: 'https://llm.example.test/v1', + model: 'gpt-test', + apiKind: 'openai_responses', + stream: false, + error: null, + agents: [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + fireEvent.click(screen.getByRole('button', { name: 'LLM状态' })); + + expect(await screen.findByText(/LLM 已配置:gpt-test/)).not.toBeNull(); + expect(screen.getByLabelText('聊天').textContent).toContain( + 'LLM 已配置:gpt-test @ https://llm.example.test/v1,openai_responses,流式 关闭,API Key 已读取。', + ); expect(screen.queryByText(/sk-test-secret/)).toBeNull(); expect(invoke).toHaveBeenCalledWith('check_game_creator_llm_config'); }); @@ -1617,6 +11990,20 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'append_local_permission_log') { return {}; } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } if (command !== 'init_local_game_project') { throw new Error(`unexpected invoke ${command}`); } @@ -1637,7 +12024,9 @@ describe('AI 游戏创作 App 界面边界', () => { expect( await screen.findByText('已设置本地项目:/tmp/authorized-game'), ).not.toBeNull(); - submitChat('做一个反弹弹幕厨房游戏'); + await act(async () => { + submitChat('做一个反弹弹幕厨房游戏'); + }); expect(screen.getByText('game.generate_draft')).not.toBeNull(); expect( @@ -1645,6 +12034,17 @@ describe('AI 游戏创作 App 界面边界', () => { '调用 LLM Planner / Generator,编排 6 组角色 brief,写入 /tmp/authorized-game/game、assets、memory、exports,通过 Evaluator 和自检后启动本地 HTTP 预览并交给外部浏览器', ), ).not.toBeNull(); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', { + projectPath: '/tmp/authorized-game', + agentId: null, + message: { + role: 'user', + content: '做一个反弹弹幕厨房游戏', + agentId: null, + }, + }); + }); }); it('streams agent generation progress into the user chat', async () => { @@ -1662,15 +12062,68 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'append_local_permission_log') { return {}; } - if (command !== 'init_local_game_project') { - throw new Error(`unexpected invoke ${command}`); + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; } - const projectPath = String(args?.projectPath ?? ''); - return { - projectPath, - manifestPath: `${projectPath}/.agent/manifest.json`, - manifest, - }; + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: '1', + ...(args?.message as Record), + updatedAt: 1, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } + throw new Error(`unexpected invoke ${command}`); }, ); const listen = vi.fn( @@ -1977,6 +12430,9 @@ describe('AI 游戏创作 App 界面边界', () => { root: String(args?.projectPath ?? ''), }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return generatedManifest; } @@ -1996,7 +12452,7 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - screen.getByText(/开始调用 LLM:Planner 正在整理规格。/), + await screen.findByText(/开始调用 LLM:Planner 正在整理规格。/), ).not.toBeNull(); expect(screen.getByText(/Generator 生成代码和资产清单/)).not.toBeNull(); expect( @@ -2053,6 +12509,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', }); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath: '/tmp/authorized-game', @@ -2062,6 +12519,142 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('rejects unsafe generated project paths before preview side effects', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'generate_local_game_draft') { + return { + projectPath: 'relative-game', + gameIndexPath: 'relative-game/game/index.html', + designPath: 'relative-game/game/game_design.md', + shortMemoryPath: 'relative-game/memory/session.md', + longMemoryPath: 'relative-game/memory/project.md', + manifest, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('做一个厨房弹幕游戏'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('生成结果项目路径无效')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('generate_local_game_draft', { + projectPath: '/tmp/authorized-game', + prompt: '做一个厨房弹幕游戏', + }); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'start_local_game_preview', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'open_local_game_preview', + expect.anything(), + ); + }); + + it('opens runtime config when game generation is missing LLM configuration', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'generate_local_game_draft') { + throw new Error( + 'LLM 未配置:请在 /tmp/game-creator.config.json 的 llm.apiKey 中设置 API Key', + ); + } + if (command === 'read_local_project_file') { + throw new Error('trace missing'); + } + if (command === 'read_game_creator_app_config') { + return { + path: '/tmp/game-creator.config.json', + config: { + llm: { + apiKey: '', + baseUrl: 'https://api.example.test/v1', + model: 'gpt-4.1', + apiKind: 'openai_responses', + stream: false, + requestTimeoutMs: 60000, + maxRetries: 0, + retryBackoffMs: 500, + }, + editorApi: { + baseUrl: 'https://editor.example.test', + apiKey: '', + }, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('做一个厨房弹幕游戏'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + 'LLM 未配置:请在 /tmp/game-creator.config.json 的 llm.apiKey 中设置 API Key', + ), + ).not.toBeNull(); + expect( + await screen.findByRole('dialog', { name: '运行时配置' }), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config'); + }); + it('uploads files from chat through the authorized local project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -2101,6 +12694,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifestPath: `${projectPath}/.agent/manifest.json`, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return uploadedManifest; } @@ -2142,11 +12738,537 @@ describe('AI 游戏创作 App 界面边界', () => { expect( await screen.findByText(/upload · assets\/uploads\/hero\.png · uploaded/), ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '读取首个资产' })); + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/read assets/uploads/hero.png', + ); expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', { projectPath: '/tmp/authorized-game', + commandId: 'asset.list', }); }); + it('registers an existing project asset from chat after confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'register_local_asset') { + const projectPath = String(args?.projectPath ?? ''); + return { + id: 'asset-existing', + localPath: String(args?.localPath ?? ''), + absolutePath: `${projectPath}/${String(args?.localPath ?? '')}`, + manifestPath: `${projectPath}/.agent/manifest.json`, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/asset-register assets/hero.png sprite image/png'); + + expect( + await screen.findByText('asset.register · assets/hero.png'), + ).not.toBeNull(); + expect( + screen.getByText( + '登记 /tmp/authorized-game/assets/hero.png · sprite · image/png', + ), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'register_local_asset', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('已登记资产:assets/hero.png'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('register_local_asset', { + projectPath: '/tmp/authorized-game', + localPath: 'assets/hero.png', + kind: 'sprite', + mediaType: 'image/png', + sourceKind: 'generated', + canvasProjectId: '', + resourceId: '', + assetObjectId: '', + taskId: '', + prompt: '', + model: '', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.confirm', + commandId: 'asset.register', + }); + }); + + it('cancels asset registration from chat before invoking Tauri', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'register_local_asset') { + throw new Error('should not register after cancel'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/asset-register assets/hero.png'); + const command = await screen.findByText('asset.register · assets/hero.png'); + fireEvent.click( + within(command.closest('.pending-command') as HTMLElement).getByRole( + 'button', + { name: '取消' }, + ), + ); + + expect(screen.getByText('已取消。')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'register_local_asset', + expect.anything(), + ); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.cancel', + commandId: 'asset.register', + }), + ); + }); + + it('requires project policy confirmation before registering assets from chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['asset.register'], + }, + }; + } + if (command === 'register_local_asset') { + const projectPath = String(args?.projectPath ?? ''); + return { + id: 'asset-existing', + localPath: String(args?.localPath ?? ''), + absolutePath: `${projectPath}/${String(args?.localPath ?? '')}`, + manifestPath: `${projectPath}/.agent/manifest.json`, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/asset-register assets/hero.png sprite image/png'); + + expect( + await screen.findByText('准备登记项目资产:assets/hero.png'), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('准备登记项目资产。')).not.toBeNull(); + expect( + screen.getByText('登记 /tmp/authorized-game/assets/hero.png'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'register_local_asset', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('已登记资产:assets/hero.png'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('register_local_asset', { + projectPath: '/tmp/authorized-game', + localPath: 'assets/hero.png', + kind: 'sprite', + mediaType: 'image/png', + sourceKind: 'generated', + canvasProjectId: '', + resourceId: '', + assetObjectId: '', + taskId: '', + prompt: '', + model: '', + }); + }); + + it('rejects unsafe asset registration paths from chat before confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'register_local_asset') { + throw new Error('should not register unsafe path'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/asset-register ../hero.png'); + + expect( + await screen.findByText('资产路径必须是项目内相对路径。'), + ).not.toBeNull(); + expect(screen.queryByText(/asset\.register/)).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'register_local_asset', + expect.anything(), + ); + }); + + it('confirms asset registration from the developer asset panel', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'register_local_asset') { + const projectPath = String(args?.projectPath ?? ''); + return { + id: 'asset-registered', + localPath: String(args?.localPath ?? ''), + absolutePath: `${projectPath}/${String(args?.localPath ?? '')}`, + manifestPath: `${projectPath}/.agent/manifest.json`, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + const confirm = vi.spyOn(window, 'confirm'); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.click(screen.getByRole('button', { name: '登记资产' })); + + expect(screen.getByText('asset.register')).not.toBeNull(); + expect( + screen.getByText('登记 /tmp/genarrative-ai-game-draft/assets/hero.png'), + ).not.toBeNull(); + expect(confirm).not.toHaveBeenCalled(); + expect(invoke).not.toHaveBeenCalledWith( + 'register_local_asset', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('已登记:assets/hero.png')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('register_local_asset', { + projectPath: '/tmp/genarrative-ai-game-draft', + localPath: 'assets/hero.png', + kind: 'asset', + mediaType: 'application/octet-stream', + sourceKind: 'generated', + canvasProjectId: '', + resourceId: '', + assetObjectId: '', + taskId: '', + prompt: '', + model: '', + }); + }); + + it('cancels asset registration from the developer asset panel', () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'register_local_asset') { + throw new Error('should not register asset after cancel'); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + fireEvent.click(screen.getByRole('button', { name: '登记资产' })); + const command = screen.getByText('asset.register'); + fireEvent.click( + within(command.closest('.pending-command') as HTMLElement).getByRole( + 'button', + { name: '取消' }, + ), + ); + + expect(screen.getByText('已取消登记资产')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'register_local_asset', + expect.anything(), + ); + }); + + it('rejects unsafe developer asset registration before confirmation or invoke', () => { + const invoke = vi.fn(); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const assetPanel = within(screen.getByLabelText('文件和资产')); + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: 'relative-project' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '登记资产' })); + expect(screen.getByText('请提供本地项目绝对路径。')).not.toBeNull(); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project' }, + }); + fireEvent.change(screen.getByLabelText('资产路径'), { + target: { value: '../hero.png' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '登记资产' })); + expect(screen.getByText('资产路径必须是项目内相对路径。')).not.toBeNull(); + + fireEvent.change(screen.getByLabelText('资产路径'), { + target: { value: 'assets/hero.png' }, + }); + fireEvent.change(screen.getByLabelText('资产来源'), { + target: { value: 'canvas' }, + }); + fireEvent.change(screen.getByLabelText('画板项目'), { + target: { value: 'canvas-project-1' }, + }); + fireEvent.change(screen.getByLabelText('资源 ID'), { + target: { value: '' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '登记资产' })); + expect(screen.getByText('请提供资源 ID 或资产对象 ID。')).not.toBeNull(); + + expect(screen.queryByText('asset.register')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('truncates long asset lists in chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const assetManifest = { + ...manifest, + assets: Array.from({ length: 22 }, (_, index) => ({ + id: `asset-${index + 1}`, + kind: 'generated', + mediaType: 'image/png', + localPath: `assets/generated/asset-${index + 1}.png`, + source: { kind: 'generated' }, + })), + } satisfies typeof manifest; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'get_local_game_manifest') { + return assetManifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/assets'); + + expect(await screen.findByText(/本地项目资产:/)).not.toBeNull(); + expect(screen.getByText(/asset-20\.png/)).not.toBeNull(); + expect(screen.queryByText(/asset-21\.png/)).toBeNull(); + expect(screen.getByText(/- 还有 2 个资产/)).not.toBeNull(); + }); + + it('cancels asset list policy confirmation without reading assets', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['asset.list'], + }, + }; + } + if (command === 'get_local_game_manifest') { + throw new Error('should wait for asset list confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/assets'); + + const assetCommand = await screen.findByText('asset.list'); + fireEvent.click( + within(assetCommand.closest('.pending-command') as HTMLElement).getByRole( + 'button', + { name: '取消' }, + ), + ); + + expect(await screen.findByText('已取消读取项目资产')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'get_local_game_manifest', + expect.objectContaining({ commandId: 'asset.list' }), + ); + }); + it('reads local project files from chat through the authorized project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -2165,6 +13287,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'read_local_project_file') { return { path: String(args?.relativePath ?? ''), @@ -2193,6 +13318,146 @@ describe('AI 游戏创作 App 界面边界', () => { expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: 'game/index.html', + commandId: 'file.read', + }); + }); + + it('requires confirmation for file reads when project policy asks for it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['file.read'], + }, + }; + } + if (command === 'read_local_project_file') { + return { + path: String(args?.relativePath ?? ''), + absolutePath: `${String(args?.projectPath ?? '')}/${String( + args?.relativePath ?? '', + )}`, + content: '', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/read game/index.html'); + + expect(await screen.findByText('准备读取项目文件。')).not.toBeNull(); + expect(screen.getByText('file.read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.objectContaining({ commandId: 'file.read' }), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/文件:game\/index\.html/)).not.toBeNull(); + expect(screen.getByText(/<\/canvas>/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: 'game/index.html', + commandId: 'file.read', + }); + }); + + it('rejects unsafe project file reads before calling Tauri', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: '1', + ...(args?.message as Record), + updatedAt: 1, + }, + ], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/read ../secret.txt'); + expect(screen.getByText('文件路径必须是项目内相对路径。')).not.toBeNull(); + + submitChat('/read /tmp/secret.txt'); + expect(screen.getAllByText('文件路径必须是项目内相对路径。').length).toBe( + 2, + ); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.objectContaining({ relativePath: '../secret.txt' }), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.objectContaining({ relativePath: '/tmp/secret.txt' }), + ); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.objectContaining({ + message: expect.objectContaining({ + content: '文件路径必须是项目内相对路径。', + }), + }), + ); }); }); @@ -2214,6 +13479,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'get_local_game_manifest') { return manifest; } @@ -2238,6 +13506,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.getByText(/下一步:策划组 \/ Director/)).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('get_local_game_manifest', { projectPath: '/tmp/authorized-game', + commandId: 'task.list', }); }); @@ -2376,6 +13645,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'read_local_project_file') { return { path: '.agent/run.latest.json', @@ -2394,6 +13666,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect( await screen.findByText('已设置本地项目:/tmp/authorized-game'), ).not.toBeNull(); + invoke.mockClear(); submitChat('/trace'); @@ -2411,6 +13684,168 @@ describe('AI 游戏创作 App 界面边界', () => { expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + }); + + it('shows an empty agent trace message before the first run', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/trace'); + + expect( + await screen.findByText( + '暂无最近 Agent trace。先生成一次游戏草案后再查看。', + ), + ).not.toBeNull(); + expect(screen.getByLabelText('聊天').textContent).not.toContain( + 'No such file or directory', + ); + }); + + it('requires confirmation for agent trace reads when project policy asks for it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-chat-trace-confirm', + commandId: 'game.generate_draft', + status: 'passed', + passes: 1, + maxPasses: 3, + toolCallCount: 3, + maxToolCalls: 128, + stopReason: 'evaluator-passed', + goal: '做一个厨房弹幕游戏', + coordination: 'Planner -> Generator', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个厨房弹幕游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: [], + }, + passPlans: [], + nextStep: 'preview-playtest', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.trace_read'], + }, + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/trace'); + + expect( + await screen.findByText('准备读取 Agent run trace。'), + ).not.toBeNull(); + expect(screen.getByText('agent.trace_read')).not.toBeNull(); + expect( + screen.getByText('读取 /tmp/authorized-game 的最近 Agent run trace'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.objectContaining({ commandId: 'agent.trace_read' }), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText(/Run:run-chat-trace-confirm/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.pending', + commandId: 'agent.trace_read', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.confirm', + commandId: 'agent.trace_read', }); }); @@ -2444,7 +13879,7 @@ describe('AI 游戏创作 App 界面边界', () => { tasks: createGameCreationAppSeedTasks(), }, passPlans: [], - nextStep: 'runner-claim', + nextStep: 'rerun-now', error: null, updatedAt: 1, }; @@ -2461,6 +13896,9 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'control_agent_run') { const action = String(args?.action ?? ''); const detail = String(args?.detail ?? ''); @@ -2468,7 +13906,7 @@ describe('AI 游戏创作 App 界面边界', () => { status: { status: 'pending', lifecycleStatus: 'pending', - nextStep: 'runner-claim', + nextStep: 'rerun-now', message: 'run run-control-chat 当前状态:pending / pending', }, kill: { @@ -2478,16 +13916,17 @@ describe('AI 游戏创作 App 界面边界', () => { message: 'run run-control-chat 已标记为 killed', }, retry: { - status: 'pending', - lifecycleStatus: 'pending', - nextStep: 'runner-claim', - message: 'run run-control-chat 已重试,等待下一次 claim', + status: 'passed', + lifecycleStatus: 'done', + nextStep: 'preview-playtest', + message: + 'run run-control-chat 已重试,已重新运行为 run-control-chat-next:game/index.html', }, resume: { - status: 'pending', - lifecycleStatus: 'pending', - nextStep: 'runner-claim', - message: `run run-control-chat 已恢复:${detail}`, + status: 'passed', + lifecycleStatus: 'done', + nextStep: 'preview-playtest', + message: `run run-control-chat 已恢复:${detail},已重新运行为 run-control-chat-next:game/index.html`, }, }[action]; if (!resultByAction) { @@ -2527,6 +13966,19 @@ describe('AI 游戏创作 App 界面边界', () => { /run run-control-chat 当前状态:pending \/ pending/, ), ).not.toBeNull(); + expect(screen.getByText(/run:run-control-chat/)).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '读取 Run 输出' })); + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/read .agent/output.jsonl', + ); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_project_file', + expect.objectContaining({ relativePath: '.agent/output.jsonl' }), + ); + fireEvent.change(screen.getByLabelText('创作想法'), { + target: { value: '' }, + }); expect(invoke).toHaveBeenCalledWith('control_agent_run', { projectPath: '/tmp/authorized-game', action: 'status', @@ -2559,12 +14011,14 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.getByText('agent.retry')).not.toBeNull(); expect( screen.getByText( - '标记 /tmp/authorized-game/.agent/run.latest.json 为 pending,等待 runner claim', + '使用 /tmp/authorized-game/.agent/run.latest.json 的目标重新运行一次', ), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText(/run run-control-chat 已重试,等待下一次 claim/), + await screen.findByText( + /run run-control-chat 已重试,已重新运行为 run-control-chat-next/, + ), ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('control_agent_run', { projectPath: '/tmp/authorized-game', @@ -2576,12 +14030,14 @@ describe('AI 游戏创作 App 界面边界', () => { expect(screen.getByText('agent.resume')).not.toBeNull(); expect( screen.getByText( - '附加用户说明并标记 /tmp/authorized-game/.agent/run.latest.json 为 pending', + '附加说明「继续修复输入监听」,继续运行 /tmp/authorized-game/.agent/run.latest.json 的目标', ), ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText(/run run-control-chat 已恢复:继续修复输入监听/), + await screen.findByText( + /run run-control-chat 已恢复:继续修复输入监听,已重新运行为 run-control-chat-next/, + ), ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('control_agent_run', { projectPath: '/tmp/authorized-game', @@ -2590,6 +14046,972 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('shows an empty agent run status message before the first run', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'control_agent_run') { + throw new Error( + '读取 Agent run trace 失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/agent-status'); + + expect( + await screen.findByText( + '暂无最近 Agent run。先生成一次游戏草案后再查看状态。', + ), + ).not.toBeNull(); + expect(screen.getByLabelText('聊天').textContent).not.toContain( + 'No such file or directory', + ); + }); + + it('shows an empty agent run control message before the first run', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'control_agent_run') { + throw new Error( + '读取 Agent run trace 失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/agent-kill'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + '暂无可控制的 Agent run。先生成一次游戏草案后再操作。', + ), + ).not.toBeNull(); + expect(screen.getByLabelText('聊天').textContent).not.toContain( + 'No such file or directory', + ); + }); + + it('blocks pending agent run control when project policy denies it', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: ['agent.kill'], + confirmCommands: [], + }, + }; + } + if (command === 'control_agent_run') { + throw new Error('should not control agent run after deny'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/agent-kill'); + expect(screen.getByText('agent.kill')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('项目权限策略拒绝执行:agent.kill'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'control_agent_run', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_permission_log', + expect.objectContaining({ + event: 'permission.confirm', + commandId: 'agent.kill', + }), + ); + }); + + it('requires project policy confirmation before controlling agent run lifecycle', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-kill-confirm', + commandId: 'game.generate_draft', + status: 'killed', + lifecycleStatus: 'killed', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'killed', + goal: '做一个反弹弹幕厨房游戏', + coordination: 'filesystem', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个反弹弹幕厨房游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'rerun-now', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.kill'], + }, + }; + } + if (command === 'control_agent_run') { + return { + runId: 'run-kill-confirm', + status: 'killed', + lifecycleStatus: 'killed', + nextStep: 'rerun-now', + message: 'agent run killed', + activityPath: '/tmp/authorized-game/.agent/activity.jsonl', + outputPath: '/tmp/authorized-game/.agent/output.jsonl', + contextBundlePath: + '/tmp/authorized-game/.agent/context.bundle.json', + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/agent-kill'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('准备执行 Agent run 操作。')).not.toBeNull(); + expect(screen.getByText('agent.kill')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'control_agent_run', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'kill', + detail: undefined, + }); + }); + }); + + it('requires project policy confirmation before reading agent run status from chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-status-confirm', + commandId: 'game.generate_draft', + status: 'pending', + lifecycleStatus: 'pending', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'running', + goal: '做一个反弹弹幕厨房游戏', + coordination: 'filesystem', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个反弹弹幕厨房游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'rerun-now', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.run_status'], + }, + }; + } + if (command === 'control_agent_run') { + return { + runId: 'run-status-confirm', + status: 'pending', + lifecycleStatus: 'pending', + nextStep: 'rerun-now', + message: 'run run-status-confirm 当前状态:pending / pending', + activityPath: '/tmp/authorized-game/.agent/activity.jsonl', + outputPath: '/tmp/authorized-game/.agent/output.jsonl', + contextBundlePath: + '/tmp/authorized-game/.agent/context.bundle.json', + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/agent-status'); + + expect(await screen.findByText('准备查看 Agent run 状态。')).not.toBeNull(); + expect(screen.getByText('agent.run_status')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'control_agent_run', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + /run run-status-confirm 当前状态:pending \/ pending/, + ), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'status', + detail: undefined, + }); + }); + + it('cancels agent run status policy confirmation without reading status', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.run_status'], + }, + }; + } + if (command === 'control_agent_run') { + throw new Error('should wait for agent run status confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/agent-status'); + + const agentStatusCommand = await screen.findByText('agent.run_status'); + fireEvent.click( + within( + agentStatusCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + expect( + await screen.findByText('run: 已取消读取 Agent run 状态'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'control_agent_run', + expect.anything(), + ); + }); + + it('cancels pending agent run control without invoking native control', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'control_agent_run') { + throw new Error('should not control agent run after cancel'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/agent-kill'); + const agentCommand = screen.getByText('agent.kill'); + fireEvent.click( + within(agentCommand.closest('.pending-command') as HTMLElement).getByRole( + 'button', + { name: '取消' }, + ), + ); + + expect( + await screen.findByText('run: 已取消 Agent run 操作'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'control_agent_run', + expect.anything(), + ); + }); + + it('confirms before reading trace after agent run status from chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-status-trace-confirm', + commandId: 'game.generate_draft', + status: 'pending', + lifecycleStatus: 'pending', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'running', + goal: '做一个反弹弹幕厨房游戏', + coordination: 'filesystem', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个反弹弹幕厨房游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'rerun-now', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.trace_read'], + }, + }; + } + if (command === 'control_agent_run') { + return { + runId: 'run-status-trace-confirm', + status: 'pending', + lifecycleStatus: 'pending', + nextStep: 'rerun-now', + message: 'run run-status-trace-confirm 当前状态:pending / pending', + activityPath: '/tmp/authorized-game/.agent/activity.jsonl', + outputPath: '/tmp/authorized-game/.agent/output.jsonl', + contextBundlePath: + '/tmp/authorized-game/.agent/context.bundle.json', + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/agent-status'); + + expect( + await screen.findByText( + /run run-status-trace-confirm 当前状态:pending \/ pending/, + ), + ).not.toBeNull(); + expect(await screen.findByText('agent.trace_read')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'status', + detail: undefined, + }); + expect(invoke).not.toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + }); + }); + + it('controls agent run lifecycle from the agent status panel', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-panel-control', + commandId: 'game.generate_draft', + status: 'pending', + lifecycleStatus: 'pending', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'retry-requested', + goal: '做一个反弹弹幕厨房游戏', + coordination: 'filesystem', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个反弹弹幕厨房游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'rerun-now', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'control_agent_run') { + const action = String(args?.action ?? ''); + return { + runId: 'run-panel-control', + status: action === 'kill' ? 'killed' : 'pending', + lifecycleStatus: action === 'kill' ? 'killed' : 'pending', + nextStep: action === 'retry' ? 'preview-playtest' : 'rerun-now', + message: `panel ${action}`, + activityPath: '/tmp/authorized-game/.agent/activity.jsonl', + outputPath: '/tmp/authorized-game/.agent/output.jsonl', + contextBundlePath: + '/tmp/authorized-game/.agent/context.bundle.json', + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: '状态' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'status', + detail: undefined, + }); + }); + + fireEvent.click(screen.getByRole('button', { name: '终止' })); + expect(screen.getByText('agent.kill')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'kill', + detail: undefined, + }); + }); + + fireEvent.click(screen.getByRole('button', { name: '重试' })); + expect(screen.getByText('agent.retry')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'retry', + detail: undefined, + }); + }); + + fireEvent.click(screen.getByRole('button', { name: '继续' })); + expect(screen.getByText('agent.resume')).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'resume', + detail: undefined, + }); + }); + }); + + it('requires project policy confirmation before reading agent run status from the panel', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-panel-status-confirm', + commandId: 'game.generate_draft', + status: 'pending', + lifecycleStatus: 'pending', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'running', + goal: '做一个反弹弹幕厨房游戏', + coordination: 'filesystem', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个反弹弹幕厨房游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'rerun-now', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.run_status'], + }, + }; + } + if (command === 'control_agent_run') { + return { + runId: 'run-panel-status-confirm', + status: 'pending', + lifecycleStatus: 'pending', + nextStep: 'rerun-now', + message: 'panel status', + activityPath: '/tmp/authorized-game/.agent/activity.jsonl', + outputPath: '/tmp/authorized-game/.agent/output.jsonl', + contextBundlePath: + '/tmp/authorized-game/.agent/context.bundle.json', + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: '状态' })); + + expect(await screen.findByText('agent.run_status')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'control_agent_run', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'status', + detail: undefined, + }); + }); + }); + + it('confirms before reading trace after agent run status from the panel', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace: GameCreationAgentRunTrace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-panel-status-trace-confirm', + commandId: 'game.generate_draft', + status: 'pending', + lifecycleStatus: 'pending', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'running', + goal: '做一个反弹弹幕厨房游戏', + coordination: 'filesystem', + steps: [], + artifacts: [], + taskGraph: { + goal: '做一个反弹弹幕厨房游戏', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: createGameCreationAppSeedTasks(), + }, + passPlans: [], + nextStep: 'rerun-now', + error: null, + updatedAt: 1, + }; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['agent.trace_read'], + }, + }; + } + if (command === 'control_agent_run') { + return { + runId: 'run-panel-status-trace-confirm', + status: 'pending', + lifecycleStatus: 'pending', + nextStep: 'rerun-now', + message: 'panel status', + activityPath: '/tmp/authorized-game/.agent/activity.jsonl', + outputPath: '/tmp/authorized-game/.agent/output.jsonl', + contextBundlePath: + '/tmp/authorized-game/.agent/context.bundle.json', + }; + } + if (command === 'read_local_project_file') { + return { + path: '.agent/run.latest.json', + absolutePath: `${String(args?.projectPath ?? '')}/.agent/run.latest.json`, + content: JSON.stringify(trace), + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + fireEvent.click(screen.getByRole('button', { name: '状态' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('control_agent_run', { + projectPath: '/tmp/authorized-game', + action: 'status', + detail: undefined, + }); + }); + expect(await screen.findByText('agent.trace_read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith('read_local_project_file', { + projectPath: '/tmp/authorized-game', + relativePath: '.agent/run.latest.json', + commandId: 'agent.trace_read', + }); + }); + }); + it('manages long memory from chat through the authorized local project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', @@ -2601,6 +15023,9 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'append_local_permission_log') { return {}; } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } if (command === 'init_local_game_project') { const projectPath = String(args?.projectPath ?? ''); return { @@ -2681,7 +15106,7 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); - it('imports canvas assets from chat with asset object ids', async () => { + it('requires project policy confirmation before reading memory from chat', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', '未命名游戏原型', @@ -2699,6 +15124,344 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['memory.read'], + }, + }; + } + if (command === 'read_local_game_memory') { + return { + scope: args?.scope, + path: 'memory/project.md', + content: '# 项目长期记忆\n', + exists: true, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/memory'); + + expect(await screen.findByText('准备读取项目记忆。')).not.toBeNull(); + expect(screen.getByText('memory.read')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_game_memory', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/长期记忆:/)).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_game_memory', { + projectPath: '/tmp/authorized-game', + scope: 'long', + }); + }); + + it('cancels project memory read confirmation from chat', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['memory.read'], + }, + }; + } + if (command === 'read_local_game_memory') { + throw new Error('should wait for confirmation'); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/memory'); + + expect(await screen.findByText('准备读取项目记忆。')).not.toBeNull(); + const memoryReadCommand = screen.getByText('memory.read'); + fireEvent.click( + within( + memoryReadCommand.closest('.pending-command') as HTMLElement, + ).getByRole('button', { name: '取消' }), + ); + + await waitFor(() => { + expect(screen.queryByText('memory.read')).toBeNull(); + }); + expect(screen.getByText('已取消读取项目记忆。')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_game_memory', + expect.anything(), + ); + }); + + it('rejects unknown memory scopes before reading or deleting memory', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: '1', + ...(args?.message as Record), + updatedAt: 1, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/memory typo'); + expect( + await screen.findByText('格式:/memory [short|long|blackboard]'), + ).not.toBeNull(); + + submitChat('/forget-memory typo'); + expect( + await screen.findByText('格式:/forget-memory [short|long|blackboard]'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'read_local_game_memory', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'delete_local_game_memory', + expect.anything(), + ); + expect(screen.queryByText('memory.delete')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + }); + + it('manages blackboard memory from chat through the authorized local project path', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + let blackboardMemory = '# 项目黑板\n'; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_game_memory') { + return { + scope: args?.scope, + path: 'memory/blackboard.md', + content: blackboardMemory, + exists: true, + }; + } + if (command === 'write_local_game_memory') { + blackboardMemory = String(args?.content ?? ''); + return { + scope: args?.scope, + path: 'memory/blackboard.md', + content: blackboardMemory, + exists: true, + }; + } + if (command === 'delete_local_game_memory') { + blackboardMemory = ''; + return { + scope: args?.scope, + path: 'memory/blackboard.md', + content: '', + exists: false, + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/memory blackboard'); + expect(await screen.findByText(/黑板记忆:/)).not.toBeNull(); + + submitChat('/remember blackboard 共享美术约束'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect(await screen.findByText('已追加黑板记忆。')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('write_local_game_memory', { + projectPath: '/tmp/authorized-game', + scope: 'blackboard', + content: '# 项目黑板\n- 共享美术约束\n', + }); + + submitChat('/memory-set 黑板 统一使用俯视角'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect(await screen.findByText('已保存黑板记忆。')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('write_local_game_memory', { + projectPath: '/tmp/authorized-game', + scope: 'blackboard', + content: '统一使用俯视角', + }); + + submitChat('/forget-memory blackboard'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect(await screen.findByText('已删除黑板记忆。')).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('delete_local_game_memory', { + projectPath: '/tmp/authorized-game', + scope: 'blackboard', + }); + }); + + it('imports canvas assets from chat with asset object ids', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: '1', + ...(args?.message as Record), + updatedAt: 1, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } if (command === 'import_canvas_asset') { return { id: 'asset-1', @@ -2718,16 +15481,23 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText('已设置本地项目:/tmp/authorized-game'), + await screen.findByText('已打开:/tmp/authorized-game'), ).not.toBeNull(); submitChat( '/import-canvas-asset assets/hero.png canvas-project-1 object:asset-object-1 character image/png', ); + expect( + await screen.findByText( + /导入 \/tmp\/authorized-game\/assets\/hero\.png · 画板 canvas-project-1 \/ object:asset-object-1 · character · image\/png/, + ), + ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText('已导入画板资产:assets/hero.png'), + await screen.findByText( + '已导入画板资产 canvas-project-1 / object:asset-object-1:assets/hero.png', + ), ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('import_canvas_asset', { projectPath: '/tmp/authorized-game', @@ -2743,7 +15513,379 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); - it('syncs canvas project assets from chat after project confirmation', async () => { + it('rejects invalid dev canvas project ids before confirmation', () => { + const invoke = vi.fn(); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const assetPanel = within(screen.getByLabelText('文件和资产')); + fireEvent.click(assetPanel.getByRole('button', { name: '打开画板' })); + + expect(screen.getByText('请提供画板项目 ID。')).not.toBeNull(); + expect(screen.queryByText('canvas.project_open')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + + fireEvent.change(screen.getByLabelText('画板项目'), { + target: { value: 'bad\u0007canvas' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '打开画板' })); + + expect(screen.getByText('画板项目 ID 不能包含控制字符。')).not.toBeNull(); + expect(screen.queryByText('canvas.project_open')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'append_local_permission_log', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'open_canvas_project', + expect.anything(), + ); + }); + + it('rejects invalid dev canvas asset imports before confirmation', () => { + const invoke = vi.fn(); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const assetPanel = within(screen.getByLabelText('文件和资产')); + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: 'relative-project' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '导入画板资产' })); + expect(screen.getByText('请提供本地项目绝对路径。')).not.toBeNull(); + + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project' }, + }); + fireEvent.change(screen.getByLabelText('资产路径'), { + target: { value: '../hero.png' }, + }); + fireEvent.change(screen.getByLabelText('画板项目'), { + target: { value: 'canvas-project-1' }, + }); + fireEvent.change(screen.getByLabelText('资源 ID'), { + target: { value: 'resource-1' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '导入画板资产' })); + + expect( + screen.getByText('画板资产路径必须是项目内相对路径。'), + ).not.toBeNull(); + + fireEvent.change(screen.getByLabelText('资产路径'), { + target: { value: 'assets/hero.png' }, + }); + fireEvent.change(screen.getByLabelText('画板项目'), { + target: { value: 'bad\u0007canvas' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '导入画板资产' })); + + expect(screen.getByText('画板项目 ID 不能包含控制字符。')).not.toBeNull(); + + fireEvent.change(screen.getByLabelText('画板项目'), { + target: { value: 'canvas-project-1' }, + }); + fireEvent.change(screen.getByLabelText('资源 ID'), { + target: { value: '' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '导入画板资产' })); + + expect(screen.getByText('请提供资源 ID 或资产对象 ID。')).not.toBeNull(); + expect(screen.queryByText('canvas.asset_import')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + expect(invoke).not.toHaveBeenCalled(); + }); + + it('generates platform art assets from the developer asset panel after confirmation', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'generate_platform_art_asset') { + return { + id: 'generated-art-panel', + localPath: 'assets/canvas-generated/generated-art-panel.png', + absolutePath: + '/tmp/project/assets/canvas-generated/generated-art-panel.png', + manifestPath: '/tmp/project/.agent/manifest.json', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const assetPanel = within(screen.getByLabelText('文件和资产')); + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project' }, + }); + fireEvent.change(screen.getByLabelText('美术生成提示词'), { + target: { value: '像素风厨房主角' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '生成美术资产' })); + + expect(screen.getByText('canvas.asset_generate')).not.toBeNull(); + expect(screen.getByText('生成 /tmp/project 的首版美术素材')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'generate_platform_art_asset', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + '已生成美术素材:assets/canvas-generated/generated-art-panel.png', + ), + ).not.toBeNull(); + expect( + screen.getByText('assets/canvas-generated/generated-art-panel.png'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('generate_platform_art_asset', { + projectPath: '/tmp/project', + prompt: '像素风厨房主角', + }); + }); + + it('opens runtime config when platform art generation is missing configuration', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'generate_platform_art_asset') { + throw new Error( + 'LLM 未配置:请在 /tmp/game-creator.config.json 的 llm.apiKey 中设置 API Key', + ); + } + if (command === 'read_game_creator_app_config') { + return { + path: '/tmp/game-creator.config.json', + config: { + llm: { + apiKey: '', + baseUrl: 'https://api.example.test/v1', + model: 'gpt-image-2', + apiKind: 'openai_responses', + stream: true, + requestTimeoutMs: 60000, + maxRetries: 1, + retryBackoffMs: 500, + }, + editorApi: { + baseUrl: 'https://editor.example.test', + apiKey: '', + }, + }, + }; + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const assetPanel = within(screen.getByLabelText('文件和资产')); + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project' }, + }); + fireEvent.change(screen.getByLabelText('美术生成提示词'), { + target: { value: '像素风厨房主角' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '生成美术资产' })); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + 'LLM 未配置:请在 /tmp/game-creator.config.json 的 llm.apiKey 中设置 API Key', + ), + ).not.toBeNull(); + expect( + await screen.findByRole('dialog', { name: '运行时配置' }), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_game_creator_app_config'); + }); + + it('cancels pending platform art generation from the developer asset panel', async () => { + const invoke = vi.fn(async (command: string) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'generate_platform_art_asset') { + throw new Error('should not generate art after cancel'); + } + throw new Error(`unexpected invoke ${command}`); + }); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const assetPanel = within(screen.getByLabelText('文件和资产')); + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project' }, + }); + fireEvent.change(screen.getByLabelText('美术生成提示词'), { + target: { value: '像素风厨房主角' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '生成美术资产' })); + + const assetCommand = screen.getByText('canvas.asset_generate'); + fireEvent.click( + within(assetCommand.closest('.pending-command') as HTMLElement).getByRole( + 'button', + { name: '取消' }, + ), + ); + + expect(await screen.findByText('已取消生成美术素材')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'generate_platform_art_asset', + expect.anything(), + ); + }); + + it('syncs canvas project assets from the developer asset panel after confirmation', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'sync_canvas_project_assets') { + return { + canvasProjectId: String(args?.canvasProjectId ?? ''), + importRoot: 'assets/canvas-sync/canvas-project-1-1', + importedCount: 1, + assets: [ + { + id: 'canvas-panel-1', + localPath: 'assets/canvas-sync/canvas-project-1-1/res-1.png', + absolutePath: + '/tmp/project/assets/canvas-sync/canvas-project-1-1/res-1.png', + manifestPath: '/tmp/project/.agent/manifest.json', + }, + ], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const assetPanel = within(screen.getByLabelText('文件和资产')); + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project' }, + }); + fireEvent.change(screen.getByLabelText('画板项目'), { + target: { value: 'canvas-project-1' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '同步画板项目' })); + + expect(screen.getByText('canvas.project_sync')).not.toBeNull(); + expect( + screen.getByText('同步画板项目资源:canvas-project-1'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'sync_canvas_project_assets', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + '已同步画板项目:1 个资产,assets/canvas-sync/canvas-project-1-1', + ), + ).not.toBeNull(); + expect( + screen.getByText('assets/canvas-sync/canvas-project-1-1/res-1.png'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('sync_canvas_project_assets', { + projectPath: '/tmp/project', + canvasProjectId: 'canvas-project-1', + }); + }); + + it('imports canvas export packages from the developer asset panel after confirmation', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'import_canvas_export') { + return { + canvasProjectId: String(args?.canvasProjectId ?? ''), + importRoot: 'assets/canvas-imports/canvas-project-1-1', + importedCount: 2, + assets: [ + { + id: 'canvas-export-panel-1', + localPath: + 'assets/canvas-imports/canvas-project-1-1/export-1.png', + absolutePath: + '/tmp/project/assets/canvas-imports/canvas-project-1-1/export-1.png', + manifestPath: '/tmp/project/.agent/manifest.json', + }, + ], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?dev'); + + const assetPanel = within(screen.getByLabelText('文件和资产')); + fireEvent.change(screen.getByLabelText('本地项目目录'), { + target: { value: '/tmp/project' }, + }); + fireEvent.change(screen.getByLabelText('画板项目'), { + target: { value: 'canvas-project-1' }, + }); + fireEvent.change(screen.getByLabelText('画板导出 ZIP'), { + target: { value: '/tmp/canvas-export.zip' }, + }); + fireEvent.click(assetPanel.getByRole('button', { name: '导入导出包' })); + + expect(screen.getByText('canvas.export_import')).not.toBeNull(); + expect( + screen.getByText('导入画板导出包:/tmp/canvas-export.zip'), + ).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'import_canvas_export', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + '已导入画板导出包:2 个资产,assets/canvas-imports/canvas-project-1-1', + ), + ).not.toBeNull(); + expect( + screen.getByText('assets/canvas-imports/canvas-project-1-1/export-1.png'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('import_canvas_export', { + projectPath: '/tmp/project', + exportPath: '/tmp/canvas-export.zip', + canvasProjectId: 'canvas-project-1', + }); + }); + + it('fills the canvas export import command from the main quick action file picker', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', '未命名游戏原型', @@ -2761,6 +15903,369 @@ describe('AI 游戏创作 App 界面边界', () => { manifest, }; } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { projectPath: String(args?.projectPath ?? ''), files: [] }; + } + if (command === 'pick_local_file') { + return '/tmp/canvas-export.zip'; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); + + await screen.findByText('想做什么游戏?'); + fireEvent.click(screen.getByRole('button', { name: '导入画板包' })); + + expect( + await screen.findByText('已选择画板导出包:/tmp/canvas-export.zip'), + ).not.toBeNull(); + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/import-canvas-export /tmp/canvas-export.zip ', + ); + expect(invoke).toHaveBeenCalledWith('pick_local_file'); + expect(invoke).not.toHaveBeenCalledWith( + 'import_canvas_export', + expect.anything(), + ); + }); + + it('rejects unsafe canvas import paths before confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: '1', + ...(args?.message as Record), + updatedAt: 1, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开:/tmp/authorized-game'), + ).not.toBeNull(); + expect(await screen.findByText(/还没有最近一次 Agent run/)).not.toBeNull(); + + submitChat( + '/import-canvas-asset /tmp/hero.png canvas-project-1 resource-1', + ); + expect( + screen.getByText('画板资产路径必须是项目内相对路径。'), + ).not.toBeNull(); + + submitChat('/import-canvas-asset ../hero.png canvas-project-1 resource-1'); + expect( + screen.getAllByText('画板资产路径必须是项目内相对路径。').length, + ).toBe(2); + + submitChat('/import-canvas-export relative.zip canvas-project-1'); + expect( + screen.getByText('画板导出 ZIP 路径必须是绝对路径。'), + ).not.toBeNull(); + expect(screen.queryByText('canvas.asset_import')).toBeNull(); + expect(screen.queryByText('canvas.export_import')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'import_canvas_asset', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'import_canvas_export', + expect.anything(), + ); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.objectContaining({ + message: expect.objectContaining({ + content: '画板导出 ZIP 路径必须是绝对路径。', + }), + }), + ); + }); + }); + + it('rejects canvas project ids with control characters before confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: '1', + ...(args?.message as Record), + updatedAt: 1, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/canvas bad\u0007canvas'); + submitChat('/sync-canvas-project bad\u0007canvas'); + submitChat( + '/import-canvas-asset assets/hero.png bad\u0007canvas resource-1', + ); + submitChat('/import-canvas-export /tmp/canvas-export.zip bad\u0007canvas'); + + expect(screen.getAllByText('画板项目 ID 不能包含控制字符。').length).toBe( + 4, + ); + expect(screen.queryByText('canvas.project_open')).toBeNull(); + expect(screen.queryByText('canvas.project_sync')).toBeNull(); + expect(screen.queryByText('canvas.asset_import')).toBeNull(); + expect(screen.queryByText('canvas.export_import')).toBeNull(); + expect(screen.queryByRole('button', { name: '确认' })).toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'open_canvas_project', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'sync_canvas_project_assets', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'import_canvas_asset', + expect.anything(), + ); + expect(invoke).not.toHaveBeenCalledWith( + 'import_canvas_export', + expect.anything(), + ); + await waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'append_local_conversation_message', + expect.objectContaining({ + message: expect.objectContaining({ + content: + '/import-canvas-export /tmp/canvas-export.zip bad\u0007canvas', + }), + }), + ); + }); + }); + + it('opens canvas projects from chat after confirmation', async () => { + let resolveOpenCanvasProject: + | ((value: { url: string }) => void) + | undefined; + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'open_canvas_project') { + return new Promise<{ url: string }>((resolve) => { + resolveOpenCanvasProject = resolve; + }); + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/canvas canvas-project-1'); + + expect( + await screen.findByText('准备打开画板项目:canvas-project-1'), + ).not.toBeNull(); + expect( + screen.getByText('canvas.project_open · canvas-project-1'), + ).not.toBeNull(); + expect( + screen.getByText('打开本机画板项目 canvas-project-1'), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText('正在打开画板:canvas-project-1'), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('open_canvas_project', { + canvasProjectId: 'canvas-project-1', + editorBaseUrl: 'http://127.0.0.1:3000', + }); + + resolveOpenCanvasProject?.({ + url: 'http://127.0.0.1:3000/editor/canvas?projectid=canvas-project-1', + }); + + expect( + await screen.findByText( + '已打开画板:http://127.0.0.1:3000/editor/canvas?projectid=canvas-project-1', + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '同步此画板' })); + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/sync-canvas-project canvas-project-1', + ); + }); + + it('syncs canvas project assets from chat after project confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: '1', + ...(args?.message as Record), + updatedAt: 1, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } if (command === 'sync_canvas_project_assets') { return { canvasProjectId: String(args?.canvasProjectId ?? ''), @@ -2786,7 +16291,7 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/project /tmp/authorized-game'); fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText('已设置本地项目:/tmp/authorized-game'), + await screen.findByText('已打开:/tmp/authorized-game'), ).not.toBeNull(); submitChat('/sync-canvas-project canvas-project-1'); @@ -2794,7 +16299,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect( await screen.findByText( - '已同步 1 个画板资产:assets/canvas-sync/canvas-project-1-1', + '已同步 1 个画板资产自 canvas-project-1:assets/canvas-sync/canvas-project-1-1', ), ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('sync_canvas_project_assets', { @@ -2802,4 +16307,235 @@ describe('AI 游戏创作 App 界面边界', () => { canvasProjectId: 'canvas-project-1', }); }); + + it('generates platform art assets from chat after project confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_local_conversation') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], + }; + } + if (command === 'append_local_conversation_message') { + return { + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [ + { + schemaVersion: '1', + ...(args?.message as Record), + updatedAt: 1, + }, + ], + }; + } + if (command === 'read_local_project_file') { + throw new Error( + '读取文件元数据失败:/tmp/authorized-game/.agent/run.latest.json: No such file or directory (os error 2)', + ); + } + if (command === 'list_local_project_files') { + return { + projectPath: String(args?.projectPath ?? ''), + files: [], + }; + } + if (command === 'generate_platform_art_asset') { + return { + id: 'generated-art-1', + localPath: 'assets/canvas-generated/generated-art-1.png', + absolutePath: + '/tmp/authorized-game/assets/canvas-generated/generated-art-1.png', + manifestPath: '/tmp/authorized-game/.agent/manifest.json', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已打开:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/generate-art 月光厨房弹幕主角'); + expect(await screen.findByText('准备生成首版美术素材。')).not.toBeNull(); + expect(screen.getByText('canvas.asset_generate')).not.toBeNull(); + expect( + screen.getByText( + /通过平台 External Editor API 生成美术素材并写入 \/tmp\/authorized-game\/assets\/canvas-generated\//, + ), + ).not.toBeNull(); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + '已生成美术素材:assets/canvas-generated/generated-art-1.png', + ), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('generate_platform_art_asset', { + projectPath: '/tmp/authorized-game', + prompt: '月光厨房弹幕主角', + }); + }); + + it('requires project policy confirmation before generating platform art assets', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'read_project_permission_policy') { + return { + path: '.agent/policy.json', + policy: { + deniedCommands: [], + confirmCommands: ['canvas.asset_generate'], + }, + }; + } + if (command === 'generate_platform_art_asset') { + return { + id: 'generated-art-confirm', + localPath: 'assets/canvas-generated/generated-art-confirm.png', + absolutePath: + '/tmp/authorized-game/assets/canvas-generated/generated-art-confirm.png', + manifestPath: '/tmp/authorized-game/.agent/manifest.json', + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + invoke.mockClear(); + + submitChat('/generate-art 月光厨房弹幕主角'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText('准备生成首版美术素材。')).not.toBeNull(); + expect(screen.getByText('canvas.asset_generate')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'generate_platform_art_asset', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + '已生成美术素材:assets/canvas-generated/generated-art-confirm.png', + ), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('generate_platform_art_asset', { + projectPath: '/tmp/authorized-game', + prompt: '月光厨房弹幕主角', + }); + }); + + it('imports canvas export packages from chat after project confirmation', async () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'append_local_permission_log') { + return {}; + } + if (command === 'init_local_game_project') { + const projectPath = String(args?.projectPath ?? ''); + return { + projectPath, + manifestPath: `${projectPath}/.agent/manifest.json`, + manifest, + }; + } + if (command === 'import_canvas_export') { + return { + canvasProjectId: String(args?.canvasProjectId ?? ''), + importRoot: 'assets/canvas-imports/canvas-project-1-1', + importedCount: 2, + assets: [], + }; + } + if (command === 'read_project_permission_policy') { + return emptyProjectPolicy(); + } + if (command === 'get_local_game_manifest') { + return manifest; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderAppAt('/'); + + submitChat('/project /tmp/authorized-game'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText('已设置本地项目:/tmp/authorized-game'), + ).not.toBeNull(); + + submitChat('/import-canvas-export /tmp/canvas-export.zip canvas-project-1'); + expect( + await screen.findByText( + /导入 \/tmp\/canvas-export\.zip 到 \/tmp\/authorized-game\/assets\/canvas-imports\/ · 画板 canvas-project-1/, + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText( + '已导入 2 个画板资产自 canvas-project-1:assets/canvas-imports/canvas-project-1-1', + ), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('import_canvas_export', { + projectPath: '/tmp/authorized-game', + exportPath: '/tmp/canvas-export.zip', + canvasProjectId: 'canvas-project-1', + }); + }); }); diff --git a/apps/ai-game-creator-shell/tests/rememberCommand.test.ts b/apps/ai-game-creator-shell/tests/rememberCommand.test.ts index ff73c702f..6c6615547 100644 --- a/apps/ai-game-creator-shell/tests/rememberCommand.test.ts +++ b/apps/ai-game-creator-shell/tests/rememberCommand.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { + deriveAgentStatusCards, isAbsoluteProjectPath, needsInitializedChatProject, parseRememberInput, @@ -8,6 +9,11 @@ import { resolveChatProjectPath, resolvePendingCommandProjectPath, } from '../src/App'; +import { + createGameCreationAppManifest, + GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + type GameCreationAgentRunTrace, +} from '../../../packages/shared/src/contracts/gameCreationApp'; describe('AI 游戏创作聊天记忆命令', () => { it('recognizes local project absolute paths across desktop platforms', () => { @@ -17,7 +23,7 @@ describe('AI 游戏创作聊天记忆命令', () => { expect(isAbsoluteProjectPath('relative-game')).toBe(false); }); - it('defaults /remember to long memory and supports short memory scope', () => { + it('defaults /remember to long memory and supports short and blackboard scopes', () => { expect(parseRememberInput('主角喜欢反弹弹幕')).toEqual({ scope: 'long', content: '主角喜欢反弹弹幕', @@ -34,6 +40,14 @@ describe('AI 游戏创作聊天记忆命令', () => { scope: 'long', content: '覆盖后的长期设定', }); + expect(parseRememberInput('blackboard 共享美术约束')).toEqual({ + scope: 'blackboard', + content: '共享美术约束', + }); + expect(parseRememberInput('黑板 统一使用俯视角')).toEqual({ + scope: 'blackboard', + content: '统一使用俯视角', + }); }); it('describes append and replace memory writes before confirmation', () => { @@ -59,6 +73,17 @@ describe('AI 游戏创作聊天记忆命令', () => { '/tmp/game', ), ).toBe('覆盖保存到 /tmp/game/memory/session.md'); + expect( + pendingCommandDetail( + { + id: 'memory.write', + scope: 'blackboard', + content: '共享美术约束', + mode: 'append', + }, + '/tmp/game', + ), + ).toBe('追加到 /tmp/game/memory/blackboard.md'); }); it('requires an initialized local project before chat memory writes', () => { @@ -66,6 +91,8 @@ describe('AI 游戏创作聊天记忆命令', () => { expect(resolveChatProjectPath({ projectPath: '/tmp/game' })).toBe( '/tmp/game', ); + expect(resolveChatProjectPath({ projectPath: 'relative-game' })).toBeNull(); + expect(resolveChatProjectPath({ projectPath: '/tmp/bad\u0007game' })).toBeNull(); expect(parseRememberInput('long')).toEqual({ scope: 'long', content: '', @@ -83,6 +110,15 @@ describe('AI 游戏创作聊天记忆命令', () => { '/tmp/game', ), ).toBe('删除 /tmp/game/memory/session.md'); + expect( + pendingCommandDetail( + { + id: 'memory.delete', + scope: 'blackboard', + }, + '/tmp/game', + ), + ).toBe('删除 /tmp/game/memory/blackboard.md'); }); it('describes local generation side effects before confirmation', () => { @@ -99,6 +135,15 @@ describe('AI 游戏创作聊天记忆命令', () => { ); }); + it('describes preview side effects before confirmation', () => { + expect(pendingCommandDetail({ id: 'preview.start' }, '/tmp/game')).toBe( + '启动 /tmp/game/game/ 并交给外部浏览器', + ); + expect(pendingCommandDetail({ id: 'preview.open' }, '/tmp/game')).toBe( + '打开 /tmp/game 的当前本地预览', + ); + }); + it('requires a project before chat commands write or run local artifacts', () => { expect(needsInitializedChatProject('game.generate_draft')).toBe(true); expect(needsInitializedChatProject('asset.upload')).toBe(true); @@ -128,7 +173,7 @@ describe('AI 游戏创作聊天记忆命令', () => { { id: 'project.restore', checkpointId: 'checkpoint-1' }, '/tmp/game', ), - ).toBe('从 checkpoint-1 恢复已跟踪项目文件'); + ).toBe('从 checkpoint-1 恢复 /tmp/game 的已跟踪项目文件'); expect( pendingCommandDetail( { @@ -140,7 +185,38 @@ describe('AI 游戏创作聊天记忆命令', () => { }, '/tmp/game', ), - ).toBe('写入 /tmp/game/.agent/policy.json'); + ).toBe('写入 /tmp/game/.agent/policy.json · 拒绝:file.write · 确认:无'); + }); + + it('describes canvas asset imports before confirmation', () => { + expect( + pendingCommandDetail( + { + id: 'canvas.asset_import', + localPath: 'assets/hero.png', + canvasProjectId: 'canvas-project-1', + canvasAssetId: '', + canvasAssetObjectId: 'asset-object-1', + kind: 'character', + mediaType: 'image/png', + }, + '/tmp/game', + ), + ).toBe( + '导入 /tmp/game/assets/hero.png · 画板 canvas-project-1 / object:asset-object-1 · character · image/png', + ); + expect( + pendingCommandDetail( + { + id: 'canvas.export_import', + exportPath: '/tmp/canvas-export.zip', + canvasProjectId: 'canvas-project-1', + }, + '/tmp/game', + ), + ).toBe( + '导入 /tmp/canvas-export.zip 到 /tmp/game/assets/canvas-imports/ · 画板 canvas-project-1', + ); }); it('describes agent run lifecycle controls before confirmation', () => { @@ -148,14 +224,16 @@ describe('AI 游戏创作聊天记忆命令', () => { '标记 /tmp/game/.agent/run.latest.json 为 killed,并写入 activity/output', ); expect(pendingCommandDetail({ id: 'agent.retry' }, '/tmp/game')).toBe( - '标记 /tmp/game/.agent/run.latest.json 为 pending,等待 runner claim', + '使用 /tmp/game/.agent/run.latest.json 的目标重新运行一次', ); expect( pendingCommandDetail( { id: 'agent.resume', detail: '继续修复输入监听' }, '/tmp/game', ), - ).toBe('附加用户说明并标记 /tmp/game/.agent/run.latest.json 为 pending'); + ).toBe( + '附加说明「继续修复输入监听」,继续运行 /tmp/game/.agent/run.latest.json 的目标', + ); }); it('shows the authorized project path in pending write command details', () => { @@ -174,4 +252,64 @@ describe('AI 游戏创作聊天记忆命令', () => { ), ).toBe('/new/game'); }); + + it('derives per-agent status cards from manifest and latest trace steps', () => { + const manifest = createGameCreationAppManifest( + 'local-project-draft', + '未命名游戏原型', + ); + const trace = { + schemaVersion: GAME_CREATION_AGENT_RUN_SCHEMA_VERSION, + runId: 'run-agent-status', + commandId: 'game.generate_draft', + status: 'passed', + passes: 1, + maxPasses: 3, + toolCallCount: 1, + maxToolCalls: 128, + stopReason: 'evaluator-passed', + goal: '像素动作', + coordination: 'filesystem', + steps: [ + { + pass: 1, + agent: 'Planner', + phase: 'plan', + taskId: 'design-director', + group: 'design', + role: 'Director', + status: 'completed', + inputPaths: [], + outputPaths: ['.agent/spec.md'], + summary: '拆解完成', + toolCalls: [], + }, + ], + artifacts: [], + taskGraph: { + goal: '像素动作', + readyTaskIds: [], + activeTaskIds: [], + carriedTaskIds: [], + repairFocus: [], + repairRoutes: [], + tasks: manifest.tasks, + }, + passPlans: [], + nextStep: 'manual-playtest', + error: null, + updatedAt: 1, + } satisfies GameCreationAgentRunTrace; + + const traced = deriveAgentStatusCards(manifest, trace); + expect(traced[0]).toMatchObject({ + id: 'design-director', + title: '拆解创作方向', + status: 'completed', + summary: '拆解完成', + }); + + const fallback = deriveAgentStatusCards(manifest, null); + expect(fallback[0]?.summary).toBe('创作目标、范围和专业组分工明确'); + }); }); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 46bdcad5f..46139b0d7 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,10 +16,22 @@ --- +## 2026-07-01 AI 游戏创作 App v1 使用本地 JSONL 对话和派生 Agent 状态 + +- 背景:AI 游戏创作 App 已有 Godcoder 式本地工程护栏、项目黑板、角色私有记忆、manifest 和 run trace;新增结构化对话记录、agent 状态列表和单 agent 对话入口时,需要避免引入平行状态源或提前承诺后台 runner 能力。 +- 决策:v1 结构化对话记录统一使用本地 `.agent/conversations/` append-only JSONL。普通聊天写 `.agent/conversations/project.jsonl`;从 agent 状态列表进入单个 agent 后,用户消息、agent 回复、工具建议和错误只写对应 `.agent/conversations/agents/.jsonl`。Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单和 `.agent/run.latest.json` / `.agent/runs/.json` 的 step、taskGraph、passPlans、lifecycleStatus 派生,并把 `taskGraph.tasks` 的任务状态与 active / carry-over / ready 编排标记显示在主窗口和单 agent 对话入口中;单 agent 最近证据里的安全相对输入 / 输出路径只填入 `/read ` 草稿,仍由用户发送并走既有 `file.read` / `agent.trace_read` 权限流。不新增独立状态数据库。项目黑板和角色私有记忆继续只保存稳定摘要,不承载原始对话流水。 +- 补充:App 启动先进入独立启动器窗口;用户选择工作区后,Tauri 关闭启动器并打开主窗口,主窗口读取该工作区的主 conversation、manifest 和 run trace。启动器“新建项目”先调用 `init_local_game_project` 初始化成功,默认项目名取目标文件夹名,再写最近项目并打开主窗口;遇到非空目录时先弹确认,取消或初始化失败则不打开主窗口、不写最近项目。最近工作区只保存在本机 WebView storage,可单项移除或清空,不进入项目文件或共享记忆;已初始化项目优先显示 manifest 项目名并保留路径副信息,`.agent/run.latest.json` 可读时显示最近 run 状态。最近项目路径缺失、不是目录、缺少可读 `.agent/manifest.json` 或检查失败时禁用打开,刷新只重新执行只读检查;“显示”只用系统文件管理器打开已确认存在的本地目录,未初始化但存在的目录也可显示,避免把历史路径误当新项目重建。 +- 补充:主窗口“显示目录”复用同一只读目录打开能力,只打开当前本地项目目录,不初始化项目、不写项目文件、不切换工作区;主窗口头部只读显示 manifest 项目名、项目路径和最近 `.agent/run.latest.json` 的 run 状态摘要,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。最近项目资产入口只读展示 localPath、kind、mediaType 和 source.kind,点击仍走原 `file.read` 权限流。 +- 补充:启动器和主窗口共用同一个运行时配置弹窗,配置只读写 Tauri 应用配置目录中的 `game-creator.config.json`,不写入项目文件或对话历史。 +- 补充:启动器“打开”只进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;路径不存在、不是文件夹或只是普通文件夹时不打开主窗口、不创建目录,用户需要创建或初始化时走“新建项目”。 +- 影响范围:`apps/ai-game-creator-shell` 的主窗口 agent 状态列表、单 agent 对话入口、本地项目文件结构、共享契约和 AI 游戏创作 App 实施计划。 +- 验证方式:文档更新先运行 `npm run check:encoding` 和 `git diff --check`;后续工程落地时补充壳 typecheck、Tauri Rust 测试和对话 JSONL / 状态派生的定向测试。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-06-30 AI 游戏创作 App 使用客户端配置文件 - 背景:`apps/ai-game-creator-shell` 是客户端 App,不应通过 `.env` 或进程环境变量承载 LLM / 画板同步配置;旧口径会让本地 secrets、CLI wrapper 和桌面 App 启动逻辑混在一起。 -- 决策:仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板;发布 App 启动时在 Tauri 应用配置目录写入默认 `game-creator.config.json`,真实密钥和本机覆盖项都保存在该运行时配置文件中。主窗口提供“配置”面板读写该运行时 JSON;开发 CLI 无 AppHandle 时才回退读取仓库旁边的模板和 gitignored 本机覆盖文件。`llm.apiKey/baseUrl/model/protocol/stream/requestTimeoutMs/maxRetries/retryBackoffMs` 驱动真实 LLM 路径,`editorApi.baseUrl/apiKey` 驱动画板项目同步;`/llm-status` 只展示 baseUrl、model、protocol 和 API Key 是否存在,不显示密钥。 +- 决策:仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板;发布 App 启动时在 Tauri 应用配置目录写入默认 `game-creator.config.json`,真实密钥和本机覆盖项都保存在该运行时配置文件中。主窗口提供“配置”面板读写该运行时 JSON;开发 CLI 无 AppHandle 时才回退读取仓库旁边的模板和 gitignored 本机覆盖文件。`llm.apiKey/baseUrl/model/apiKind/stream/requestTimeoutMs/maxRetries/retryBackoffMs` 驱动全局 LLM 路径,`agentLlm.` 可为 Planner、Generator 和角色 agent 单独覆盖 API Key、base URL、模型、API 类型和流式请求,空项继承全局配置;`editorApi.baseUrl/apiKey` 驱动画板项目同步;`/llm-status` 只展示全局和各 agent resolved 后的 baseUrl、model、apiKind、stream 和 API Key 是否存在,不显示密钥。生成游戏或平台美术遇到 LLM / editorApi 缺配置错误时,主窗口自动打开运行时配置弹窗,但错误消息仍只显示缺失项,不回显密钥值。 - 影响范围:AI 游戏创作 App 的 Tauri Rust 配置加载、主窗口配置面板、CLI wrapper、agent-run smoke、`check-config` 门禁、`.gitignore` 和实施计划文档。 - 验证方式:运行 `npm run ai-game-creator-shell:typecheck`、`cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`、`npm run check:encoding` 和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 @@ -3841,7 +3853,7 @@ - 2026-06-25 调整:正式用户 App 不承载游戏预览画面,release CSP 不允许 `frame-src http://127.0.0.1:*`;只有开发窗口 / dev CSP 可以嵌入本地预览 iframe。`/preview`、`/run` 和生成完成后的用户侧路径只启动 `127.0.0.1` HTTP preview 并通过 `open_local_game_preview` 交给系统外部浏览器。 - 2026-06-25 调整:`project.create` 成功后的 durable 权限证据必须在聊天 `/project` 和开发窗口初始化两条入口统一写入 `.agent/logs/command.log`,避免同一能力因为入口不同导致 `/audit` 或开发排障证据不一致。 - 2026-06-25 调整:`.agent/run.latest.json` 和 `.agent/runs/.json` 必须记录 loop 的 `maxPasses` 与 `stopReason`,开发窗口直接展示该状态,避免只从 summary 文案推断 loop 是否跑满、通过、返工、写入产物或进入预览。本地 HTTP 预览的 `/` 映射到 `game/index.html`,路径解析必须 canonicalize 项目根目录和目标文件,只允许访问项目内 `game/` 与 `assets/`,拒绝 `memory/`、`.agent/`、`exports/`、`..`、反斜杠和符号链接越界;常见图片、音频、视频和 Web 资源必须返回对应 MIME。这样上传和画板回流资产能被生成游戏引用,但记忆、trace 和导出包不会被预览服务暴露。 -- 2026-06-26 调整:AI 游戏创作 App 借鉴 Harbour 的控制平面思想,但不搬 Harbour 后台。最近 run 在 `.agent/run.latest.json` 增加可选 `lifecycleStatus`,并通过 `/agent-status`、`/agent-kill`、`/agent-retry`、`/agent-resume [说明]` 控制本地生命周期,写入 `.agent/activity.jsonl`、`.agent/output.jsonl` 和 `.agent/context.bundle.json`。v1 的 kill/retry/resume 只更新本地状态和上下文包,不伪装成能中断已发出的上游 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。 +- 2026-06-26 调整,2026-07-03 更新:AI 游戏创作 App 借鉴 Harbour 的控制平面思想,但不搬 Harbour 后台。最近 run 在 `.agent/run.latest.json` 增加可选 `lifecycleStatus`,并通过 `/agent-status`、`/agent-kill`、`/agent-retry`、`/agent-resume [说明]` 控制本地生命周期,写入 `.agent/activity.jsonl`、`.agent/output.jsonl` 和 `.agent/context.bundle.json`;聊天里的状态 / 控制结果可填入 `/read .agent/output.jsonl` 草稿继续查看 run 输出,但不直接读取文件或绕过 `file.read` 策略。v1 的 kill/retry/resume 只更新本地状态和上下文包,不伪装成能中断已发出的上游 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。 - 2026-06-25 调整:本地 HTTP 预览静态 `HEAD` 必须返回与 `GET` 相同的真实 `Content-Length`,但不返回 body;浏览器、图片、音频和视频探测不能拿到 `Content-Length: 0` 的假响应。 - 2026-06-25 调整:普通用户通过聊天输入 `/run` 触发待确认 `game.run_local`,确认后只能复用白名单 `game.static_smoke` 自检当前 `game/index.html`,通过后启动 `127.0.0.1` 本地 HTTP 预览。独立执行 `game.static_smoke` 时如果已有 `.agent/run.latest.json`,必须追加 `Playtest / game.static_smoke` trace step,避免“运行了代码但编排 trace 不可见”。 - 2026-06-25 调整:普通用户通过聊天输入 `/trace` 触发只读 `agent.trace_read`,读取 `.agent/run.latest.json` 并在聊天里摘要 loop 轮次、stopReason、nextStep、active / carry-over 任务、repairRoutes、agent 建议命令和最近 step。trace 面板仍只在开发窗口展示,普通用户窗口不新增面板。 @@ -3852,11 +3864,12 @@ - 2026-06-24 调整:同一本地项目多次 `game.generate_draft` 必须追加 `memory/session.md` 与 `memory/project.md`,不得覆盖历史对话和创作目标记录。 - 2026-07-01 调整:AI 游戏创作 App 在 `memory/session.md` 与 `memory/project.md` 之外新增项目级黑板 `memory/blackboard.md`,只记录重要跨 agent 决策、依赖和风险摘要;每个角色 agent 拥有私有记忆 `memory/agents//.md`。角色 brief 必须读取自己的私有记忆和项目黑板;`game.generate_draft` 通过 Evaluator 与 `game.static_smoke` 后,追加项目黑板摘要和各角色成功产出摘要,不得覆盖既有记忆。失败 run 仍只保留 trace 和 pass 快照,不写最终记忆摘要。 - 2026-07-01 调整:AI 游戏创作 App 借鉴 Godcoder 的本地工程护栏,但只收敛到五项本地机制:`ArtifactWriter` 写入前 checkpoint、写入后 diff、用户确认 restore;进入 LLM 前过滤密钥和本机配置痕迹;`.agent/agent.db` 继续作为轻量 JSONL 项目索引,`/index` 额外刷新 `.agent/project.index.json`;同一项目写入通过 `.agent/project.lock` 串行化;`.agent/policy.json` 记录项目级命令拒绝 / 确认策略。v1 不引入通用 IDE 插件、云工作区、SQLite 或任意 shell 代理。 +- 2026-07-03 调整:主窗口最近 checkpoint 列表必须直接展示 checkpoint id、文件数、大小和创建时间,并只提供填充对比与确认回滚的轻量操作;回滚仍走 `project.restore` 确认卡,不在列表按钮中直接写项目文件。 - 2026-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。 - 2026-06-24 调整:聊天区待确认命令的日志语义必须区分 `permission.pending`、`permission.confirm` 和 `permission.cancel`;待确认卡片必须展示本地写入目标路径,避免用户在不知道落盘位置时确认。 - 2026-06-24 调整:普通用户通过聊天输入 `/status` 读取 `.agent/manifest.json` 的项目状态摘要,只在聊天消息里展示项目目录、任务状态、资产数量、预览状态和最近命令;不得为了状态查看暴露任务、文件或日志面板。 - 2026-06-24 调整:普通用户通过聊天输入 `/files` 触发只读 `file.list`,只在聊天消息里展示本地项目文件摘要;不得把文件读写面板暴露到普通用户窗口。 -- 2026-06-24 调整:普通用户通过聊天输入 `/assets` 触发只读 `asset.list`,只在聊天消息里展示本地项目资产路径、类型和来源;不得把资产面板暴露到普通用户窗口。 +- 2026-06-24 调整,2026-07-03 更新:普通用户通过聊天输入 `/assets` 触发只读 `asset.list`,只在聊天消息里展示本地项目资产路径、类型和来源;资产列表消息可以填入首个资产的 `/read` 草稿,方便从聊天继续查看资产文本元数据,但仍不直接读取文件或绕过聊天命令;不得把资产面板暴露到普通用户窗口。 - 2026-06-24 调整:普通用户通过聊天输入 `/read 本地相对路径` 触发只读 `file.read`,只在聊天消息里展示项目内文本文件并截断长文本;不得开放聊天里的文件写入或删除能力。 - 2026-06-24 调整:普通用户通过聊天输入 `/tasks` 触发只读 `task.list`,只在聊天消息里展示专业组、角色、任务状态和产物交接;不得把任务面板暴露到普通用户窗口。 - 2026-06-24 调整:普通用户只能通过聊天触发内置命令;当前 `/smoke` 映射到白名单 `command.run_limited game.static_smoke` 并走待确认卡片,不允许扩展成任意 shell 或自由命令解析。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index b018cf181..9268c0480 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -32,10 +32,11 @@ Agent Runtime 负责: - 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。 - 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作。 - 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。 -- 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents//.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。 +- 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents//.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。最近 project / agent conversation 会作为短期 prompt 上下文读取,不替代正式 memory 文件。 +- 对话能力:结构化对话记录统一落在 `.agent/conversations/` 的 append-only JSONL;普通聊天写 `.agent/conversations/project.jsonl`,进入单个 agent 后只写对应 `.agent/conversations/agents/.jsonl`,不把原始对话混进项目黑板或角色私有记忆。 - 本地能力:生成代码和资产落盘、本地 manifest、受限运行命令、本地 HTTP 预览。 - 文件能力:`file.list/read/write/delete` 只允许访问项目目录内的相对路径,拒绝绝对路径、`..`、反斜杠和符号链接。 -- 产物治理能力:写入前生成本地 checkpoint,写入后记录相对路径 diff,用户确认后可 restore 到最近 checkpoint。 +- 产物治理能力:写入前生成本地 checkpoint,写入后记录相对路径 diff,用户确认后可 restore 到最近 checkpoint;restore 会回滚到 checkpoint 状态,包括删除 checkpoint 后新增的受跟踪文件。 - 安全能力:上下文进入 LLM 前先做密钥模式和本机配置痕迹过滤;项目写入走项目级写锁和项目级权限策略。 - 创作能力:策划、美术、程序、数值、音乐、运营 6 组专业 agent,组内按任务使用 Director、Gameplay、Code、Asset、Preview、Playtest、Polish、Publish 等角色模板。 @@ -60,6 +61,10 @@ game-project/ project.lock project.index.json run.latest.json + conversations/ + project.jsonl + agents/ + .jsonl activity.jsonl output.jsonl context.bundle.json @@ -89,22 +94,26 @@ game-project/ 1. 在 `platform-agent` 建立游戏创作专业组与种子任务图契约。 2. 在共享契约中补本地项目 manifest、内置命令和权限枚举。 3. 扩展 `apps/ai-game-creator-shell` 的本地能力:项目目录、文件写入、受限命令、本地 HTTP 预览。 -4. 用户侧只保留聊天界面;任务、文件/资产、预览和日志只在开发构建的独立开发窗口展示。 +4. 用户侧保留聊天、上传、Agent 状态列表和单 Agent 对话;任务面板、文件/资产面板、嵌入预览和命令日志只在开发构建的独立开发窗口展示。 5. 将美术组、音乐组接入现有画板与外部生成队列。 ## v1 验收 - 用户能创建本地 Web 游戏项目。 -- 用户侧只看到聊天框;开发环境通过独立窗口查看任务拆分、专业组、状态、产物和错误。 +- 用户侧看到当前工作区项目名 / 路径、聊天框、上传入口、Agent 状态列表和单 Agent 对话;最近项目资产入口显示本地路径、kind、mediaType 和来源类型,开发环境通过独立窗口查看任务拆分、专业组细节、产物、文件面板、嵌入预览和错误日志。 - 生成代码和资产进入用户本地项目目录。 - 本地 HTTP 预览能启动,并在外部浏览器展示可玩原型。 - 美术/音乐资产能从画板链路回流到本地项目。 -- 短期记忆、长期记忆、项目黑板和角色私有记忆按授权本地项目路径读写;普通用户仍只通过聊天命令访问短期 / 长期记忆。 +- 短期记忆、长期记忆、项目黑板和角色私有记忆按授权本地项目路径读写;普通用户仍只通过聊天命令访问短期 / 长期 / 黑板记忆,角色私有记忆只在单 agent 对话和生成 loop 中按目标 agent 读取。 +- 结构化对话记录按授权本地项目路径追加 JSONL;普通聊天、`/history`、工作区历史和单 agent 对话都读取 `.agent/conversations/`,最近 project / agent 对话可进入生成 prompt 上下文,但 v1 不提供 fork、archive 或云端同步。 +- Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单和 `.agent/run.latest.json` / `.agent/runs/.json` 的 step、taskGraph、passPlans、lifecycleStatus 派生;v1 不新增独立状态数据库,也不承诺完整后台 runner。 +- App 启动先进入独立启动器窗口;选择工作区后关闭启动器并打开主窗口,主窗口复用 `init_local_game_project` 补齐目录,再从该工作区加载主 conversation、manifest、run trace 和最近 run 历史。最近工作区只保存在本机 WebView storage,用于下次快速选择,并允许用户在启动器中移除单个最近项目或清空最近项目列表;最近项目目标不存在、不是目录或缺少可读 `.agent/manifest.json` 时只显示状态并禁用打开,不自动重建目录。 ## v1 验收证据矩阵 -- `npm run ai-game-creator-shell:check`:覆盖壳 typecheck、聊天命令单测、用户 / 开发窗口 UI 边界 smoke、主窗口运行时配置面板读写 Tauri 配置目录中的 `game-creator.config.json` 且不把 API Key 写入聊天、聊天侧 `/capabilities` 展示标准 Agent 能力清单且不打开开发面板、聊天侧 `/audit` 从 manifest / 本地文件 / `.agent/run.latest.json` 分别汇总用户面、6 组任务配置、6 组协作证据、任务编排、loop、记忆、本地产物、HTTP 预览、画板回流和权限日志证据且不打开开发面板;未生成 `.agent/run.latest.json` 前,`/audit` 只能标记任务配置通过,不能把 6 组协作证据误判为通过;trace 已存在但状态为 `failed`、`needs-revision`、`running`、`max-passes-exhausted` 或缺少 `Evaluator passed` 步骤时,`/audit` 不能把 loop 误判为通过。聊天侧 `/llm-status` 只显示 base_url / model / API Key 已读取状态且不泄露密钥本体、聊天侧长期记忆查看 / 追加 / 覆盖 / 删除的授权本地项目路径、上传资产写入后的 manifest 刷新和 `/assets` 聊天可见性、`/smoke` 聊天侧确认后只通过授权本地项目路径执行白名单 `game.static_smoke`、`/run` 聊天侧确认后通过授权本地项目路径执行 `game.static_smoke`、启动 `127.0.0.1` 本地预览并交给外部浏览器、`/preview` 聊天侧确认后通过授权本地项目路径启动 `127.0.0.1` 本地预览并交给外部浏览器、`/status` 聊天侧项目 / 任务 / 资产 / 预览 / 最近命令汇总、`/files` 聊天侧本地文件列表、`/read` 聊天侧文件读取的授权本地项目路径、`/tasks` 聊天侧任务拆分与下一步专业组展示的授权本地项目路径、聊天确认生成后实时展示 Planner / Orchestrator / 角色 brief / Generator / Evaluator / 写盘 / 自检进度,并自动读取 `.agent/run.latest.json` 在普通聊天消息里展示 Run、LLM 对话、loop 轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照、`/trace` 聊天侧读取 `.agent/run.latest.json` 并展示 loop 轮次 / active 任务 / 返工路线 / dependency waves 的授权本地项目路径、`platform-agent` 编排测试、共享契约测试、Tauri 本地能力测试和无密钥本地 provider 端到端 smoke;用于证明独立 App、真实 LLM-compatible loop、本地落盘、自检和 HTTP 预览闭环,并覆盖 loop 跑满 3 轮失败时不会写入最终游戏产物。 -- `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 窗口边界、正式用户 App 不嵌入游戏预览 iframe、用户侧预览命令交给外部浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式用户窗口只登记 `main` 聊天窗口,开发面板只在 debug/dev 路径打开,独立壳能完成 release 编译。 +- `npm run ai-game-creator-shell:check`:覆盖壳 typecheck、聊天命令单测、用户 / 开发窗口 UI 边界 smoke、主窗口命令按钮复用 `/help` 命令列表、能力按钮复用 `/capabilities`、LLM状态按钮复用 `/llm-status`、项目状态按钮复用 `/status`、权限按钮复用 `/policy`、审计按钮复用 `/audit`、资产按钮复用 `/assets` 且资产结果可一键复用 `/read`、任务按钮复用 `/tasks`、Trace 按钮复用 `/trace`、文件按钮复用 `/files` 且文件结果可一键复用 `/read`、索引按钮复用 `/index`、记忆 / 短期记忆 / 黑板按钮复用 `/memory long|short|blackboard`、快照按钮复用 `/checkpoint`、快照列表按钮复用 `/checkpoints` 且 checkpoint 结果可一键复用 `/diff`、历史按钮复用 `/history`、受限命令白名单按钮复用 `/commands` 且无需项目初始化、静态自检快捷按钮复用 `/smoke`、预览状态快捷按钮复用 `/preview-status`、主窗口运行时配置面板读写 Tauri 配置目录中的 `game-creator.config.json`、支持全局与每个 agent 单独选择 LLM Provider 且不把 API Key 写入聊天、聊天侧 `/capabilities` 展示标准 Agent 能力清单且不打开开发面板、聊天侧 `/audit` 从 manifest / 本地文件 / `.agent/run.latest.json` 分别汇总用户面、6 组任务配置、6 组协作证据、任务编排、loop、记忆、本地产物、HTTP 预览、画板回流和权限日志证据且不打开开发面板;未生成 `.agent/run.latest.json` 前,`/audit` 只能标记任务配置通过,不能把 6 组协作证据误判为通过;trace 已存在但状态为 `failed`、`needs-revision`、`running`、`max-passes-exhausted` 或缺少 `Evaluator passed` 步骤时,`/audit` 不能把 loop 误判为通过。聊天侧 `/llm-status` 只显示 base_url / model / API Key 已读取状态且不泄露密钥本体、聊天侧长期记忆查看 / 追加 / 覆盖 / 删除的授权本地项目路径、上传资产写入后的 manifest 刷新和 `/assets` 聊天可见性、`/smoke` 聊天侧确认后只通过授权本地项目路径执行白名单 `game.static_smoke`、`/run` 聊天侧确认后通过授权本地项目路径执行 `game.static_smoke`、启动 `127.0.0.1` 本地预览并交给外部浏览器、`/preview` 聊天侧确认后通过授权本地项目路径启动 `127.0.0.1` 本地预览并交给外部浏览器、`/status` 聊天侧项目 / 任务 / 资产 / 预览 / 最近命令汇总、`/files` 聊天侧本地项目文件列表、`/read` 聊天侧文件读取的授权本地项目路径、`/tasks` 聊天侧任务拆分与下一步专业组展示的授权本地项目路径、聊天确认生成后实时展示 Planner / Orchestrator / 角色 brief / Generator / Evaluator / 写盘 / 自检进度,并自动读取 `.agent/run.latest.json` 在普通聊天消息里展示 Run、LLM 对话、loop 轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照、`/trace` 聊天侧读取 `.agent/run.latest.json` 并展示 loop 轮次 / active 任务 / 返工路线 / dependency waves 的授权本地项目路径、`platform-agent` 编排测试、共享契约测试、Tauri 本地能力测试和无密钥本地 provider 端到端 smoke;用于证明独立 App、真实 LLM-compatible loop、本地落盘、自检和 HTTP 预览闭环,并覆盖 loop 跑满 3 轮失败时不会写入最终游戏产物。 +- 主窗口最近 checkpoint 列表展示 checkpoint id、文件数、大小和创建时间,同时提供“对比”和“回滚”按钮;回滚继续走 `project.restore` 确认卡,不直接写项目文件。 +- `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 窗口边界、正式用户 App 不嵌入游戏预览 iframe、用户侧预览命令交给外部浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式发布只登记 `launcher` 启动器窗口,选择工作区后才关闭启动器并打开 `main` 主窗口,开发面板只在 debug/dev 路径打开,独立壳能完成 release 编译。 - `npm run check:encoding` 与 `git diff --check`:覆盖中文文档、中文命令文案和补丁空白;用于避免乱码、尾随空白和无关格式漂移。 - `npm run ai-game-creator-shell:llm-status`:只检查 LLM 客户端配置是否就绪,不请求上游、不显示 API Key;用于本机联调前确认配置。发布版启动时会在 Tauri 应用配置目录生成默认 `game-creator.config.json`,仓库内 `apps/ai-game-creator-shell/game-creator.config.json` 只作为默认模板。 - `npm run ai-game-creator-shell:agent-run -- --no-wait /绝对项目路径 "游戏创作需求"`:使用真实 OpenAI-compatible 配置跑一次本地生成、落盘、自检和预览;用于人工验收真实 provider 路径。真实 provider 配置放在 Tauri 应用配置目录的 `game-creator.config.json` 中,至少设置 `llm.apiKey`,需要覆盖默认服务时设置 `llm.baseUrl`、`llm.model`;默认 API kind 为 `openai_responses`,旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`,URL 会在 base URL 后拼 `/v1/messages`,例如 Minimax Anthropic base URL 可配置为 `https://api.minimaxi.com/anthropic`;真实网关长请求若在非流式响应前被 60 秒空闲连接切断,联调时设置 `llm.stream` 为 `true`。 @@ -113,34 +122,39 @@ game-project/ - `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`。 - 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。 +- v1 conversation 记录使用 append-only JSONL,每行带 `schemaVersion`、`role`、`content`、`agentId` 和 `updatedAt`,作为聊天历史和单 agent 对话历史的事实源;目录在首次写入时创建。 - 普通用户可在聊天框输入 `/project /绝对路径` 生成待确认的 `project.create` 命令,用于授权并初始化本地项目目录;相对路径不会生成待确认命令;开发窗口仍可直接编辑项目路径。 +- 启动器可选择、打开、新建或显示当前输入的项目绝对路径;最近项目行也可显示目录,非法或相对路径不会调用系统文件管理器。 - 普通用户侧的生成、上传、运行、自检、预览状态 / 启动 / 打开 / 停止、记忆写入和画板资产导入都必须先完成 `/project` 初始化;未初始化时只提示设置本地项目,不落到默认临时目录。 - 终端可用 `npm run ai-game-creator-shell:llm-status` 检查 LLM 客户端配置是否就绪;桌面 App 主窗口“配置”面板可读写 Tauri 应用配置目录中的 `game-creator.config.json`,`/llm-status` / 生成入口读取同一份配置,CLI 开发入口无 AppHandle 时才回退读取仓库旁边的配置模板和 gitignored 本机覆盖文件;不请求上游、不显示 API Key,缺配置时以非零状态退出或在聊天里提示未就绪。 - 终端可用 `npm run ai-game-creator-shell:check` 跑 v1 开发验收:壳 typecheck、`platform-agent` 编排测试、共享契约测试、Tauri Rust 测试和无密钥本地 provider 端到端 smoke。 - 终端可用 `npm run ai-game-creator-shell:agent-run -- /绝对项目路径 "游戏创作需求"` 跑一次真实 LLM 生成、落盘、`game.static_smoke` 和本地 HTTP 预览;发布 App 读取 Tauri 应用配置目录中的 `game-creator.config.json`,开发 CLI 无 AppHandle 时才读取仓库旁边的配置模板和 gitignored 本机覆盖文件,不把 API Key 写入仓库或项目文件。自动验证可加 `--no-wait`,例如 `npm run ai-game-creator-shell:agent-run -- --no-wait /tmp/genarrative-ai-game-test "像素风反弹弹幕厨房"`,生成预览 trace 后立即停止本地预览,避免终端卡在回车等待。默认 API kind 为 `openai_responses`;旧 Chat Completions 兼容网关设置 `llm.apiKind` 为 `openai_chat`,Anthropic Messages 网关设置 `llm.apiKind` 为 `anthropic`。真实 OpenAI-compatible 网关建议设置 `llm.stream` 为 `true` 跑 Planner 和 Generator,避免长请求非流式空闲断连。 -- 终端可用 `npm run ai-game-creator-shell:agent-run:smoke` 跑一次无密钥本地端到端 smoke:脚本启动本机 OpenAI-compatible SSE 流式测试 provider,预置一个本地上传图片和一个本地上传音频,复用真实 `--agent-run`、Planner / Orchestrator / 角色 agent / Generator / Evaluator loop、本地落盘、`game.static_smoke` 和本地 HTTP 预览,并断言每次 provider 请求都使用 `stream: true`、provider prompt 收到图片与音频资产上下文、生成 HTML 引用这些资产、预览服务能用 `GET` 读取 `/assets/...`、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、`.agent/run.latest.json` 的 step group 覆盖 design / balance / art / audio / code / publishing 六组、第二轮会重跑 Evaluator 命中任务及其下游影响任务,未受影响角色 carry-over;随后脚本自动给 CLI 发送回车停止预览。该脚本只用于开发验证,不进入产品生成路径。 +- 终端可用 `npm run ai-game-creator-shell:agent-run:smoke` 跑一次无密钥本地端到端 smoke:脚本启动本机 OpenAI-compatible SSE 流式测试 provider,预置一个本地上传图片和一个本地上传音频,复用真实 `--agent-run`、Planner / Orchestrator / 角色 agent / Generator / Evaluator loop、本地落盘、`game.static_smoke` 和本地 HTTP 预览,并断言每次 provider 请求都使用 `stream: true`、Planner 与 Generator 分别命中自己的 `agentLlm` provider 配置、provider prompt 收到图片与音频资产上下文以及最近对话上下文、生成 HTML 引用这些资产、预览服务能用 `GET` 读取 `/assets/...`、用 `HEAD` 返回真实资源长度和对应 MIME、headless Chrome 打开预览后至少执行一帧游戏 JS,且通过确定性亮色探针采样证明 canvas 不是空白画布、`.agent/run.latest.json` 的 step group 覆盖 design / balance / art / audio / code / publishing 六组、第二轮会重跑 Evaluator 命中任务及其下游影响任务,未受影响角色 carry-over;随后脚本自动给 CLI 发送回车停止预览。该脚本只用于开发验证,不进入产品生成路径。 - `npm run ai-game-creator-shell:dev` 的 Tauri `devUrl` 固定为 `http://127.0.0.1:3080/`,Vite 必须 `strictPort` 对齐;`beforeDevCommand` 先复用已经跑在 3080 且页面标题为 `AI 游戏创作` 的本 app Vite server,否则才启动新的 Vite,若端口被其它服务占用则直接失败并提示释放端口。 - `.agent/manifest.json` 会保存 6 个专业组下 16 个组内角色任务状态,当前覆盖 `Director`、`Gameplay`、`Difficulty`、`Asset`、`Polish`、`SFX`、`Code`、`Review`、`Preview`、`Playtest`、`Publish`;程序组内显式包含 `quality-review` 质量评审 gate,由 Evaluator trace 标记完成;开发窗口的专业组面板读取 manifest,而不是前端硬编码。 +- 主窗口的 agent 状态列表以 manifest 角色任务为底表,再合并最近 run trace 中 `taskGraph.tasks` 的任务状态、同 taskId / group / role 的最新 step 状态、输入输出路径、错误摘要、lifecycleStatus 和 `activeTaskIds` / `carriedTaskIds` / `readyTaskIds` 编排标记;如果 trace 缺失或过期,只展示 manifest 的静态任务状态和“暂无最近运行证据”。 - 共享契约和 `platform-agent` 会按任务依赖与 `completed` 状态计算当前可执行任务,作为 v1 的最小编排选择器;每轮 `Orchestrator` 的 activeTaskIds、carriedTaskIds、repairRoutes 和 dependencyWaves 由 `platform-agent` 纯编排内核产出,`apps/ai-game-creator-shell` 只负责写入 `.agent/passes/pass-N/` 和执行本地工具;`Evaluator` 会在 `.agent/findings.md` 写出 `## Repair Routes` JSON,下一轮编排优先采用该结构化 taskIds,解析不到时才退回关键词路由;返工路由会按任务图自动扩展下游影响任务,例如美术资产变化会继续触发程序预览和运营包装重算。 - `game.generate_draft` 使用 OpenAI-compatible LLM 配置生成结构化 JSON 草案,发布 App 的配置项来自 Tauri 应用配置目录中的 `game-creator.config.json`:`llm.apiKey`、`llm.baseUrl`、`llm.model`、`llm.apiKind`、`llm.stream`、`llm.requestTimeoutMs`、`llm.maxRetries`、`llm.retryBackoffMs`;默认 API kind 为 `openai_responses`,可设 `llm.apiKind=openai_chat` 切回旧 Chat Completions 兼容网关,或 `llm.apiKind=anthropic` 走 Anthropic Messages;`llm.stream=true` 时 Planner 和 Generator 使用流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。 -- 主窗口“配置”面板读写 Tauri 应用配置目录中的 `game-creator.config.json`,覆盖 LLM API Key、base URL、模型、API 类型、流式请求、超时、重试和画板 External API 配置;保存时只写运行时配置文件,不写仓库模板、本地项目、trace 或 manifest。 -- 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认 LLM base_url、model 和 API Key 是否已从客户端配置读取;状态消息不会显示或保存 API Key。 +- 主窗口“配置”面板和聊天 `/config` 命令读写 Tauri 应用配置目录中的 `game-creator.config.json`,覆盖 LLM API Key、base URL、模型、API 类型、流式请求、超时、重试和画板 External API 配置;Planner、Orchestrator、Generator、Evaluator 和 16 个角色 agent 都可在 `agentLlm` 中单独覆盖 API Key、base URL、模型、API 类型和流式请求,空项继承全局 LLM 配置;默认生成链路仍只让 Planner / Generator 调 LLM,配置了 `agentLlm.` 的角色 agent 会改用自己的 provider 生成 brief,未配置的角色 agent 继续使用本地 brief;生成游戏或平台美术时如果返回 LLM / editorApi 缺配置错误,主窗口自动打开同一个运行时配置弹窗;API Key 输入框使用密码字段并关闭自动填充,数值项在 UI 层夹住下限,Rust 写配置时也拒绝过低超时,保存时只写运行时配置文件,不写仓库模板、本地项目、trace 或 manifest。 +- 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认全局 LLM 以及各 agent resolved 后的 base_url、model、API 类型和 API Key 是否已从客户端配置读取;状态消息不会显示或保存 API Key;当前生成链路只要求 Planner / Generator 就绪。 - `game.generate_draft` 的 LLM JSON 必须包含 `handoffs` 数组,覆盖 `design`、`balance`、`art`、`audio`、`code`、`publishing` 6 个专业组;每组必须给出 role、summary、outputs 和 next,缺组或交接内容不完整会判定为模型输出无效并进入返工。 - `game.generate_draft` 的真实生成路径使用最小 Planner / Orchestrator / 组内角色 agent / Generator / Evaluator loop:Planner 写 `.agent/spec.md`;每轮 Orchestrator 先写 `.agent/passes/pass-N/agenda.md` 和 `.agent/passes/pass-N/task-graph.json`,首轮全量调度 16 个角色任务,返工轮按 `.agent/findings.md` 生成结构化 `repairRoutes`,重跑命中问题的角色任务及其下游依赖任务,其余角色 brief 从上一轮 carry-over;`task-graph.json` 记录 activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和按依赖排序的 dependencyWaves;每个角色 brief 必须读取自己的私有记忆 `memory/agents//.md` 和项目黑板 `memory/blackboard.md`,写入 `.agent/passes/pass-N/groups//*.md`,再汇总为 `.agent/passes/pass-N/groups/*.md`;Generator 必须读取用户需求、记忆、`.agent/spec.md`、本轮 `agenda.md`、`task-graph.json`、`.agent/findings.md` 和 6 组汇总 brief 后返回结构化 JSON;每轮会把 Generator 草案拆成 6 组交接快照,写入 `.agent/passes/pass-N/`;Evaluator 做质量评审并写 `.agent/findings.md`,通过后才进入 `game.static_smoke` 静态自检和预览试玩。 - loop 最多执行 3 轮;Evaluator 发现 HTML 非自包含、缺少 `canvas`、缺少 `requestAnimationFrame`、缺少输入监听或用户输入未转义时,把问题写入 `.agent/findings.md` 并让下一轮 Generator 修复。3 轮仍失败则 `game.generate_draft` 失败,不写最终游戏产物。 -- loop 每次运行会写 `.agent/run.latest.json` 和 `.agent/runs/.json`,记录 `Planner` / `Orchestrator` agenda / 16 个组内角色 brief 或 carry-over / 6 个 `GroupCoordinator` 汇总 / `Generator` / 6 个专业组交接 / `Evaluator` 质量评审 / `ArtifactWriter` / `Playtest` step、每步 `toolCalls`、输入文件、输出文件、状态、轮次、maxPasses、toolCallCount、maxToolCalls、stopReason、nextStep 和错误摘要;Planner、角色 agent 和 Generator 的 `inputPaths` 必须包含对应记忆文件、`.agent/manifest.json` 和 agenda 等上下文来源,其中角色 agent 必须包含自己的 `memory/agents//.md` 和 `memory/blackboard.md`;每个 step 必须带 phase、taskId、group 和 role,`.agent/run.latest.json.taskGraph` 必须记录 goal、readyTaskIds、activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和当前任务状态;`.agent/run.latest.json.passPlans` 必须按轮记录 mode、summary、activeTaskIds、carriedTaskIds、dependencyWaves、repairFocus 和 repairRoutes,作为 `/trace` 与开发窗口判断编排 loop 是否真实发生的直接证据;`run.latest.json` 是当前指针,`.agent/runs/` 保留历史 run trace;开发窗口读取 latest 展示编排过程,并复用 `file.list/read` 列出和载入历史 run,普通用户窗口不展示。 +- loop 每次运行会写 `.agent/run.latest.json` 和 `.agent/runs/.json`,记录 `Planner` / `Orchestrator` agenda / 16 个组内角色 brief 或 carry-over / 6 个 `GroupCoordinator` 汇总 / `Generator` / 6 个专业组交接 / `Evaluator` 质量评审 / `ArtifactWriter` / `Playtest` step、每步 `toolCalls`、输入文件、输出文件、状态、轮次、maxPasses、toolCallCount、maxToolCalls、stopReason、nextStep 和错误摘要;Planner、角色 agent 和 Generator 的 `inputPaths` 必须包含对应记忆文件、`.agent/conversations/project.jsonl`、`.agent/conversations/agents/`、`.agent/manifest.json` 和 agenda 等上下文来源,其中角色 agent 必须包含自己的 `memory/agents//.md` 和 `memory/blackboard.md`;conversation 输入只取最近少量 project / agent 对话摘要,不读取全量历史;每个 step 必须带 phase、taskId、group 和 role,`.agent/run.latest.json.taskGraph` 必须记录 goal、readyTaskIds、activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和当前任务状态;`.agent/run.latest.json.passPlans` 必须按轮记录 mode、summary、activeTaskIds、carriedTaskIds、dependencyWaves、repairFocus 和 repairRoutes,作为 `/trace` 与开发窗口判断编排 loop 是否真实发生的直接证据;`run.latest.json` 是当前指针,`.agent/runs/` 保留历史 run trace;开发窗口读取 latest 展示编排过程,并复用 `file.list/read` 按文件修改时间先载入最近 20 个历史 run,滚动时再按批次读取剩余历史,普通用户窗口不展示。 - `.agent/run.latest.json` 的 `artifacts` 使用结构化记录,包含相对路径、字节数和 `fnv1a64:` checksum;除最终本地产物外,也会收集 `.agent/passes/pass-N/` 快照,便于确认返工前后的产物差异。 - 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md` 与 `memory/project.md`,把重要跨 agent 决策 / 依赖 / 风险摘要追加到 `memory/blackboard.md`,并把各角色本轮成功产出的角色摘要追加到 `memory/agents//.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。 +- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再追加一条本地 agent 回执,二者都写入对应 `.agent/conversations/agents/.jsonl`。最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents//.md`;v1 不把本地回执伪装成实时 LLM 回复。 - `.agent/agent.db` 当前作为最小本地项目索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题、本地产物路径、checkpoint 和 diff 摘要,上传 / 登记 / 画板导入资产时追加 `asset.register` 或 `asset.update`;v1 不引入 SQLite 依赖。 -- `game.generate_draft`、资产导入、记忆写入、预览状态写入、checkpoint / restore 和 policy 写入会先按 `.agent/policy.json` 判断本次命令是否被项目策略拒绝,再拿项目级 `.agent/project.lock` 串行化;锁只保护同一本地项目,v1 不做后台锁管理。 -- `ArtifactWriter` 写入最终产物前把当前项目文件保存到 `.agent/checkpoints//`,写入后把新增、修改、删除计数记录到 `.agent/agent.db`;聊天命令 `/checkpoint`、`/diff checkpoint-id` 和 `/restore checkpoint-id` 允许用户手动保存、对比和确认恢复 checkpoint。 -- Planner、组内角色和 Generator 读取上下文前会先做安全过滤:拒绝 `.env*`、`game-creator.config*`、Authorization / Cookie / API Key / Token / Bearer 等密钥样式内容,并清理 `sk-*` / `tnr_sk_*` token;被过滤内容不进入 LLM prompt。 +- `game.generate_draft`、资产登记 / 导入、记忆写入、预览状态写入、checkpoint / restore、agent 生命周期控制、画板资源回流 / 生成和 policy 写入会先按 `.agent/policy.json` 判断本次命令是否被项目策略拒绝,再拿项目级 `.agent/project.lock` 串行化;锁只保护同一本地项目,v1 不做后台锁管理。`confirmCommands` 可把索引、状态读取、资产登记、checkpoint、预览、agent 生命周期、画板资源回流 / 生成、conversation 读写等命令转成项目策略确认,命中时用户确认后才执行;用户可用 `/policy-confirm 命令` 加入确认列表,用 `/policy-auto 命令` 移除确认项。 +- `ArtifactWriter` 写入最终产物前把当前项目文件保存到 `.agent/checkpoints//`,写入后把新增、修改、删除计数记录到 `.agent/agent.db`;聊天命令 `/checkpoint`、`/checkpoints`、`/diff checkpoint-id` 和 `/restore checkpoint-id` 允许用户手动保存、列出最近 checkpoint、对比和确认回滚到 checkpoint,回滚时会删除 checkpoint 后新增的受跟踪项目文件。 +- Planner、组内角色和 Generator 读取上下文前会先做安全过滤:拒绝 `.env*`、`game-creator.config*`、Authorization / Cookie / API Key / Token / Bearer 等密钥样式内容,并清理 `sk-*` / `tnr_sk_*` token;memory、资产摘要和 conversation JSONL 中被过滤的内容不进入 LLM prompt。 - `.agent/run.latest.json` 的 schema 固定为共享契约 `GAME_CREATION_AGENT_RUN_SCHEMA_VERSION = game-creator-agent-run.v1`;TS 与 Rust 都从共享契约读取 run trace 类型,避免开发窗口和 Tauri 写入结构漂移。 -- `.agent/run.latest.json` 增加可选 `lifecycleStatus`,把一次生成 run 映射到本地最小生命周期:`scheduled / running / waiting / pending / done / failed / killed`。聊天命令 `/agent-status` 读取最近 run,`/agent-kill` 标记为 `killed`,`/agent-retry` 与 `/agent-resume [说明]` 标记为 `pending`,并写入 `.agent/activity.jsonl`、`.agent/output.jsonl` 和 `.agent/context.bundle.json`。v1 只做本地状态控制,不承诺真正中断已在上游执行中的 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。 +- `.agent/run.latest.json` 增加可选 `lifecycleStatus`,把一次生成 run 映射到本地最小生命周期:`scheduled / running / waiting / pending / done / failed / killed`。聊天命令 `/agent-status` 读取最近 run,`/agent-kill` 标记为 `killed`,`/agent-retry` 与 `/agent-resume [说明]` 标记为 `pending`,并写入 `.agent/activity.jsonl`、`.agent/output.jsonl` 和 `.agent/context.bundle.json`;状态 / 控制结果消息可一键填入 `/read .agent/output.jsonl` 草稿继续查看 run 输出。v1 只做本地状态控制,不承诺真正中断已在上游执行中的 LLM 请求;后续引入独立 runner 后再把 `pending` 接入 claim。 +- v1 的 agent 状态列表和单 agent 对话都复用上述本地文件事实源:状态从 manifest / run trace 派生,单 agent 消息写对应 conversation JSONL;不引入 fork/archive 语义,不把 `pending` 包装成已经具备后台 claim / resume runner。 - `game.generate_draft` 写入最终产物后会复用白名单受限命令 `game.static_smoke` 做一次生成后自检,至少检查 `game/index.html` 包含 canvas、canvas 渲染上下文、绘制调用、主循环、非空输入监听、明确目标、失败或胜利状态和重开路径,且不使用远程资源、`eval`、`new Function`、`localStorage`、`fetch`、`WebSocket` 或 `ServiceWorker`,也不得包含固定星核传送门模板词、纯按钮计分模板或 `TODO` / `待实现` / `这里省略` 等未完成实现;画板资源占位引用允许出现在 asset id 或说明中,并把该工具调用写入 `.agent/run.latest.json` 与 `.agent/logs/command.log`;自检失败则本次命令失败,不继续启动预览。 - `ArtifactWriter` step 使用 `file.write.local_artifacts` 工具调用记录最终写入的 `memory/`、`memory/agents/`、`game/`、`assets/`、`exports/` 和 `.agent/manifest.json` 路径;写入完成后 `nextStep` 指向 `game.static_smoke`。 - `preview.start` / `preview.stop` 会追加 `.agent/logs/preview.log`,并在 `.agent/run.latest.json` 已存在时追加 `Preview` step 和 `preview.*` toolCall,记录本地 HTTP 预览 URL 与停止事件;单全局本地预览被新项目替换时,会 best-effort 把旧项目 manifest、preview log 和 trace 记录为 stopped,避免旧项目残留 running;本地 HTTP server 的 `/` 映射到 `game/index.html`,只允许读取 canonical 后仍位于项目真实 `game/` 或真实 `assets/` 下的文件,拒绝 `memory/`、`.agent/`、`exports/`、`..`、一级 `game` / `assets` 符号链接目录和内部符号链接越界,并为常见图片、音频、视频和 Web 资源返回对应 MIME;静态 `HEAD` 返回真实 `Content-Length` 但不返回 body,确保浏览器和媒体资源探测可用;上传和画板回流资产可被生成游戏引用但不会暴露记忆或 trace;没有 run trace 的手动预览启动不阻断。 -- 聊天输入会生成待确认的 `game.generate_draft` 内置命令;用户确认后,正式用户聊天会实时展示 Planner LLM、Orchestrator、6 组角色 brief、Generator LLM、Evaluator 质量评审、ArtifactWriter 和 `game.static_smoke` 的进度,再把 LLM 返回的结构化草案写入短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目黑板 `memory/blackboard.md`、角色私有记忆 `memory/agents//.md`、设计草案 `game/game_design.md`、数值配置 `game/balance.json`、美术清单 `assets/manifest.art.json`、音乐音效清单 `assets/manifest.audio.json`、发布包装草案 `exports/README.md` 和可运行 `game/index.html`。生成完成后,普通聊天消息会自动展示最近一次 Agent loop 的 Run、LLM 对话、轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照;完整证据仍由 `/trace` 读取同一份 `.agent/run.latest.json`。 +- 聊天输入会生成待确认的 `game.generate_draft` 内置命令;用户确认后,正式用户聊天会实时展示 Planner LLM、Orchestrator、6 组角色 brief、Generator LLM、Evaluator 质量评审、ArtifactWriter 和 `game.static_smoke` 的进度,再把 LLM 返回的结构化草案写入短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目黑板 `memory/blackboard.md`、角色私有记忆 `memory/agents//.md`、设计草案 `game/game_design.md`、数值配置 `game/balance.json`、美术清单 `assets/manifest.art.json`、音乐音效清单 `assets/manifest.audio.json`、发布包装草案 `exports/README.md` 和可运行 `game/index.html`。生成完成后,普通聊天消息会自动展示最近一次 Agent loop 的 Run、LLM 对话、轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照;单 agent 最近证据中的输入 / 输出路径可一键填入 `/read ` 草稿,画板同步建议可一键填入 `/sync-canvas-project ` 草稿,再由用户补齐参数并走原确认流;完整证据仍由 `/trace` 读取同一份 `.agent/run.latest.json`。 - `game.generate_draft` 的 `game/index.html` 必须是可试玩原型,至少包含输入、主循环、目标、失败或胜利状态和重开路径;不能只输出按钮计分或纯展示页。 - `game.generate_draft` 会校验 LLM 输出:`balance`、美术清单和音乐清单必须是 JSON object,`gameHtml` 必须是自包含 HTML、包含 `canvas` 与 `requestAnimationFrame`,不得加载远程脚本或资源,不得使用 `eval` / `new Function` / `localStorage` / `fetch` / `WebSocket` / `ServiceWorker`,不得把包含 `<` / `>` 的用户输入原样写入 HTML。 - 同一项目内多次 `game.generate_draft` 不覆盖记忆文件,而是继续追加短期对话记录、长期创作目标记录、项目黑板摘要和角色私有摘要,保留用户迭代历史。 @@ -151,29 +165,39 @@ game-project/ - 聊天输入 `/help` 会在聊天里列出当前可用内置命令,普通用户不需要打开任何开发面板来发现命令;`/capabilities` 会直接列出聊天 / 上传 / 内置命令、任务拆分、编排、Planner / Generator / Evaluator loop、工具预算、多智能体协作、组内角色协作、短期 / 长期记忆、本地产物、本地 HTTP 预览、画板同步、权限 gate 和 trace 日志等标准 Agent 能力;`/audit` 会只读聚合 manifest、文件列表和最近 run trace,逐项显示当前本地项目是否已经具备这些能力证据,并区分“6 组任务已配置”“最近 run trace 已实际覆盖 6 组协作”和“最近 loop 是否真正通过 Evaluator”。 - 聊天区待确认命令必须先记录 `permission.pending`,用户点击后再记录 `permission.confirm` 或 `permission.cancel`;已授权本地项目内的权限事件同步追加到 `.agent/logs/command.log`,待确认卡片显示本地写入目标路径。 - 聊天输入 `/status` 会读取 `.agent/manifest.json` 并在聊天里汇总项目目录、任务状态、资产数量、预览状态和最近命令,不向普通用户暴露任务或文件面板。 -- 聊天输入 `/files` 会通过 `file.list` 只读列出本地项目内的文件摘要;普通用户仍不暴露文件读写面板。 -- 聊天输入 `/assets` 会读取 `.agent/manifest.json` 并在聊天里列出本地项目资产路径、类型和来源;普通用户仍不暴露资产面板。 +- 聊天输入 `/files` 会通过 `file.list` 只读列出本地项目内的文件摘要;主窗口最近项目文件可一键读取,也可一键填入 `/read` 或 `/asset-register` 草稿,但资产登记仍必须走聊天确认;`/checkpoints` 复用 `file.list` / `file.read` 只读列出最近 checkpoint id、文件数、大小和可复制的 `/diff` / `/restore` 命令;普通用户仍不暴露文件读写面板。 +- 聊天输入 `/assets` 会读取 `.agent/manifest.json` 并在聊天里列出本地项目资产路径、类型和来源,资产列表消息可一键填入首个资产的 `/read` 草稿;`/asset-register 路径 [kind] [mediaType]` 可确认后登记项目内已有资产;普通用户仍不暴露资产面板。 - 聊天输入 `/read 本地相对路径` 会通过 `file.read` 只读返回项目内文本文件内容并在聊天中截断长文本;普通用户仍不暴露文件写入或删除能力。 - 聊天输入 `/tasks` 会读取 `.agent/manifest.json` 并在聊天里列出专业组、角色、任务状态、产物交接和下一步可执行任务;普通用户仍不暴露任务面板。 - 聊天输入 `/trace` 会通过 `agent.trace_read` 只读读取 `.agent/run.latest.json`,在聊天里汇总最近一次 loop 的轮次、stopReason、nextStep、active/carry-over 任务、repairRoutes、agent 建议命令和最近 step;active、carry-over、repairRoutes 和 dependencyWaves 必须把 taskId 映射成专业组 / 角色 / 任务名,避免普通用户只能看到内部 id;普通用户仍不暴露开发 trace 面板。 +- 主窗口可从 agent 状态列表进入单个 agent 对话;该入口只加载目标 agent 的 conversation JSONL,发送消息后追加到同一 agent conversation,不开启平行任务图、不 fork run,也不归档历史会话。 +- v1 通过独立启动器窗口选择本地项目后再进入主窗口;主窗口只承载当前工作区的聊天、配置和 agent 状态,切换项目时关闭当前主窗口并回到启动器,主窗口按钮和 `/switch-project` 聊天命令都复用同一 Tauri 启动器入口,避免在主窗口内用遮罩面板混合多个工作区上下文。 +- 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。 +- 启动器和主窗口共用同一个运行时配置弹窗,读写 Tauri 应用配置目录中的 `game-creator.config.json`;API Key 仍不进入本地项目、trace、manifest 或聊天记录。 +- 启动器支持手动输入绝对路径,也支持通过 Tauri 原生目录选择器回填项目路径;用户取消目录选择时不覆盖已经输入的路径。 +- 启动器最近项目列表只来自本机 WebView storage,可移除单项或清空;这些操作不触碰项目目录,也不触发打开主窗口。启动器会只读检查最近项目路径,已初始化项目优先显示 `.agent/manifest.json` 里的项目名并保留路径副信息;如果 `.agent/run.latest.json` 可读,则显示最近 run 的 status / stopReason;缺失、非目录、未初始化或检查失败的条目禁用打开,用户可移除后重新选择。刷新最近项目只重新执行这组只读检查,不改项目目录或最近项目列表;刷新未完成时按钮显示“刷新中”并禁用,最近项目条目显示“检查中”且临时禁用打开 / 显示操作,避免重复触发并让用户知道检查仍在进行;“显示”只通过系统文件管理器打开已确认存在的本地目录,未初始化但存在的目录也可显示,缺失、非目录或检查失败时禁用,不初始化项目、不写最近项目、不打开主窗口。 +- 启动器中“打开”只进入已初始化且 `.agent/manifest.json` 可读的 AI 游戏项目;路径不存在、不是文件夹或只是普通文件夹时留在启动器提示,不自动创建目录;Tauri 打开主工作区窗口命令本身也拒绝空路径、相对路径或包含控制字符的路径。“新建项目”和主窗口 `project.create` 新建入口会先检查目标目录,目标目录已经存在且非空时必须弹出确认提醒;用户继续后才调用 `init_local_game_project`,默认项目名取目标文件夹名,再写入最近项目并打开主窗口;用户取消或初始化失败时不打开主窗口、也不写入最近项目。 +- 聊天输入 `/commands` 会只读列出 Tauri runtime 暴露的受限命令白名单,读取失败或非 Tauri 环境下回退到共享契约默认列表;该命令不执行白名单命令,也不要求先初始化项目。 - 聊天输入 `/smoke` 会生成待确认的 `command.run_limited` 内置命令,当前只映射到白名单 `game.static_smoke`,不开放任意命令解析。 - 聊天输入 `/run` 会生成待确认的 `game.run_local` 内置命令,确认后复用白名单 `game.static_smoke` 运行当前 `game/index.html`,通过后启动只读本地 HTTP 预览并交给外部浏览器;该命令不开放任意 shell。 - 聊天输入 `/preview` 会生成待确认的 `preview.start` 内置命令,确认后启动只读本地 HTTP 预览并交给外部浏览器;`/open-preview` 在本地项目已初始化后会生成待确认的 `preview.open`,并且只打开当前已授权项目对应的 `127.0.0.1` 本地预览;`/preview-status` 只查询当前已授权项目对应的本地 HTTP 预览并写入 `preview.status` 命令日志;`/preview-stop` 只停止当前项目预览,不打开、展示或停止其它项目遗留的全局预览,不向普通用户暴露预览面板。 -- 聊天输入 `/memory [short]` 读取长期或短期记忆;`/remember [short|long] 内容` 生成待确认的 `memory.write` 并追加短期或长期记忆,未写 scope 时默认追加长期记忆;`/memory-set [short|long] 内容` 生成待确认的 `memory.write` 并覆盖保存对应记忆;`/forget-memory [short]` 生成待确认的 `memory.delete`。 -- 聊天输入 `/canvas 画板项目ID` 会生成待确认的 `canvas.project_open`,只打开本机 Genarrative 编辑器里的指定画板项目,不开放任意 URL。 -- 聊天输入 `/sync-canvas-project 画板项目ID` 会生成待确认的 `canvas.project_sync`,通过 External Editor API 把该画板项目资源下载到 `assets/canvas-sync/` 并登记为画板来源资产。 -- 聊天输入 `/import-canvas-asset 本地路径 画板项目ID 资源ID|object:资产对象ID [kind] [mediaType]` 会生成待确认的 `canvas.asset_import`,只把项目目录内已有文件登记为画板来源资产;画板只有 `assetObjectId` 时使用 `object:` 前缀,不伪造 resourceId。 -- 聊天输入 `/import-canvas-export /绝对/画板素材.zip 画板项目ID` 会生成待确认的 `canvas.export_import`,把画板导出的素材 ZIP 解包到 `assets/canvas-imports/` 并登记为画板来源资产。 +- 聊天输入 `/memory [short|long|blackboard]` 读取短期、长期或黑板记忆;`/remember [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并追加短期、长期或黑板记忆,未写 scope 时默认追加长期记忆;`/memory-set [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并覆盖保存对应记忆;`/forget-memory [short|long|blackboard]` 生成待确认的 `memory.delete`。 +- 聊天输入 `/canvas 画板项目ID` 会生成待确认的 `canvas.project_open`,只打开本机 Genarrative 编辑器里的指定画板项目,不开放任意 URL;确认后聊天先反馈正在打开,再回写真实打开 URL。画板项目 ID 为空或包含控制字符时在聊天侧直接拒绝。 +- 聊天输入 `/sync-canvas-project 画板项目ID` 会生成待确认的 `canvas.project_sync`,通过 External Editor API 把该画板项目资源下载到 `assets/canvas-sync/` 并登记为画板来源资产;画板项目 ID 为空或包含控制字符时在聊天侧直接拒绝。 +- 聊天输入 `/generate-art 提示词` 会生成待确认的 `canvas.asset_generate`,通过 External Editor API 生成首版美术素材并写入 `assets/canvas-generated/`;提示词为空时在聊天侧直接拒绝。 +- 主窗口“打开画板”“同步画板”“生成美术”和“导入画板包”按钮只填入对应聊天命令草稿并聚焦输入框,不绕过聊天侧确认流;其中“打开画板”只生成 `/canvas ` 草稿,不要求本地项目已初始化;“导入画板包”通过 Tauri 原生文件选择器选择本地 ZIP 后填入 `/import-canvas-export /绝对路径 `,仍需用户补画板项目 ID 并确认后才导入。 +- 聊天输入 `/import-canvas-asset 本地路径 画板项目ID 资源ID|object:资产对象ID [kind] [mediaType]` 会生成待确认的 `canvas.asset_import`,只把项目目录内已有文件登记为画板来源资产;画板只有 `assetObjectId` 时使用 `object:` 前缀,不伪造 resourceId。画板项目 ID 为空或包含控制字符时在聊天侧直接拒绝。 +- 聊天输入 `/import-canvas-export /绝对/画板素材.zip 画板项目ID` 会生成待确认的 `canvas.export_import`,把画板导出的素材 ZIP 解包到 `assets/canvas-imports/` 并登记为画板来源资产。画板项目 ID 为空或包含控制字符时在聊天侧直接拒绝。 - 开发窗口里的 `confirm` 权限命令使用原生确认门,取消时只写命令日志,不执行本地写入、删除、预览或画板打开操作。 - 原生文件上传会写入 `assets/uploads/`,并追加登记到 `.agent/manifest.json`。 - 开发模式可把项目内已有文件登记为资产;`asset.register` 支持 `uploaded`、`generated`、`canvas` 三种来源,并可记录画板 project/resource/object 元数据。 - 开发模式可执行 `canvas.project_open`,打开本机 Genarrative 编辑器里的指定画板项目。 - 开发模式可执行 `canvas.project_sync`,同步平台画板项目中的已有资源到本地项目资产目录;该命令要求 External API Key 具备 `editor:project` 与 `editor:asset` 相关 scope。 - 开发模式可执行 `canvas.asset_import`,将项目内已有文件按画板来源导入 manifest;也可执行 `canvas.export_import`,把现有画板素材导出 ZIP 回流为本地项目资产。`game.generate_draft` 在配置 `editorApi.apiKey` 时会自动复用 External Editor API 的图片生成接口生成首版美术素材,不新增平行资产模型。 -- 普通模式只渲染聊天区;上传文件入口在聊天输入区内,Tauri 主窗口保持聊天窗口尺寸,不承载工具台布局,也不承载游戏预览画面。 +- 普通模式渲染聊天区、上传入口、Agent 状态列表和单 Agent 对话;Tauri 主窗口不承载工具台布局,也不承载游戏预览画面。 - 聊天生成草案后会尝试启动只读 `127.0.0.1:` 静态 HTTP server,调用系统外部浏览器打开预览,并把预览地址回到聊天消息;外部浏览器打开失败时保留本地 URL 供用户手动复制。 -- 开发模式仅在 Vite dev 环境响应 `?dev` 或 `#dev`,Tauri dev 会自动额外打开 `developer` 窗口显示专业组、本地项目、预览 iframe 和内置命令日志;正式构建忽略 dev 参数,release 配置只登记 `main` 聊天窗口。 -- `check:native-shells` 会运行 `ai-game-creator-shell:check` 和 `ai-game-creator-shell:build -- --no-bundle`,并静态检查 release 只登记 `main` 聊天窗口、开发窗口只在 debug 下打开,开发面板必须挂在 `devMode` 分支内,正式用户 App 不能嵌入游戏预览 iframe,release CSP 也不能允许 `frame-src http://127.0.0.1:*`,用户侧预览命令必须调用 `open_local_game_preview` 交给系统外部浏览器。 +- 开发模式仅在 Vite dev 环境响应 `?dev` 或 `#dev`,Tauri dev 会自动额外打开 `developer` 窗口显示专业组、本地项目、预览 iframe 和内置命令日志;正式构建忽略 dev 参数,release 配置只登记 `launcher` 启动器窗口,选择工作区后由 Tauri command 打开 `main` 主窗口。 +- `check:native-shells` 会运行 `ai-game-creator-shell:check` 和 `ai-game-creator-shell:build -- --no-bundle`,并静态检查 release 只登记 `launcher` 启动器窗口、开发窗口只在 debug 下打开,开发面板必须挂在 `devMode` 分支内,正式用户 App 不能嵌入游戏预览 iframe,release CSP 也不能允许 `frame-src http://127.0.0.1:*`,用户侧预览命令必须调用 `open_local_game_preview` 交给系统外部浏览器。 - 共享契约提供 `GAME_CREATION_AGENT_CAPABILITIES` 和内置命令权限枚举;开发模式会展示能力列表。 - 共享契约提供 manifest task schema 和 ready-task 选择器,用于记录任务拆分、专业组、角色模板、依赖、产物、验收条件和当前可执行任务。 - 开发模式可读取、保存、删除短期记忆和长期记忆文件;普通用户通过聊天命令完成同类能力。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index 1a1bf31ec..eda441fe7 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -68,6 +68,11 @@ describe('AI 游戏创作 App 共享契约', () => { GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'asset.list') ?.permission, ).toBe('auto'); + expect( + GAME_CREATION_APP_COMMANDS.find( + (command) => command.id === 'asset.register', + )?.permission, + ).toBe('confirm'); expect( GAME_CREATION_APP_COMMANDS.find( (command) => command.id === 'preview.open', @@ -86,6 +91,16 @@ describe('AI 游戏创作 App 共享契约', () => { GAME_CREATION_APP_COMMANDS.find((command) => command.id === 'memory.read') ?.permission, ).toBe('auto'); + expect( + GAME_CREATION_APP_COMMANDS.find( + (command) => command.id === 'conversation.read', + )?.permission, + ).toBe('auto'); + expect( + GAME_CREATION_APP_COMMANDS.find( + (command) => command.id === 'conversation.write', + )?.permission, + ).toBe('auto'); expect( GAME_CREATION_APP_COMMANDS.find( (command) => command.id === 'canvas.project_open', @@ -96,6 +111,11 @@ describe('AI 游戏创作 App 共享契约', () => { (command) => command.id === 'canvas.project_sync', )?.permission, ).toBe('confirm'); + expect( + GAME_CREATION_APP_COMMANDS.find( + (command) => command.id === 'canvas.asset_generate', + )?.permission, + ).toBe('confirm'); expect( GAME_CREATION_APP_COMMANDS.find( (command) => command.id === 'canvas.export_import', @@ -124,11 +144,17 @@ describe('AI 游戏创作 App 共享契约', () => { 'repair-loop-carryover', 'short-term-memory', 'long-term-memory', + 'conversation-history', 'canvas-project-sync', 'local-preview', 'developer-window', ]), ); + expect( + GAME_CREATION_AGENT_CAPABILITIES.find( + (capability) => capability.id === 'conversation-history', + )?.title, + ).toBe('对话记录上下文'); }); it('keeps at least one concrete limited run command for local verification', () => { diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index f61c5792b..f54e4292c 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -39,7 +39,7 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'file.delete', permission: 'confirm' }, { id: 'asset.list', permission: 'auto' }, { id: 'asset.upload', permission: 'confirm' }, - { id: 'asset.register', permission: 'auto' }, + { id: 'asset.register', permission: 'confirm' }, { id: 'preview.start', permission: 'confirm' }, { id: 'preview.open', permission: 'confirm' }, { id: 'preview.stop', permission: 'auto' }, @@ -48,10 +48,13 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'canvas.project_open', permission: 'confirm' }, { id: 'canvas.project_sync', permission: 'confirm' }, { id: 'canvas.asset_import', permission: 'confirm' }, + { id: 'canvas.asset_generate', permission: 'confirm' }, { id: 'canvas.export_import', permission: 'confirm' }, { id: 'memory.read', permission: 'auto' }, { id: 'memory.write', permission: 'confirm' }, { id: 'memory.delete', permission: 'confirm' }, + { id: 'conversation.read', permission: 'auto' }, + { id: 'conversation.write', permission: 'auto' }, ] as const satisfies readonly GameCreationAppCommandDescriptor[]; export interface GameCreationAgentCapabilityDescriptor { @@ -106,6 +109,11 @@ export const GAME_CREATION_AGENT_CAPABILITIES = [ }, { id: 'short-term-memory', area: 'agent-runtime', title: '短期记忆' }, { id: 'long-term-memory', area: 'agent-runtime', title: '长期记忆' }, + { + id: 'conversation-history', + area: 'agent-runtime', + title: '对话记录上下文', + }, { id: 'local-artifacts', area: 'local-runtime', title: '本地产物保存' }, { id: 'project-checkpoints', area: 'local-runtime', title: '项目快照与恢复' }, { id: 'project-index', area: 'local-runtime', title: '本地项目索引' }, diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 0cfb7d871..bca1fb2c5 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -1938,8 +1938,12 @@ function assertGeneratedNativeShellArtifactsAreIgnored() { function assertAiGameCreatorShellUserDevBoundary() { const windows = aiGameCreatorShellTauriConfig.app?.windows ?? []; - if (windows.length !== 1 || windows[0]?.label !== 'main') { - throw new Error('AI game creator release shell must register only the main chat window'); + if ( + windows.length !== 1 || + windows[0]?.label !== 'launcher' || + windows[0]?.url !== 'index.html?launcher' + ) { + throw new Error('AI game creator release shell must register only the launcher window'); } const releaseCsp = aiGameCreatorShellTauriConfig.app?.security?.csp ?? ''; const devCsp = aiGameCreatorShellTauriConfig.app?.security?.devCsp ?? ''; @@ -2015,6 +2019,33 @@ function assertAiGameCreatorShellUserDevBoundary() { throw new Error(`AI game creator developer window boundary drifted: missing ${snippet}`); } } + + const workspaceWindowCommandIndex = aiGameCreatorShellTauriSource.indexOf( + 'fn open_game_creator_workspace_window(', + ); + const launcherWindowCommandIndex = aiGameCreatorShellTauriSource.indexOf( + 'fn open_game_creator_launcher_window(', + ); + const developerWindowIndex = aiGameCreatorShellTauriSource.indexOf( + '#[cfg(debug_assertions)]\nfn open_developer_window(', + ); + const workspaceWindowCommandSource = aiGameCreatorShellTauriSource.slice( + workspaceWindowCommandIndex, + launcherWindowCommandIndex, + ); + const launcherWindowCommandSource = aiGameCreatorShellTauriSource.slice( + launcherWindowCommandIndex, + developerWindowIndex, + ); + if ( + workspaceWindowCommandIndex < 0 || + launcherWindowCommandIndex < 0 || + developerWindowIndex < 0 || + !workspaceWindowCommandSource.includes('window.close().map_err(|error| error.to_string())?;') || + !launcherWindowCommandSource.includes('window.close().map_err(|error| error.to_string())?;') + ) { + throw new Error('AI game creator workspace switch must close the source window'); + } } function collectProductionShellFiles(entryPath) { diff --git a/server-rs/crates/shared-contracts/src/game_creation_app.rs b/server-rs/crates/shared-contracts/src/game_creation_app.rs index ee4c3e272..117b97160 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -21,7 +21,7 @@ pub struct GameCreationAppCommandDescriptor { pub permission: GameCreationAppPermission, } -pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 39] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 42] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), command("project.status", GameCreationAppPermission::Auto), @@ -48,7 +48,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 39] = [ command("file.delete", GameCreationAppPermission::Confirm), command("asset.list", GameCreationAppPermission::Auto), command("asset.upload", GameCreationAppPermission::Confirm), - command("asset.register", GameCreationAppPermission::Auto), + command("asset.register", GameCreationAppPermission::Confirm), command("preview.start", GameCreationAppPermission::Confirm), command("preview.open", GameCreationAppPermission::Confirm), command("preview.stop", GameCreationAppPermission::Auto), @@ -57,10 +57,13 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 39] = [ command("canvas.project_open", GameCreationAppPermission::Confirm), command("canvas.project_sync", GameCreationAppPermission::Confirm), command("canvas.asset_import", GameCreationAppPermission::Confirm), + command("canvas.asset_generate", GameCreationAppPermission::Confirm), command("canvas.export_import", GameCreationAppPermission::Confirm), command("memory.read", GameCreationAppPermission::Auto), command("memory.write", GameCreationAppPermission::Confirm), command("memory.delete", GameCreationAppPermission::Confirm), + command("conversation.read", GameCreationAppPermission::Auto), + command("conversation.write", GameCreationAppPermission::Auto), ]; const fn command( @@ -78,7 +81,7 @@ pub struct GameCreationAgentCapabilityDescriptor { pub title: &'static str, } -pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 26] = [ +pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 27] = [ capability("chat", "user", "聊天入口"), capability("file-upload", "user", "上传文件"), capability("built-in-commands", "agent-runtime", "内置命令调用"), @@ -108,6 +111,7 @@ pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescript ), capability("short-term-memory", "agent-runtime", "短期记忆"), capability("long-term-memory", "agent-runtime", "长期记忆"), + capability("conversation-history", "agent-runtime", "对话记录上下文"), capability("local-artifacts", "local-runtime", "本地产物保存"), capability("project-checkpoints", "local-runtime", "项目快照与恢复"), capability("project-index", "local-runtime", "本地项目索引"), @@ -696,6 +700,15 @@ mod tests { .expect("command should exist"); assert_eq!(asset_list.permission, GameCreationAppPermission::Auto); + let asset_register = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == "asset.register") + .expect("command should exist"); + assert_eq!( + asset_register.permission, + GameCreationAppPermission::Confirm + ); + let preview_open = GAME_CREATION_APP_COMMANDS .iter() .find(|command| command.id == "preview.open") @@ -720,6 +733,17 @@ mod tests { .expect("command should exist"); assert_eq!(memory_read.permission, GameCreationAppPermission::Auto); + for command_id in ["conversation.read", "conversation.write"] { + let conversation_command = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == command_id) + .expect("command should exist"); + assert_eq!( + conversation_command.permission, + GameCreationAppPermission::Auto + ); + } + let canvas_project_open = GAME_CREATION_APP_COMMANDS .iter() .find(|command| command.id == "canvas.project_open") @@ -738,6 +762,15 @@ mod tests { GameCreationAppPermission::Confirm ); + let canvas_asset_generate = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == "canvas.asset_generate") + .expect("command should exist"); + assert_eq!( + canvas_asset_generate.permission, + GameCreationAppPermission::Confirm + ); + let canvas_export_import = GAME_CREATION_APP_COMMANDS .iter() .find(|command| command.id == "canvas.export_import") @@ -770,12 +803,20 @@ mod tests { "repair-loop-carryover", "short-term-memory", "long-term-memory", + "conversation-history", "canvas-project-sync", "local-preview", "developer-window", ] { assert!(ids.contains(&expected), "missing {expected}"); } + assert_eq!( + GAME_CREATION_AGENT_CAPABILITIES + .iter() + .find(|capability| capability.id == "conversation-history") + .map(|capability| capability.title), + Some("对话记录上下文") + ); } #[test]