From f3d9ae38d0e21b52b51278d80732f530267e99f1 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Fri, 3 Jul 2026 15:27:37 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=9C=AC=E5=9C=B0=E8=AF=95?= =?UTF-8?q?=E7=8E=A9=E5=8C=85=E5=AF=BC=E5=87=BA=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 /export 聊天命令和主窗口导出确认入口 新增本地试玩 ZIP 白名单打包和安全记录 同步共享命令契约、技术方案和决策记录 --- .../src-tauri/src/main.rs | 393 +++++++++++++++ apps/ai-game-creator-shell/src/App.tsx | 466 +++++++++++------- .../tests/appSurface.test.ts | 209 +++++--- .../shared-memory/decision-log.md | 8 + ...¹案】AI游戏创作智能体App实施计划-2026-06-24.md | 6 +- .../src/contracts/gameCreationApp.test.ts | 5 + .../shared/src/contracts/gameCreationApp.ts | 1 + .../shared-contracts/src/game_creation_app.rs | 12 +- 8 files changed, 850 insertions(+), 250 deletions(-) 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 c16c2dc70..60f7a7880 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -408,6 +408,15 @@ struct LocalProjectCheckpointResult { total_bytes: u64, } +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalProjectExportPackageResult { + package_path: String, + package_relative_path: String, + file_count: usize, + total_bytes: u64, +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LocalProjectDiffEntry { @@ -622,6 +631,8 @@ 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 MAX_PROJECT_EXPORT_PACKAGE_FILES: usize = 1200; +const MAX_PROJECT_EXPORT_PACKAGE_BYTES: u64 = 512 * 1024 * 1024; const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [ ".agent/agent.db", ".agent/spec.md", @@ -1665,6 +1676,16 @@ fn create_local_project_checkpoint( create_local_project_checkpoint_at(root) } +#[tauri::command] +fn export_local_project_package( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_package")?; + let _lock = acquire_project_write_lock(root, "project.export_package")?; + export_local_project_package_at(root) +} + #[tauri::command] fn diff_local_project_checkpoint( project_path: String, @@ -7697,6 +7718,239 @@ fn create_local_project_checkpoint_at(root: &Path) -> Result Result { + validate_project_root(root)?; + ensure_project_export_package_dir(root, "game")?; + let game_index_path = resolve_local_project_path(root, "game/index.html")?; + if !game_index_path.is_file() { + return Err("导出试玩包前需要先生成 game/index.html".to_string()); + } + let game_index_metadata = checked_export_package_metadata(&game_index_path, "game/index.html")?; + if !game_index_metadata.is_file() { + return Err("导出试玩包前需要先生成 game/index.html".to_string()); + } + let game_index = fs::read_to_string(&game_index_path) + .map_err(|error| format!("读取游戏入口失败:{}: {error}", game_index_path.display()))?; + if game_index.trim().is_empty() { + return Err("导出试玩包前 game/index.html 不能为空".to_string()); + } + let lower_game_index = game_index.to_ascii_lowercase(); + if !lower_game_index.contains("(); + if files.len() > MAX_PROJECT_EXPORT_PACKAGE_FILES { + return Err(format!( + "试玩包文件数量超过上限:{} > {}", + files.len(), + MAX_PROJECT_EXPORT_PACKAGE_FILES + )); + } + if total_bytes > MAX_PROJECT_EXPORT_PACKAGE_BYTES { + return Err(format!( + "试玩包文件总大小超过上限:{}B > {}B", + total_bytes, MAX_PROJECT_EXPORT_PACKAGE_BYTES + )); + } + let package_relative_path = next_project_export_package_relative_path(root)?; + let package_path = resolve_local_project_path(root, &package_relative_path)?; + if let Some(parent) = package_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建导出目录失败:{}: {error}", parent.display()))?; + } + + let file = File::create(&package_path) + .map_err(|error| format!("创建试玩包失败:{}: {error}", package_path.display()))?; + let mut writer = zip::ZipWriter::new(file); + let options = zip::write::SimpleFileOptions::default() + .compression_method(zip::CompressionMethod::Deflated); + for (relative_path, absolute_path, _) in &files { + writer + .start_file(relative_path, options) + .map_err(|error| format!("写入试玩包条目失败:{relative_path}: {error}"))?; + let bytes = fs::read(absolute_path) + .map_err(|error| format!("读取导出文件失败:{}: {error}", absolute_path.display()))?; + writer + .write_all(&bytes) + .map_err(|error| format!("写入试玩包文件失败:{relative_path}: {error}"))?; + } + writer + .finish() + .map_err(|error| format!("完成试玩包失败:{}: {error}", package_path.display()))?; + + let updated_at = unix_timestamp(); + let log_path = root.join(".agent/logs/command.log"); + if let Some(parent) = log_path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建命令日志目录失败:{}: {error}", parent.display()))?; + } + let output = format!( + "导出试玩包:{},{} 个文件,{}B", + package_relative_path, + files.len(), + total_bytes + ); + let line = format!("{updated_at} project.export_package: {output}\n"); + fs::OpenOptions::new() + .create(true) + .append(true) + .open(&log_path) + .and_then(|mut file| file.write_all(line.as_bytes())) + .map_err(|error| format!("写入命令日志失败:{}: {error}", log_path.display()))?; + record_command_run( + root, + GameCreationAppCommandRunState { + command_id: "project.export_package".to_string(), + status: GameCreationAppCommandRunStatus::Completed, + output, + log_path: log_path.to_string_lossy().into_owned(), + updated_at, + }, + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.export_package", + "packagePath": package_relative_path, + "fileCount": files.len(), + "totalBytes": total_bytes, + }), + )?; + + Ok(LocalProjectExportPackageResult { + package_path: package_path.to_string_lossy().into_owned(), + package_relative_path, + file_count: files.len(), + total_bytes, + }) +} + +fn next_project_export_package_relative_path(root: &Path) -> Result { + let seed = unix_millis(); + for suffix in 0..1000 { + let file_name = if suffix == 0 { + format!("playtest-package-{seed}.zip") + } else { + format!("playtest-package-{seed}-{suffix}.zip") + }; + let relative_path = format!("exports/{file_name}"); + let package_path = resolve_local_project_path(root, &relative_path)?; + if !package_path.exists() { + return Ok(relative_path); + } + } + Err("无法生成唯一试玩包文件名".to_string()) +} + +fn collect_project_export_package_files( + root: &Path, +) -> Result, String> { + let mut files = Vec::new(); + collect_project_export_package_dir_files(root, "game", &mut files)?; + if resolve_local_project_path(root, "assets")?.exists() { + collect_project_export_package_dir_files(root, "assets", &mut files)?; + } + let readme_path = resolve_local_project_path(root, "exports/README.md")?; + let readme_metadata = checked_export_package_metadata(&readme_path, "exports/README.md")?; + if !readme_metadata.is_file() { + return Err("导出试玩包前需要先生成 exports/README.md".to_string()); + } + let readme_size = readme_metadata.len(); + files.push(("exports/README.md".to_string(), readme_path, readme_size)); + files.sort_by(|left, right| left.0.cmp(&right.0)); + Ok(files) +} + +fn ensure_project_export_package_dir(root: &Path, relative_dir: &str) -> Result<(), String> { + let dir = resolve_local_project_path(root, relative_dir)?; + let metadata = checked_export_package_metadata(&dir, relative_dir)?; + if !metadata.is_dir() { + return Err(format!("{relative_dir} 必须是目录")); + } + Ok(()) +} + +fn collect_project_export_package_dir_files( + root: &Path, + relative_dir: &str, + files: &mut Vec<(String, PathBuf, u64)>, +) -> Result<(), String> { + let dir = resolve_local_project_path(root, relative_dir)?; + if !dir.exists() { + return Ok(()); + } + let dir_metadata = checked_export_package_metadata(&dir, relative_dir)?; + if !dir_metadata.is_dir() { + return Err(format!("{relative_dir} 必须是目录")); + } + let mut dirs = vec![dir]; + while let Some(current_dir) = dirs.pop() { + for entry in fs::read_dir(¤t_dir) + .map_err(|error| format!("读取导出目录失败:{}: {error}", current_dir.display()))? + { + let entry = entry + .map_err(|error| format!("读取导出文件失败:{}: {error}", current_dir.display()))?; + let file_type = entry.file_type().map_err(|error| { + format!("读取导出文件类型失败:{}: {error}", entry.path().display()) + })?; + let relative_path = + normalize_export_package_entry_path(&relative_project_path(root, &entry.path())?)?; + if file_type.is_symlink() { + return Err(format!("试玩包不能包含符号链接:{relative_path}")); + } + if file_type.is_dir() { + dirs.push(entry.path()); + } else if file_type.is_file() { + let size = entry + .metadata() + .map_err(|error| { + format!( + "读取导出文件元数据失败:{}: {error}", + entry.path().display() + ) + })? + .len(); + files.push((relative_path, entry.path(), size)); + } + } + } + Ok(()) +} + +fn checked_export_package_metadata( + path: &Path, + relative_path: &str, +) -> Result { + let metadata = fs::symlink_metadata(path) + .map_err(|error| format!("读取导出文件类型失败:{}: {error}", path.display()))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "试玩包不能包含符号链接:{}", + normalize_export_package_entry_path(relative_path)? + )); + } + Ok(metadata) +} + +fn normalize_export_package_entry_path(relative_path: &str) -> Result { + if relative_path.chars().any(char::is_control) { + return Err("试玩包条目路径不能包含控制字符".to_string()); + } + normalize_relative_path(relative_path) +} + fn diff_local_project_checkpoint_at( root: &Path, checkpoint_id: &str, @@ -8729,6 +8983,7 @@ fn main() { append_local_conversation_message, build_local_project_index, create_local_project_checkpoint, + export_local_project_package, diff_local_project_checkpoint, restore_local_project_checkpoint, read_project_permission_policy, @@ -12245,6 +12500,144 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn local_project_export_package_uses_runtime_whitelist_and_records() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) + .expect("write playable html"); + write_local_project_file_at(&root, "assets/hero.txt", "hero asset").expect("write asset"); + write_local_project_file_at(&root, "exports/README.md", "playtest notes") + .expect("write readme"); + write_local_project_file_at(&root, "memory/project.md", "private memory") + .expect("write memory"); + fs::write(root.join(".env"), "LOCAL_PLACEHOLDER=not-for-export") + .expect("write local config"); + fs::write(root.join(".agent/run.latest.json"), "{}").expect("write trace"); + + let result = export_local_project_package_at(&root).expect("export package"); + + assert_eq!(result.file_count, 3); + assert!(result + .package_relative_path + .starts_with("exports/playtest-package-")); + assert!(result.package_relative_path.ends_with(".zip")); + let file = File::open(&result.package_path).expect("open export package"); + let mut archive = zip::ZipArchive::new(file).expect("read export package"); + let mut names = Vec::new(); + for index in 0..archive.len() { + names.push( + archive + .by_index(index) + .expect("zip entry") + .name() + .to_string(), + ); + } + names.sort(); + assert_eq!( + names, + vec![ + "assets/hero.txt".to_string(), + "exports/README.md".to_string(), + "game/index.html".to_string(), + ] + ); + assert!(!names.iter().any(|name| name.starts_with(".agent/"))); + assert!(!names.iter().any(|name| name.starts_with("memory/"))); + assert!(!names.iter().any(|name| name.contains(".env"))); + + let manifest = serde_json::from_str::( + &fs::read_to_string(root.join(".agent/manifest.json")).expect("manifest"), + ) + .expect("manifest json"); + assert!(manifest["commandRuns"] + .as_array() + .expect("command runs") + .iter() + .any(|run| run["commandId"] == "project.export_package")); + let command_log = + fs::read_to_string(root.join(".agent/logs/command.log")).expect("command log"); + assert!(command_log.contains("project.export_package")); + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"recordType\":\"project.export_package\"")); + assert!(!agent_db.contains("LOCAL_PLACEHOLDER=not-for-export")); + + fs::remove_dir_all(root).ok(); + } + + #[cfg(unix)] + #[test] + fn local_project_export_package_rejects_symlink_assets() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) + .expect("write playable html"); + write_local_project_file_at(&root, "exports/README.md", "playtest notes") + .expect("write readme"); + std::os::unix::fs::symlink( + root.join("memory/project.md"), + root.join("assets/private.md"), + ) + .expect("create symlink asset"); + + let error = export_local_project_package_at(&root).expect_err("symlink should be rejected"); + + assert!(error.contains("试玩包不能包含符号链接")); + assert!(!root.join("exports").join("playtest-package-").exists()); + fs::remove_dir_all(root).ok(); + } + + #[cfg(unix)] + #[test] + fn local_project_export_package_rejects_symlink_runtime_dirs_and_readme() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) + .expect("write playable html"); + write_local_project_file_at(&root, "memory/project.md", "private memory") + .expect("write memory"); + std::os::unix::fs::symlink( + root.join("memory/project.md"), + root.join("exports/README.md"), + ) + .expect("create readme symlink"); + + let error = export_local_project_package_at(&root).expect_err("readme symlink rejected"); + + assert!(error.contains("符号链接"), "{error}"); + fs::remove_file(root.join("exports/README.md")).expect("remove readme symlink"); + write_local_project_file_at(&root, "exports/README.md", "playtest notes") + .expect("write readme"); + fs::remove_dir_all(root.join("assets")).expect("remove assets dir"); + fs::create_dir_all(root.join("memory/assets")).expect("create memory assets"); + fs::write(root.join("memory/assets/hero.txt"), "private asset").expect("write asset"); + std::os::unix::fs::symlink(root.join("memory/assets"), root.join("assets")) + .expect("create assets symlink"); + + let error = export_local_project_package_at(&root).expect_err("assets symlink rejected"); + + assert!(error.contains("符号链接"), "{error}"); + fs::remove_dir_all(root).ok(); + } + + #[test] + fn local_project_export_package_requires_playable_html_and_readme() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + let missing_readme = + export_local_project_package_at(&root).expect_err("default html is not playable"); + assert!(missing_readme.contains("游戏入口必须包含可渲染画布")); + + write_local_project_file_at(&root, "game/index.html", &fake_llm_game_draft().game_html) + .expect("write playable html"); + let missing_readme = + export_local_project_package_at(&root).expect_err("readme should be required"); + assert!(missing_readme.contains("exports/README.md")); + + fs::remove_dir_all(root).ok(); + } + #[test] fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() { let root = unique_project_path(); diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index c99c11adc..54a6b9269 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -119,9 +119,7 @@ type RuntimeLlmProviderPresetId = | 'deepseek' | 'anthropic' | 'ark'; -type RuntimeAgentLlmProviderPresetId = - | 'inherit' - | RuntimeLlmProviderPresetId; +type RuntimeAgentLlmProviderPresetId = 'inherit' | RuntimeLlmProviderPresetId; interface GameCreatorLlmConfig { apiKey: string; @@ -295,6 +293,13 @@ interface LocalProjectCheckpointResult { totalBytes: number; } +interface LocalProjectExportPackageResult { + packagePath: string; + packageRelativePath: string; + fileCount: number; + totalBytes: number; +} + interface LocalProjectDiffResult { checkpointId: string; added: Array<{ path: string; status: string }>; @@ -396,6 +401,9 @@ export type PendingCommand = | { id: 'project.checkpoint'; } + | { + id: 'project.export_package'; + } | { id: 'project.index'; } @@ -581,7 +589,10 @@ function conversationRecordsToChatMessages( })); } -function isTransientProjectOpenMessage(message: ChatMessage, projectPath: string) { +function isTransientProjectOpenMessage( + message: ChatMessage, + projectPath: string, +) { return ( message.role === 'assistant' && message.text === `已设置本地项目:${projectPath}` @@ -701,11 +712,13 @@ function resolveRuntimeAgentLlmProviderPresetId( 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'; + return ( + findRuntimeLlmProviderPreset({ + baseUrl: config.baseUrl ?? '', + model: config.model ?? '', + apiKind: config.apiKind ?? defaultRuntimeConfigDraft.llm.apiKind, + })?.id ?? 'custom' + ); } function clampRuntimeConfigNumber(value: number, minimum: number) { @@ -1867,6 +1880,7 @@ const chatCommandHelp = [ '/asset-register 路径 [kind] [mediaType]:登记项目内已有资产', '/read 路径:读取本地项目内文本文件', '/run:运行自检,启动本地 HTTP 预览并交给外部浏览器', + '/export:导出本地试玩包', '/preview:启动本地 HTTP 预览并交给外部浏览器', '/open-preview:打开当前本地预览', '/preview-status:查看预览状态', @@ -2060,10 +2074,7 @@ function summarizeProjectRisks( ); if (!trace) { - addRisk( - '暂无最近 Agent run,当前项目还缺少生成闭环证据。', - '/next', - ); + addRisk('暂无最近 Agent run,当前项目还缺少生成闭环证据。', '/next'); } else if ( trace.lifecycleStatus === 'killed' || trace.status === 'failed' || @@ -2081,31 +2092,19 @@ function summarizeProjectRisks( } if (failedTasks.length > 0) { - addRisk( - `有 ${failedTasks.length} 个任务处于失败状态。`, - '/tasks', - ); + addRisk(`有 ${failedTasks.length} 个任务处于失败状态。`, '/tasks'); } if (latestCommandRun?.status === 'failed') { - addRisk( - `最近命令 ${latestCommandRun.commandId} 失败。`, - '/logs', - ); + addRisk(`最近命令 ${latestCommandRun.commandId} 失败。`, '/logs'); } if (tracePassed && !(preview?.status === 'running' && preview.url)) { - addRisk( - '最近 run 已通过,但当前本地预览未运行。', - '/run', - ); + addRisk('最近 run 已通过,但当前本地预览未运行。', '/run'); } if (readyTasks.length > 0) { - addRisk( - `还有 ${readyTasks.length} 个 ready 任务等待处理。`, - '/tasks', - ); + addRisk(`还有 ${readyTasks.length} 个 ready 任务等待处理。`, '/tasks'); } if (nextManifest.assets.length === 0) { @@ -2126,7 +2125,8 @@ function summarizeProjectRisks( risks.length > 0 ? `项目风险:\n${risks .map( - (risk) => `- ${risk.text}${risk.command ? ` 建议:${risk.command}` : ''}`, + (risk) => + `- ${risk.text}${risk.command ? ` 建议:${risk.command}` : ''}`, ) .join('\n')}` : '项目风险:\n- 暂未发现需要立即处理的风险。', @@ -2152,7 +2152,9 @@ function summarizeProjectHandoff( ); const traceReadyTasks = trace?.taskGraph.readyTaskIds - .map((taskId) => traceTasksById.get(taskId) ?? manifestTasksById.get(taskId)) + .map( + (taskId) => traceTasksById.get(taskId) ?? manifestTasksById.get(taskId), + ) .filter((task): task is GameCreationAppTaskState => Boolean(task)) ?? []; const readyTasks = traceReadyTasks.length > 0 @@ -2242,6 +2244,7 @@ function summarizeNextProjectActions( : '运行自检并启动本地预览', preview?.status === 'running' && preview.url ? '/open-preview' : '/run', ); + addSuggestion('导出本地试玩包', '/export'); } else { addSuggestion('查看最近 loop 进展', '/trace'); } @@ -2276,7 +2279,9 @@ function summarizeNextProjectActions( const firstCommand = suggestions.find((item) => item.command); return { text: `下一步建议:\n${suggestions - .map((item) => `- ${item.label}${item.command ? `:${item.command}` : ''}`) + .map( + (item) => `- ${item.label}${item.command ? `:${item.command}` : ''}`, + ) .join('\n')}`, draftCommand: firstCommand?.command, draftCommandLabel: firstCommand?.label, @@ -2369,6 +2374,16 @@ function summarizeProjectCheckpoint(result: LocalProjectCheckpointResult) { ].join('\n'); } +function summarizeProjectExportPackage( + result: LocalProjectExportPackageResult, +) { + return [ + `已导出本地试玩包:${result.packageRelativePath}`, + `文件:${result.fileCount} 个,${result.totalBytes}B`, + `路径:${result.packagePath}`, + ].join('\n'); +} + function checkpointIdFromManifestPath(path: string) { const match = path.match(/^\.agent\/checkpoints\/([^/]+)\/manifest\.json$/); return match?.[1] ?? null; @@ -2379,12 +2394,10 @@ function isCheckpointManifestFile(file: LocalProjectFileEntry) { } 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); - }); + return files.filter(isCheckpointManifestFile).sort((left, right) => { + const modifiedDelta = (right.modifiedAt ?? 0) - (left.modifiedAt ?? 0); + return modifiedDelta || right.path.localeCompare(left.path); + }); } function summarizeProjectCheckpoints( @@ -2547,7 +2560,9 @@ function summarizeProjectFileContent(result: LocalProjectFileResult) { function inferProjectFileAssetDraft(localPath: string): ProjectAssetDraft { const extension = localPath.split('.').pop()?.toLowerCase() ?? ''; - if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif'].includes(extension)) { + if ( + ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'avif'].includes(extension) + ) { const normalizedExtension = extension === 'jpg' ? 'jpeg' : extension; return { localPath, @@ -2645,8 +2660,7 @@ function firstReadableProjectAssetPath(nextManifest: GameCreationAppManifest) { } function summarizeAgentCapabilities( - capabilities: readonly GameCreationAgentCapabilityDescriptor[] = - GAME_CREATION_AGENT_CAPABILITIES, + capabilities: readonly GameCreationAgentCapabilityDescriptor[] = GAME_CREATION_AGENT_CAPABILITIES, ) { const lines = ['Agent 能力清单:']; for (const area of Object.keys(capabilityAreaLabels) as Array< @@ -3043,10 +3057,10 @@ export function summarizeAgentAudit( ? '.agent/logs/command.log 含 pending/decision' : hasAutoPermissionLog ? '.agent/logs/command.log 含 auto 权限记录' - : commandRuns.length > 0 || - hasProjectFile(files, '.agent/logs/command.log') - ? '缺 permission.pending 或确认/取消记录' - : '无命令记录' + : commandRuns.length > 0 || + hasProjectFile(files, '.agent/logs/command.log') + ? '缺 permission.pending 或确认/取消记录' + : '无命令记录' }`, '完整 trace:/trace', ].join('\n'); @@ -3088,9 +3102,7 @@ function memoryScopePath(scope: MemoryScope) { if (scope === 'short') { return 'memory/session.md'; } - return scope === 'blackboard' - ? 'memory/blackboard.md' - : 'memory/project.md'; + return scope === 'blackboard' ? 'memory/blackboard.md' : 'memory/project.md'; } function parseMemoryScope(value: string): MemoryScope { @@ -3217,6 +3229,7 @@ function isProjectPolicyConfirmableCommandId(value: string) { 'project.checkpoint', 'project.diff', 'project.restore', + 'project.export_package', 'file.list', 'file.read', 'memory.read', @@ -3274,6 +3287,7 @@ export function needsInitializedChatProject( 'file.list', 'file.read', 'project.checkpoint', + 'project.export_package', 'project.restore', 'project.policy_write', 'preview.open', @@ -3309,10 +3323,7 @@ function appendMemoryContent(current: string, next: string) { return current.trim() ? `${current.trimEnd()}\n${entry}` : entry; } -function normalizeTraceArray( - value: T[] | null | undefined, - fallback: T[], -) { +function normalizeTraceArray(value: T[] | null | undefined, fallback: T[]) { if (value == null) { return fallback; } @@ -3585,7 +3596,9 @@ function agentConversationReadDraftFromTask(task: GameCreationAppTaskState) { function agentConversationReadDraftsFromManifest( nextManifest: GameCreationAppManifest, ) { - return taskRowsFromManifest(nextManifest).map(agentConversationReadDraftFromTask); + return taskRowsFromManifest(nextManifest).map( + agentConversationReadDraftFromTask, + ); } function summarizeAgentConversationReadDrafts( @@ -3639,7 +3652,9 @@ function formatTraceRepairRoutes( ) { const visibleRoutes = routes .slice(0, 3) - .map((route) => `${route.reason}: ${formatTraceTaskIds(route.taskIds, tasks)}`); + .map( + (route) => `${route.reason}: ${formatTraceTaskIds(route.taskIds, tasks)}`, + ); if (routes.length > visibleRoutes.length) { visibleRoutes.push(`还有 ${routes.length - visibleRoutes.length} 条路线`); } @@ -3681,8 +3696,7 @@ function commandDraftFromAgentRunTrace(trace: GameCreationAgentRunTrace) { trace.steps .flatMap((step) => step.toolCalls) .map(commandDraftFromSuggestedToolCall) - .find((commandDraft) => commandDraft !== null) ?? - null + .find((commandDraft) => commandDraft !== null) ?? null ); } @@ -3700,7 +3714,9 @@ function readableArtifactsFromAgentRunTrace(trace: GameCreationAgentRunTrace) { ); } -function summarizeRunArtifactReadDrafts(trace: GameCreationAgentRunTrace | null) { +function summarizeRunArtifactReadDrafts( + trace: GameCreationAgentRunTrace | null, +) { if (!trace) { return '最近 Run 产物:暂无最近 trace'; } @@ -3756,7 +3772,9 @@ function summarizeLlmConversation(trace: GameCreationAgentRunTrace) { return `- ${step.agent} #${step.pass} · ${step.status} · ${step.phase} · ${toolIds}`; }); if (llmSteps.length > visibleLlmSteps.length) { - lines.push(`- 还有 ${llmSteps.length - visibleLlmSteps.length} 个较早 LLM 步骤`); + lines.push( + `- 还有 ${llmSteps.length - visibleLlmSteps.length} 个较早 LLM 步骤`, + ); } return lines.join('\n'); } @@ -3765,11 +3783,12 @@ export function summarizeAgentRunTrace(trace: GameCreationAgentRunTrace) { const llmConversation = summarizeLlmConversation(trace); const visibleRecentSteps = trace.steps.slice(-5); const recentStepLines = visibleRecentSteps.map( - (step) => - `- ${step.agent} #${step.pass} · ${step.status} · ${step.phase}`, + (step) => `- ${step.agent} #${step.pass} · ${step.status} · ${step.phase}`, ); if (trace.steps.length > visibleRecentSteps.length) { - recentStepLines.push(`- 还有 ${trace.steps.length - visibleRecentSteps.length} 个较早步骤`); + recentStepLines.push( + `- 还有 ${trace.steps.length - visibleRecentSteps.length} 个较早步骤`, + ); } const recentSteps = recentStepLines.join('\n'); const taskCounts = trace.taskGraph.tasks.reduce< @@ -3915,6 +3934,9 @@ function pendingCommandTitle(command: PendingCommand) { if (command.id === 'project.checkpoint') { return command.id; } + if (command.id === 'project.export_package') { + return command.id; + } if (command.id === 'project.index') { return command.id; } @@ -3977,6 +3999,9 @@ export function pendingCommandDetail( if (command.id === 'project.checkpoint') { return `保存 ${projectPath} 当前项目快照`; } + if (command.id === 'project.export_package') { + return `从 ${projectPath}/game、assets 和 exports/README.md 导出本地试玩包`; + } if (command.id === 'project.index') { return `刷新 ${projectPath}/.agent/project.index.json`; } @@ -4111,10 +4136,8 @@ export function App() { const [agentConversationMessages, setAgentConversationMessages] = useState< LocalConversationMessageRecord[] >([]); - const [ - agentConversationVisibleCount, - setAgentConversationVisibleCount, - ] = useState(CONVERSATION_INITIAL_VISIBLE_COUNT); + const [agentConversationVisibleCount, setAgentConversationVisibleCount] = + useState(CONVERSATION_INITIAL_VISIBLE_COUNT); const [agentConversationSaving, setAgentConversationSaving] = useState(false); const [agentMemoryStatus, setAgentMemoryStatus] = useState('未读取'); const [agentMemoryContent, setAgentMemoryContent] = useState(''); @@ -4233,7 +4256,8 @@ export function App() { if ( projectConversationWriteCancelledRef.current?.projectPath === nextProjectPath && - projectConversationWriteCancelledRef.current.messageCount < messages.length + projectConversationWriteCancelledRef.current.messageCount < + messages.length ) { projectConversationWriteCancelledRef.current = null; } @@ -4243,7 +4267,9 @@ export function App() { { projectPath: nextProjectPath }, ) .then((policyView) => { - if (!policyView.policy.confirmCommands.includes('conversation.write')) { + if ( + !policyView.policy.confirmCommands.includes('conversation.write') + ) { projectConversationWriteConfirmedRef.current = nextProjectPath; setConversationWriteVersion((current) => current + 1); return; @@ -4492,7 +4518,10 @@ export function App() { const message = `项目权限策略拒绝执行:${commandId}`; markProjectPolicyDenied(commandId, message); setCommandLog((current) => [...current, `permission.deny ${commandId}`]); - setMessages((current) => [...current, { role: 'assistant', text: message }]); + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); return true; } if (!policyView.policy.confirmCommands.includes(commandId)) { @@ -4694,12 +4723,15 @@ export function App() { } if ( pending.commandId === 'project.index' || - pending.commandId === 'project.diff' + pending.commandId === 'project.diff' || + pending.commandId === 'project.export_package' ) { const status = pending.commandId === 'project.index' ? '已取消刷新项目索引' - : '已取消对比项目 checkpoint'; + : pending.commandId === 'project.diff' + ? '已取消对比项目 checkpoint' + : '已取消导出本地试玩包'; setFileStatus(status); setWorkspaceStatus(status); } @@ -4778,6 +4810,17 @@ export function App() { ]); } + function queueProjectExportPackageShortcut() { + if (!requireChatProjectForUserAction()) { + return; + } + queuePendingCommand({ id: 'project.export_package' }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备导出本地试玩包。' }, + ]); + } + function queuePreviewStartShortcut() { if (!requireChatProjectForUserAction()) { return; @@ -5098,7 +5141,8 @@ export function App() { nextProjectPath, `读取 ${agent.title} Agent 对话`, '准备读取 Agent 对话。', - () => void openAgentConversation(agent, true, skipMemoryPolicyConfirm), + () => + void openAgentConversation(agent, true, skipMemoryPolicyConfirm), )) ) { setAgentConversationStatus('等待确认'); @@ -5125,7 +5169,9 @@ export function App() { return; } setAgentConversationMessages(result.messages); - setAgentConversationStatus(`已读取 ${result.messages.length} 条:${result.path}`); + setAgentConversationStatus( + `已读取 ${result.messages.length} 条:${result.path}`, + ); setCommandLog((current) => [...current, 'conversation.read']); } catch (error) { if (agentConversationLoadVersionRef.current !== loadVersion) { @@ -5155,7 +5201,9 @@ export function App() { if (agentConversationLoadVersionRef.current !== loadVersion) { return; } - setAgentMemoryStatus(error instanceof Error ? error.message : String(error)); + setAgentMemoryStatus( + error instanceof Error ? error.message : String(error), + ); return; } try { @@ -5170,7 +5218,9 @@ export function App() { return; } setAgentMemoryContent(result.content); - setAgentMemoryStatus(result.exists ? `已读取:${result.path}` : '私有记忆为空'); + setAgentMemoryStatus( + result.exists ? `已读取:${result.path}` : '私有记忆为空', + ); setCommandLog((current) => [...current, 'memory.agent.read']); } catch (error) { if (agentConversationLoadVersionRef.current !== loadVersion) { @@ -5255,7 +5305,10 @@ export function App() { } catch (error) { const message = error instanceof Error ? error.message : String(error); setAssetStatus(message); - setMessages((current) => [...current, { role: 'assistant', text: message }]); + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); } } @@ -5305,15 +5358,18 @@ export function App() { 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, + savedUserResult = await invoke( + 'append_local_conversation_message', + { + projectPath: nextProjectPath, + agentId: agent.id, + message: { + role: 'user', + content, + agentId: null, + }, }, - }); + ); if (agentConversationLoadVersionRef.current !== saveVersion) { return; } @@ -5756,7 +5812,7 @@ export function App() { ...current, { role: 'assistant', - text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、file.list、file.read、memory.read、memory.write、memory.delete、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。', + text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、file.list、file.read、memory.read、memory.write、memory.delete、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; @@ -5822,7 +5878,9 @@ export function App() { { role: 'assistant', text: summarizeRunArtifactReadDrafts(agentRunTrace), - draftCommand: firstArtifact ? `/read ${firstArtifact.path}` : undefined, + draftCommand: firstArtifact + ? `/read ${firstArtifact.path}` + : undefined, draftCommandLabel: firstArtifact ? '读取首个 Run 产物' : undefined, }, ]); @@ -5893,8 +5951,11 @@ export function App() { if (!requireChatProjectForUserAction()) { return; } - const [localPath, kind = 'asset', mediaType = 'application/octet-stream'] = - prompt.slice('/asset-register '.length).trim().split(/\s+/); + const [ + localPath, + kind = 'asset', + mediaType = 'application/octet-stream', + ] = prompt.slice('/asset-register '.length).trim().split(/\s+/); if (!localPath) { setMessages((current) => [ ...current, @@ -6092,6 +6153,11 @@ export function App() { return; } + if (prompt === '/export') { + queueProjectExportPackageShortcut(); + return; + } + if (prompt === '/preview') { if (!requireChatProjectForUserAction()) { return; @@ -6462,9 +6528,7 @@ export function App() { status.baseUrl ?? '未设置 base_url' },${status.apiKind},流式 ${ status.stream ? '开启' : '关闭' - },API Key ${ - status.apiKeyPresent ? '已读取' : '未读取' - }。` + },API Key ${status.apiKeyPresent ? '已读取' : '未读取'}。` : `LLM 未就绪:${status.error ?? '配置不完整'}。API Key:${ status.apiKeyPresent ? '已读取' : '未读取' }。`; @@ -6493,10 +6557,9 @@ export function App() { GAME_CREATION_AGENT_CAPABILITIES; if (invoke) { try { - const nativeCapabilities = - await invoke( - 'get_game_creation_agent_capabilities', - ); + const nativeCapabilities = await invoke< + GameCreationAgentCapabilityDescriptor[] + >('get_game_creation_agent_capabilities'); if (nativeCapabilities.length > 0) { capabilities = nativeCapabilities; } @@ -6562,7 +6625,8 @@ export function App() { 'memory.write', 'file.write game/index.html', ]); - const completionSummary = await refreshAgentRunTrace(generatedProjectPath); + const completionSummary = + await refreshAgentRunTrace(generatedProjectPath); try { const previewResult = await invoke( @@ -6636,7 +6700,10 @@ export function App() { setProjectStatus('已取消生成游戏草案'); return; } - if (command.id === 'game.run_local' || command.id === 'command.run_limited') { + if ( + command.id === 'game.run_local' || + command.id === 'command.run_limited' + ) { setLimitedCommandStatus('已取消运行'); return; } @@ -6644,6 +6711,10 @@ export function App() { setFileStatus('已取消保存 checkpoint'); return; } + if (command.id === 'project.export_package') { + setFileStatus('已取消导出本地试玩包'); + return; + } if (command.id === 'project.index') { setFileStatus('已取消索引项目'); setWorkspaceStatus('已取消索引项目'); @@ -6791,6 +6862,8 @@ export function App() { void executeProjectCreate(command.projectPath, true); } else if (command.id === 'project.checkpoint') { void executeProjectCheckpoint(true); + } else if (command.id === 'project.export_package') { + void executeProjectExportPackage(true); } else if (command.id === 'project.index') { void executeProjectIndex(true); } else if (command.id === 'project.restore') { @@ -7098,6 +7171,56 @@ export function App() { } } + async function executeProjectExportPackage(announceToChat: boolean) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setFileStatus('需要在 Tauri App 内运行'); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内运行。' }, + ]); + } + return; + } + const nextProjectPath = announceToChat + ? requireChatProjectForUserAction() + : projectPath; + if (!nextProjectPath) { + return; + } + + try { + const result = await invoke( + 'export_local_project_package', + { projectPath: nextProjectPath }, + ); + setFileStatus(`已导出本地试玩包:${result.packageRelativePath}`); + setCommandLog((current) => [...current, 'project.export_package']); + void refreshManifest(nextProjectPath); + if (announceToChat) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: summarizeProjectExportPackage(result), + draftCommand: '/open-project', + draftCommandLabel: '显示目录', + }, + ]); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setFileStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + async function executeProjectCheckpoints( announceToChat: boolean, skipListPolicyConfirm = false, @@ -7417,18 +7540,26 @@ export function App() { mode === 'deny' ? [...new Set([...result.policy.deniedCommands, commandId])] : mode === 'allow' - ? result.policy.deniedCommands.filter((value) => value !== commandId) + ? result.policy.deniedCommands.filter( + (value) => value !== commandId, + ) : mode === 'confirm' - ? result.policy.deniedCommands.filter((value) => value !== commandId) - : result.policy.deniedCommands; + ? 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; + ? 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, confirmCommands }, @@ -7632,9 +7763,7 @@ export function App() { draftCommand: firstReadableAsset ? `/read ${firstReadableAsset}` : undefined, - draftCommandLabel: firstReadableAsset - ? '读取首个资产' - : undefined, + draftCommandLabel: firstReadableAsset ? '读取首个资产' : undefined, }, ]); } @@ -7887,12 +8016,7 @@ export function App() { ? '准备查看 Agent run 状态。' : '准备执行 Agent run 操作。', () => - void executeAgentRunControl( - action, - detail, - announceToChat, - true, - ), + void executeAgentRunControl(action, detail, announceToChat, true), )) ) { return; @@ -7970,9 +8094,7 @@ export function App() { } } - function queueAgentRunControlFromPanel( - action: 'kill' | 'retry' | 'resume', - ) { + function queueAgentRunControlFromPanel(action: 'kill' | 'retry' | 'resume') { if (!requireChatProjectForUserAction()) { return; } @@ -8758,9 +8880,9 @@ export function App() { } setLimitedCommandStatus('正在读取内置命令'); try { - const commands = await invoke( - 'get_limited_local_commands', - ); + const commands = await invoke< + GameCreationAppLimitedRunCommandDescriptor[] + >('get_limited_local_commands'); setLimitedLocalCommands( commands.length > 0 ? commands @@ -8818,7 +8940,9 @@ export function App() { setCommandLog((current) => [...current, `file.read ${result.path}`]); } catch (error) { setProjectLogContent(''); - setProjectLogStatus(error instanceof Error ? error.message : String(error)); + setProjectLogStatus( + error instanceof Error ? error.message : String(error), + ); } } @@ -8829,10 +8953,9 @@ export function App() { ]; if (invoke) { try { - const nativeCommands = - await invoke( - 'get_limited_local_commands', - ); + const nativeCommands = await invoke< + GameCreationAppLimitedRunCommandDescriptor[] + >('get_limited_local_commands'); if (nativeCommands.length > 0) { commands = nativeCommands; } @@ -9126,7 +9249,10 @@ export function App() { ); } - async function executeFileWrite(nextProjectPath: string, relativePath: string) { + async function executeFileWrite( + nextProjectPath: string, + relativePath: string, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setFileStatus('需要在 Tauri App 内运行'); @@ -9162,7 +9288,10 @@ export function App() { ); } - async function executeFileDelete(nextProjectPath: string, relativePath: string) { + async function executeFileDelete( + nextProjectPath: string, + relativePath: string, + ) { const invoke = resolveTauriInvoke(); if (!invoke) { setFileStatus('需要在 Tauri App 内运行'); @@ -9636,7 +9765,12 @@ export function App() { nextProjectPath, `同步 ${nextProjectPath} 的画板项目资源:${canvasProjectId}`, '准备同步画板项目资源。', - () => void executeCanvasProjectSync(canvasProjectId, announceToChat, true), + () => + void executeCanvasProjectSync( + canvasProjectId, + announceToChat, + true, + ), )) ) { setAssetStatus('等待确认同步画板项目'); @@ -10424,6 +10558,13 @@ export function App() { > 运行 + @@ -10646,9 +10785,7 @@ export function App() { @@ -10848,10 +10985,7 @@ export function App() { ))} ) : null} -
+
{hiddenConversationCount > 0 ? ( @@ -11233,7 +11366,8 @@ export function App() { ) : null} {selectedAgent.toolCalls.slice(0, 5).map((toolCall) => { - const commandDraft = commandDraftFromSuggestedToolCall(toolCall); + const commandDraft = + commandDraftFromSuggestedToolCall(toolCall); return ( {[ @@ -11252,7 +11386,9 @@ export function App() { {commandDraft ? ( @@ -11417,28 +11553,22 @@ export function App() {
- {`active: ${ - formatTraceTaskIds( - agentRunTrace.taskGraph.activeTaskIds, - agentRunTrace.taskGraph.tasks, - ) - }`} + {`active: ${formatTraceTaskIds( + agentRunTrace.taskGraph.activeTaskIds, + agentRunTrace.taskGraph.tasks, + )}`} - {`carry-over: ${ - formatTraceTaskIds( - agentRunTrace.taskGraph.carriedTaskIds, - agentRunTrace.taskGraph.tasks, - ) - }`} + {`carry-over: ${formatTraceTaskIds( + agentRunTrace.taskGraph.carriedTaskIds, + agentRunTrace.taskGraph.tasks, + )}`} - {`ready: ${ - formatTraceTaskIds( - agentRunTrace.taskGraph.readyTaskIds, - agentRunTrace.taskGraph.tasks, - ) - }`} + {`ready: ${formatTraceTaskIds( + agentRunTrace.taskGraph.readyTaskIds, + agentRunTrace.taskGraph.tasks, + )}`} {agentRunTrace.taskGraph.repairFocus.length > 0 ? ( @@ -11506,8 +11636,8 @@ export function App() { agentRunTrace.taskGraph.tasks, ) : step.group && step.role - ? `${taskGroupLabels[step.group]} / ${step.role}` - : null, + ? `${taskGroupLabels[step.group]} / ${step.role}` + : null, ] .filter(Boolean) .join(' · ')} @@ -11742,11 +11872,11 @@ export function App() { onChange={(event) => setMemoryScope(event.currentTarget.value as MemoryScope) } - > - - - - + > + + + + diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 341c9d5ac..02268ccdd 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -953,7 +953,8 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: '/tmp/typed-game' }, }); - fireEvent.click(screen.getByRole('button', { name: '显示目录' })); + const revealButtons = screen.getAllByRole('button', { name: '显示目录' }); + fireEvent.click(revealButtons[revealButtons.length - 1]); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('open_local_project_directory', { @@ -971,7 +972,8 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.change(screen.getByLabelText('项目目录'), { target: { value: 'relative-game' }, }); - fireEvent.click(screen.getByRole('button', { name: '显示目录' })); + const revealButtons = screen.getAllByRole('button', { name: '显示目录' }); + fireEvent.click(revealButtons[revealButtons.length - 1]); expect(screen.getByText('请提供项目绝对路径')).not.toBeNull(); expect(invoke).not.toHaveBeenCalledWith( @@ -1580,7 +1582,8 @@ describe('AI 游戏创作 App 界面边界', () => { renderAppAt('/?main&projectPath=%2Ftmp%2Fauthorized-game'); await screen.findByText('已打开:/tmp/authorized-game'); - fireEvent.click(screen.getByRole('button', { name: '显示目录' })); + const revealButtons = screen.getAllByRole('button', { name: '显示目录' }); + fireEvent.click(revealButtons[revealButtons.length - 1]); await waitFor(() => { expect(invoke).toHaveBeenCalledWith('open_local_project_directory', { @@ -2009,6 +2012,14 @@ describe('AI 游戏创作 App 界面边界', () => { totalBytes: 256, }; } + if (command === 'export_local_project_package') { + return { + packagePath: `${String(args?.projectPath ?? '')}/exports/playtest-package-unit.zip`, + packageRelativePath: 'exports/playtest-package-unit.zip', + fileCount: 4, + totalBytes: 512, + }; + } throw new Error(`unexpected invoke ${command}`); }, ); @@ -2125,7 +2136,9 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/brief'); expect(await screen.findByText(/项目简报:/)).not.toBeNull(); expect(screen.getByText(/任务:完成 0\/16 · ready 1/)).not.toBeNull(); - expect(screen.getByText(/最近 Run:run-main-shortcut-trace/)).not.toBeNull(); + expect( + screen.getByText(/最近 Run:run-main-shortcut-trace/), + ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '查看下一步' })); expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/next'); expect( @@ -2148,8 +2161,9 @@ describe('AI 游戏创作 App 界面边界', () => { expect( screen.getByText(/最近 run 已通过,但当前本地预览未运行。 建议:\/run/), ).not.toBeNull(); - expect(screen.getByText(/还有 1 个 ready 任务等待处理。 建议:\/tasks/)) - .not.toBeNull(); + expect( + screen.getByText(/还有 1 个 ready 任务等待处理。 建议:\/tasks/), + ).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '处理首个风险' })); expect(screen.getByLabelText('创作想法')).toHaveProperty('value', '/run'); expect( @@ -2356,9 +2370,7 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/artifacts'); expect(await screen.findByText(/常用生成产物:/)).not.toBeNull(); expect(screen.getByText(/读入口 · game\/index\.html/)).not.toBeNull(); - expect( - screen.getByText(/读发布说明 · exports\/README\.md/), - ).not.toBeNull(); + expect(screen.getByText(/读发布说明 · exports\/README\.md/)).not.toBeNull(); const readEntryButtons = screen.getAllByRole('button', { name: '读入口' }); fireEvent.click(readEntryButtons[readEntryButtons.length - 1]); expect(composerInput).toHaveProperty('value', '/read game/index.html'); @@ -2592,6 +2604,39 @@ describe('AI 游戏创作 App 界面边界', () => { ), ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '导出' })); + expect(await screen.findByText('project.export_package')).not.toBeNull(); + expect( + screen.getByText( + '从 /tmp/authorized-game/game、assets 和 exports/README.md 导出本地试玩包', + ), + ).not.toBeNull(); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + expect( + await screen.findByText( + /已导出本地试玩包:exports\/playtest-package-unit\.zip/, + ), + ).not.toBeNull(); + const exportRevealButtons = screen.getAllByRole('button', { + name: '显示目录', + }); + fireEvent.click(exportRevealButtons[exportRevealButtons.length - 1]); + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/open-project', + ); + + submitChat('/export'); + await waitFor(() => + expect( + screen.getAllByText('准备导出本地试玩包。').length, + ).toBeGreaterThan(1), + ); + expect( + screen.getAllByText('project.export_package').length, + ).toBeGreaterThan(0); + fireEvent.click(screen.getByRole('button', { name: '取消' })); + fireEvent.click(screen.getByRole('button', { name: '启动预览' })); expect(await screen.findByText('preview.start')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); @@ -2663,6 +2708,9 @@ describe('AI 游戏创作 App 界面边界', () => { expect(invoke).toHaveBeenCalledWith('create_local_project_checkpoint', { projectPath: '/tmp/authorized-game', }); + expect(invoke).toHaveBeenCalledWith('export_local_project_package', { + projectPath: '/tmp/authorized-game', + }); expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { projectPath: '/tmp/authorized-game', }); @@ -7824,6 +7872,7 @@ describe('AI 游戏创作 App 界面边界', () => { screen.getByText(/\/audit:审计当前项目的 Agent 能力证据/), ).not.toBeNull(); expect(screen.getByText(/\/checkpoint:保存本地项目快照/)).not.toBeNull(); + expect(screen.getByText(/\/export:导出本地试玩包/)).not.toBeNull(); expect( screen.getByText(/\/checkpoints:列出最近 checkpoint/), ).not.toBeNull(); @@ -7882,9 +7931,7 @@ describe('AI 游戏创作 App 界面边界', () => { expect( screen.getByText(/\/runs:列出已加载 Run 历史读取命令/), ).not.toBeNull(); - expect( - screen.getByText(/\/logs:列出常用日志读取命令/), - ).not.toBeNull(); + expect(screen.getByText(/\/logs:列出常用日志读取命令/)).not.toBeNull(); expect( screen.getByText(/\/canvas 画板项目ID:打开本机画板项目/), ).not.toBeNull(); @@ -11967,77 +12014,78 @@ describe('AI 游戏创作 App 界面边界', () => { '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') { - 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') { - if (args?.relativePath === '.agent/logs/command.log') { + 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') { return { - path: '.agent/logs/command.log', - absolutePath: - '/tmp/authorized-game/.agent/logs/command.log', - content: 'permission.pending preview.start\npermission.confirm preview.start\n', + projectPath: '/tmp/authorized-game', + manifestPath: '/tmp/authorized-game/.agent/manifest.json', + manifest, }; } - if (args?.relativePath === '.agent/logs/preview.log') { + if (command === 'read_local_conversation') { return { - path: '.agent/logs/preview.log', - absolutePath: - '/tmp/authorized-game/.agent/logs/preview.log', - content: 'preview.start http://127.0.0.1:3210/\n', + path: '/tmp/authorized-game/.agent/conversations/project.jsonl', + agentId: null, + messages: [], }; } - if (args?.relativePath === '.agent/logs/agent.log') { + if (command === 'read_local_project_file') { + if (args?.relativePath === '.agent/logs/command.log') { + return { + path: '.agent/logs/command.log', + absolutePath: '/tmp/authorized-game/.agent/logs/command.log', + content: + 'permission.pending preview.start\npermission.confirm preview.start\n', + }; + } + if (args?.relativePath === '.agent/logs/preview.log') { + return { + path: '.agent/logs/preview.log', + absolutePath: '/tmp/authorized-game/.agent/logs/preview.log', + content: 'preview.start http://127.0.0.1:3210/\n', + }; + } + if (args?.relativePath === '.agent/logs/agent.log') { + return { + path: '.agent/logs/agent.log', + absolutePath: '/tmp/authorized-game/.agent/logs/agent.log', + content: 'Planner 正在整理规格\nGenerator 已写入草案\n', + }; + } + 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 { - path: '.agent/logs/agent.log', - absolutePath: '/tmp/authorized-game/.agent/logs/agent.log', - content: 'Planner 正在整理规格\nGenerator 已写入草案\n', + commandId: 'game.custom_smoke', + status: 'completed', + output: 'custom smoke passed', + logPath: '.agent/logs/custom.log', }; } - 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}`); - }); + 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('日志')); @@ -12064,7 +12112,9 @@ describe('AI 游戏创作 App 界面边界', () => { expect( await screen.findByText('已读取:.agent/logs/command.log'), ).not.toBeNull(); - expect(screen.getByText(/permission\.confirm preview\.start/)).not.toBeNull(); + expect( + screen.getByText(/permission\.confirm preview\.start/), + ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('read_local_project_file', { projectPath: '/tmp/authorized-game', relativePath: '.agent/logs/command.log', @@ -12166,10 +12216,11 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/status'); - const statusMessage = await screen.findByText((_, element) => - element?.classList.contains('message--assistant') === true && - element.textContent?.includes('项目:未命名游戏原型') === true && - element.textContent.includes('目录:/tmp/authorized-game'), + const statusMessage = await screen.findByText( + (_, element) => + element?.classList.contains('message--assistant') === true && + element.textContent?.includes('项目:未命名游戏原型') === true && + element.textContent.includes('目录:/tmp/authorized-game'), ); expect(statusMessage.textContent).toContain('任务:已完成 1,待处理 15'); expect(statusMessage.textContent).toContain('资产:1 个'); diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1363dbd4f..dc183b3a5 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,14 @@ --- +## 2026-07-03 AI 游戏创作 App 本地试玩包导出只打包运行白名单 + +- 背景:AI 游戏创作 App 需要给普通用户提供首版本地试玩包,但不能把项目记忆、trace、日志、运行时配置或密钥类文件混入可分发 ZIP。 +- 决策:v1 新增 `/export` 聊天入口和 `project.export_package` 确认命令。导出前重新校验 `game/index.html` 是可试玩自包含 HTML;ZIP 只包含 `game/**`、`assets/**` 和 `exports/README.md`,输出到 `exports/playtest-package-*.zip`;导出拒绝符号链接和不安全条目路径,并写入 manifest `commandRuns`、`.agent/logs/command.log` 和 `.agent/agent.db`。 +- 影响范围:`apps/ai-game-creator-shell` 的聊天命令、Tauri 本地项目能力、共享命令契约和 AI 游戏创作 App 实施计划。 +- 验证方式:运行 AI 游戏创作壳主窗口 smoke、Tauri `export` 定向测试、共享契约测试、类型检查、编码检查和 `git diff --check`。 +- 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 + ## 2026-07-01 AI 游戏创作 App v1 使用本地 JSONL 对话和派生 Agent 状态 - 背景:AI 游戏创作 App 已有 Godcoder 式本地工程护栏、项目黑板、角色私有记忆、manifest 和 run trace;新增结构化对话记录、agent 状态列表和单 agent 对话入口时,需要避免引入平行状态源或提前承诺后台 runner 能力。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index d02ffd288..00a4fbae0 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -100,7 +100,7 @@ game-project/ ## v1 验收 - 用户能创建本地 Web 游戏项目。 -- 用户侧看到当前工作区项目名 / 路径、最近 run、预览状态、任务完成数 / ready 数、资产数量 / 来源分布、最近命令摘要、聊天框、上传入口、Agent 状态列表和单 Agent 对话;聊天输入 `/brief` 可在普通聊天消息里生成当前项目简报并提供 `/next` 草稿,不新增普通用户面板;聊天输入 `/risks` 可在普通聊天消息里查看当前项目风险并提供首个风险处理草稿,不新增普通用户面板;聊天输入 `/handoff` 可在普通聊天消息里生成当前项目交接摘要并提供 `/next` 草稿,不新增普通用户面板;聊天输入 `/runs` 可在普通聊天消息里列出已加载 Run 历史读取命令,不新增普通用户面板;聊天输入 `/run-files` 可在普通聊天消息里列出 Agent 运行辅助文件读取命令,不新增普通用户面板;Agent 状态列表和单 Agent 对话在 `/llm-status` 后显示该 agent 当前 LLM provider / 模型 / 流式 / API Key 读取状态,但不显示密钥本体;最近项目资产入口显示本地路径、kind、mediaType 和来源类型,并可一键填入 `/read` 草稿,开发环境通过独立窗口查看任务拆分、专业组细节、产物、文件面板、嵌入预览以及 `.agent/logs/command.log` / `preview.log` / `agent.log`。 +- 用户侧看到当前工作区项目名 / 路径、最近 run、预览状态、任务完成数 / ready 数、资产数量 / 来源分布、最近命令摘要、聊天框、上传入口、Agent 状态列表和单 Agent 对话;聊天输入 `/brief` 可在普通聊天消息里生成当前项目简报并提供 `/next` 草稿,不新增普通用户面板;聊天输入 `/risks` 可在普通聊天消息里查看当前项目风险并提供首个风险处理草稿,不新增普通用户面板;聊天输入 `/handoff` 可在普通聊天消息里生成当前项目交接摘要并提供 `/next` 草稿,不新增普通用户面板;聊天输入 `/runs` 可在普通聊天消息里列出已加载 Run 历史读取命令,不新增普通用户面板;聊天输入 `/run-files` 可在普通聊天消息里列出 Agent 运行辅助文件读取命令,不新增普通用户面板;聊天输入 `/export` 确认后把当前可试玩原型导出为本地试玩 ZIP;Agent 状态列表和单 Agent 对话在 `/llm-status` 后显示该 agent 当前 LLM provider / 模型 / 流式 / API Key 读取状态,但不显示密钥本体;最近项目资产入口显示本地路径、kind、mediaType 和来源类型,并可一键填入 `/read` 草稿,开发环境通过独立窗口查看任务拆分、专业组细节、产物、文件面板、嵌入预览以及 `.agent/logs/command.log` / `preview.log` / `agent.log`。 - 生成代码和资产进入用户本地项目目录。 - 本地 HTTP 预览能启动,并在外部浏览器展示可玩原型。 - 美术/音乐资产能从画板链路回流到本地项目。 @@ -116,6 +116,7 @@ game-project/ - `/handoff` 聊天入口由 `appSurface.test.ts` 的主窗口 smoke 覆盖:只基于当前已加载的 manifest / trace / agent 状态 / run history 生成交接摘要,提供 `/next` 草稿,不触发 Tauri 读写、文件读取、预览启动或新增普通用户面板。 - `/runs` 聊天入口由 `appSurface.test.ts` 的主窗口 smoke 覆盖:只基于当前已加载的 latest trace 和已载入历史 run 批次生成 `/trace` 或 `/read .agent/runs/...` 草稿,不额外触发 Tauri 读取、不滚动加载更多历史、不新增普通用户面板。 - `/run-files` 聊天入口由 `appSurface.test.ts` 的主窗口 smoke 覆盖:只列出 `.agent/output.jsonl`、`.agent/activity.jsonl` 和 `.agent/context.bundle.json` 的 `/read` 草稿,不直接读取辅助文件、不触发 Tauri 读写或新增普通用户面板。 +- `/export` 聊天入口由 `appSurface.test.ts` 的主窗口 smoke 和 Tauri Rust 测试覆盖:确认后执行 `project.export_package`,导出 `exports/playtest-package-*.zip`,只打包 `game/**`、`assets/**` 和 `exports/README.md`,不包含 `.agent/`、`memory/`、运行时配置、日志、trace 或密钥文件;导出前必须通过 `game/index.html` 可试玩静态验收,并写入 manifest `commandRuns`、`.agent/logs/command.log` 和 `.agent/agent.db`。 - 主窗口策略快捷入口只填入 `/policy-confirm project.index`、`/policy-confirm asset.register`、`/policy-confirm memory.write`、`/policy-confirm preview.start`、`/policy-confirm preview.open`、`/policy-confirm preview.stop`、`/policy-confirm agent.run_status`、`/policy-confirm conversation.read` 或 `/policy-confirm conversation.write` 草稿;Agent 状态栏的“继续说明”只填入 `/agent-resume ` 草稿,不直接触发 run 生命周期写入。 - 主窗口最近 checkpoint 列表展示 checkpoint id、文件数、大小和创建时间,同时提供直接对比、填入 `/diff`、确认回滚和填入 `/restore`;回滚继续走 `project.restore` 确认卡,不直接写项目文件。 - `npm run check:native-shells`:覆盖 AI 游戏创作壳的 release/dev 窗口边界、正式用户 App 不嵌入游戏预览 iframe、用户侧预览命令交给外部浏览器和 Tauri release `--no-bundle` 构建 smoke;用于证明正式发布只登记 `launcher` 启动器窗口,选择工作区后才关闭启动器并打开 `main` 主窗口,开发面板只在 debug/dev 路径打开,独立壳能完成 release 编译。 @@ -183,7 +184,7 @@ game-project/ - 聊天输入 `/risks` 只使用主窗口当前已加载的 manifest、最近 run trace、预览状态、任务状态、资产来源和最近命令摘要,在聊天里列出当前项目风险,并提供首个风险处理草稿;该命令不调用 Tauri 读写、不读取文件、不启动或打开预览、不新增普通用户面板,用户必须再发送草稿并按原命令权限流继续。 - 聊天输入 `/handoff` 只使用主窗口当前已加载的 manifest、最近 run trace、Agent 状态和已载入历史 run 批次,在聊天里生成项目交接摘要,并提供 `/next` 作为后续草稿;该命令不调用 Tauri 读写、不读取文件、不启动或打开预览、不新增普通用户面板,用户必须再发送草稿并按原命令权限流继续。 - 聊天输入 `/runs` 只使用主窗口当前已加载的 latest trace 和已载入历史 run 批次,在聊天里列出 `/trace` 和 `/read .agent/runs/...` 读取草稿;该命令不调用 Tauri 读写、不滚动加载更多历史、不启动或打开预览、不新增普通用户面板,用户必须再发送草稿并按原命令权限流继续。 -- 聊天输入 `/next` 只使用主窗口当前已加载的 manifest、最近 run trace 和最近命令摘要,在聊天里给出下一步建议,列出 `/tasks`、`/trace`、`/run`、`/open-preview`、`/assets`、`/artifacts`、`/run-artifacts`、`/run-files`、`/logs`、`/agent-resume ` 等安全命令草稿方向,并提供一个首选草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件,用户必须再发送草稿并按原命令权限流继续。 +- 聊天输入 `/next` 只使用主窗口当前已加载的 manifest、最近 run trace 和最近命令摘要,在聊天里给出下一步建议,列出 `/tasks`、`/trace`、`/run`、`/export`、`/open-preview`、`/assets`、`/artifacts`、`/run-artifacts`、`/run-files`、`/logs`、`/agent-resume ` 等安全命令草稿方向,并提供一个首选草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件,用户必须再发送草稿并按原命令权限流继续。 - 主窗口可从 agent 状态列表进入单个 agent 对话;该入口只加载目标 agent 的 conversation JSONL,发送消息后追加到同一 agent conversation,不开启平行任务图、不 fork run,也不归档历史会话。 - v1 通过独立启动器窗口选择本地项目后再进入主窗口;主窗口只承载当前工作区的聊天、配置和 agent 状态,切换项目时关闭当前主窗口并回到启动器,主窗口按钮和 `/switch-project` 聊天命令都复用同一 Tauri 启动器入口,避免在主窗口内用遮罩面板混合多个工作区上下文。 - 主窗口可通过系统文件管理器显示当前项目目录,也可在聊天输入 `/open-project` 走同一只读打开动作;该操作只打开本地目录,不初始化项目、不写项目文件、不切换工作区。主窗口头部显示最近 `.agent/run.latest.json` 的 run 状态摘要和当前项目预览状态,并通过“刷新状态”重新读取同一 trace,不新增状态数据库。 @@ -194,6 +195,7 @@ 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。 +- 聊天输入 `/export` 会生成待确认的 `project.export_package` 内置命令,确认后只把 `game/**`、`assets/**` 和 `exports/README.md` 打包到 `exports/playtest-package-*.zip`;导出前重新校验 `game/index.html` 是可试玩自包含 HTML,拒绝符号链接和越界路径,不把 `.agent/`、`memory/`、日志、trace、运行时配置或密钥文件写入 ZIP。 - 聊天输入 `/preview` 会生成待确认的 `preview.start` 内置命令,确认后启动只读本地 HTTP 预览并交给外部浏览器;`/open-preview` 在本地项目已初始化后会生成待确认的 `preview.open`,并且只打开当前已授权项目对应的 `127.0.0.1` 本地预览;`/preview-status` 只查询当前已授权项目对应的本地 HTTP 预览并写入 `preview.status` 命令日志;`/preview-stop` 只停止当前项目预览,不打开、展示或停止其它项目遗留的全局预览,不向普通用户暴露预览面板。 - 聊天输入 `/memory [short|long|blackboard]` 读取短期、长期或黑板记忆;`/remember [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并追加短期、长期或黑板记忆,未写 scope 时默认追加长期记忆;主窗口“记到黑板”“覆盖黑板”“清空黑板”只填入 `/remember blackboard `、`/memory-set blackboard ` 或 `/forget-memory blackboard` 草稿,仍由用户补内容并走聊天确认;`/memory-set [short|long|blackboard] 内容` 生成待确认的 `memory.write` 并覆盖保存对应记忆;`/forget-memory [short|long|blackboard]` 生成待确认的 `memory.delete`。 - 聊天输入 `/canvas 画板项目ID` 会生成待确认的 `canvas.project_open`,只打开本机 Genarrative 编辑器里的指定画板项目,不开放任意 URL;确认后聊天先反馈正在打开,再回写真实打开 URL。画板项目 ID 为空或包含控制字符时在聊天侧直接拒绝。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index eda441fe7..1fb589ffc 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -36,6 +36,11 @@ describe('AI 游戏创作 App 共享契约', () => { (command) => command.id === 'game.run_local', )?.permission, ).toBe('confirm'); + expect( + GAME_CREATION_APP_COMMANDS.find( + (command) => command.id === 'project.export_package', + )?.permission, + ).toBe('confirm'); expect( GAME_CREATION_APP_COMMANDS.find( (command) => command.id === 'project.status', diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index f54e4292c..e24665a1e 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -20,6 +20,7 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'project.checkpoint', permission: 'confirm' }, { id: 'project.diff', permission: 'auto' }, { id: 'project.restore', permission: 'confirm' }, + { id: 'project.export_package', permission: 'confirm' }, { id: 'project.policy_read', permission: 'auto' }, { id: 'project.policy_write', permission: 'confirm' }, { id: 'task.list', permission: 'auto' }, 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 117b97160..770365d0d 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; 42] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 43] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), command("project.status", GameCreationAppPermission::Auto), @@ -29,6 +29,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 42] = [ command("project.checkpoint", GameCreationAppPermission::Confirm), command("project.diff", GameCreationAppPermission::Auto), command("project.restore", GameCreationAppPermission::Confirm), + command("project.export_package", GameCreationAppPermission::Confirm), command("project.policy_read", GameCreationAppPermission::Auto), command("project.policy_write", GameCreationAppPermission::Confirm), command("task.list", GameCreationAppPermission::Auto), @@ -638,6 +639,15 @@ mod tests { .expect("command should exist"); assert_eq!(run_local.permission, GameCreationAppPermission::Confirm); + let export_package = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == "project.export_package") + .expect("command should exist"); + assert_eq!( + export_package.permission, + GameCreationAppPermission::Confirm + ); + let status = GAME_CREATION_APP_COMMANDS .iter() .find(|command| command.id == "project.status")