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 60f7a7880..494b42a55 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -417,6 +417,22 @@ struct LocalProjectExportPackageResult { total_bytes: u64, } +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalProjectExportPackageSummary { + package_path: String, + package_relative_path: String, + total_bytes: u64, + modified_at: u64, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalProjectExportPackagesResult { + project_path: String, + packages: Vec, +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct LocalProjectDiffEntry { @@ -1686,6 +1702,15 @@ fn export_local_project_package( export_local_project_package_at(root) } +#[tauri::command] +fn list_local_project_export_packages( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.export_list")?; + list_local_project_export_packages_at(root) +} + #[tauri::command] fn diff_local_project_checkpoint( project_path: String, @@ -7854,6 +7879,73 @@ fn next_project_export_package_relative_path(root: &Path) -> Result Result { + validate_project_root(root)?; + let export_dir = resolve_local_project_path(root, "exports")?; + if !export_dir.exists() { + return Ok(LocalProjectExportPackagesResult { + project_path: root.to_string_lossy().into_owned(), + packages: Vec::new(), + }); + } + let export_dir_metadata = checked_export_package_metadata(&export_dir, "exports")?; + if !export_dir_metadata.is_dir() { + return Err("exports 必须是目录".to_string()); + } + + let mut packages = Vec::new(); + for entry in fs::read_dir(&export_dir) + .map_err(|error| format!("读取试玩包目录失败:{}: {error}", export_dir.display()))? + { + let entry = entry + .map_err(|error| format!("读取试玩包文件失败:{}: {error}", export_dir.display()))?; + let file_type = entry.file_type().map_err(|error| { + format!( + "读取试玩包文件类型失败:{}: {error}", + entry.path().display() + ) + })?; + if file_type.is_symlink() || !file_type.is_file() { + continue; + } + let file_name = match entry.file_name().to_str() { + Some(value) => value.to_string(), + None => continue, + }; + if !file_name.starts_with("playtest-package-") || !file_name.ends_with(".zip") { + continue; + } + let package_relative_path = + normalize_export_package_entry_path(&format!("exports/{file_name}"))?; + let metadata = entry.metadata().map_err(|error| { + format!("读取试玩包元数据失败:{}: {error}", entry.path().display()) + })?; + let modified_at = metadata + .modified() + .ok() + .and_then(|time| time.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_millis().min(u128::from(u64::MAX)) as u64) + .unwrap_or(0); + packages.push(LocalProjectExportPackageSummary { + package_path: entry.path().to_string_lossy().into_owned(), + package_relative_path, + total_bytes: metadata.len(), + modified_at, + }); + } + packages.sort_by(|left, right| { + (right.modified_at, &right.package_relative_path) + .cmp(&(left.modified_at, &left.package_relative_path)) + }); + + Ok(LocalProjectExportPackagesResult { + project_path: root.to_string_lossy().into_owned(), + packages, + }) +} + fn collect_project_export_package_files( root: &Path, ) -> Result, String> { @@ -8984,6 +9076,7 @@ fn main() { build_local_project_index, create_local_project_checkpoint, export_local_project_package, + list_local_project_export_packages, diff_local_project_checkpoint, restore_local_project_checkpoint, read_project_permission_policy, @@ -12566,6 +12659,59 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn local_project_export_package_list_only_returns_recent_playtest_zips() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + fs::write(root.join("exports/playtest-package-001.zip"), "old").expect("write old zip"); + fs::write(root.join("exports/not-playtest.zip"), "ignore").expect("write ignored zip"); + fs::write(root.join("exports/playtest-package-002.zip"), "newer").expect("write newer zip"); + fs::write(root.join("game/playtest-package-003.zip"), "wrong dir") + .expect("write wrong dir zip"); + + let result = list_local_project_export_packages_at(&root).expect("list packages"); + + assert_eq!(result.project_path, root.to_string_lossy().into_owned()); + let paths = result + .packages + .iter() + .map(|package| package.package_relative_path.as_str()) + .collect::>(); + assert_eq!( + paths, + vec![ + "exports/playtest-package-002.zip", + "exports/playtest-package-001.zip" + ] + ); + assert_eq!(result.packages[0].total_bytes, 5); + assert!(result.packages[0].modified_at > 0); + + fs::remove_dir_all(root).ok(); + } + + #[cfg(unix)] + #[test] + fn local_project_export_package_list_skips_symlink_packages() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + fs::write(root.join("exports/playtest-package-real.zip"), "real").expect("write zip"); + std::os::unix::fs::symlink( + root.join("memory/project.md"), + root.join("exports/playtest-package-link.zip"), + ) + .expect("create package symlink"); + + let result = list_local_project_export_packages_at(&root).expect("list packages"); + + assert_eq!(result.packages.len(), 1); + assert_eq!( + result.packages[0].package_relative_path, + "exports/playtest-package-real.zip" + ); + fs::remove_dir_all(root).ok(); + } + #[cfg(unix)] #[test] fn local_project_export_package_rejects_symlink_assets() { diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 54a6b9269..3da5b0f9e 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -300,6 +300,18 @@ interface LocalProjectExportPackageResult { totalBytes: number; } +interface LocalProjectExportPackageSummary { + packagePath: string; + packageRelativePath: string; + totalBytes: number; + modifiedAt: number; +} + +interface LocalProjectExportPackagesResult { + projectPath: string; + packages: LocalProjectExportPackageSummary[]; +} + interface LocalProjectDiffResult { checkpointId: string; added: Array<{ path: string; status: string }>; @@ -1881,6 +1893,7 @@ const chatCommandHelp = [ '/read 路径:读取本地项目内文本文件', '/run:运行自检,启动本地 HTTP 预览并交给外部浏览器', '/export:导出本地试玩包', + '/exports:列出本地试玩包', '/preview:启动本地 HTTP 预览并交给外部浏览器', '/open-preview:打开当前本地预览', '/preview-status:查看预览状态', @@ -2245,6 +2258,7 @@ function summarizeNextProjectActions( preview?.status === 'running' && preview.url ? '/open-preview' : '/run', ); addSuggestion('导出本地试玩包', '/export'); + addSuggestion('查看本地试玩包', '/exports'); } else { addSuggestion('查看最近 loop 进展', '/trace'); } @@ -2384,6 +2398,24 @@ function summarizeProjectExportPackage( ].join('\n'); } +function summarizeProjectExportPackages( + result: LocalProjectExportPackagesResult, +) { + if (result.packages.length === 0) { + return '本地试玩包:暂无。输入 /export 导出当前可试玩原型。'; + } + const visiblePackages = result.packages.slice(0, 8); + const lines = visiblePackages.map( + (item) => `- ${item.packageRelativePath} · ${item.totalBytes}B`, + ); + if (result.packages.length > visiblePackages.length) { + lines.push( + `- 还有 ${result.packages.length - visiblePackages.length} 个更早试玩包`, + ); + } + return `本地试玩包:\n${lines.join('\n')}`; +} + function checkpointIdFromManifestPath(path: string) { const match = path.match(/^\.agent\/checkpoints\/([^/]+)\/manifest\.json$/); return match?.[1] ?? null; @@ -3230,6 +3262,7 @@ function isProjectPolicyConfirmableCommandId(value: string) { 'project.diff', 'project.restore', 'project.export_package', + 'project.export_list', 'file.list', 'file.read', 'memory.read', @@ -3288,6 +3321,7 @@ export function needsInitializedChatProject( 'file.read', 'project.checkpoint', 'project.export_package', + 'project.export_list', 'project.restore', 'project.policy_write', 'preview.open', @@ -4483,7 +4517,9 @@ export function App() { commandId.startsWith('file.') || commandId === 'agent.trace_read' || commandId === 'project.index' || - commandId === 'project.diff' + commandId === 'project.diff' || + commandId === 'project.export_package' || + commandId === 'project.export_list' ) { setFileStatus(message); } @@ -5812,7 +5848,7 @@ export function App() { ...current, { role: 'assistant', - 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。', + text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、project.export_package、project.export_list、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; @@ -6158,6 +6194,11 @@ export function App() { return; } + if (prompt === '/exports') { + void executeProjectExportPackages(true); + return; + } + if (prompt === '/preview') { if (!requireChatProjectForUserAction()) { return; @@ -7221,6 +7262,74 @@ export function App() { } } + async function executeProjectExportPackages( + announceToChat: boolean, + skipPolicyConfirm = 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 && + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'project.export_list', + nextProjectPath, + `列出 ${nextProjectPath} 的本地试玩包`, + '准备列出本地试玩包。', + () => void executeProjectExportPackages(true, true), + )) + ) { + return; + } + const result = await invoke( + 'list_local_project_export_packages', + { projectPath: nextProjectPath }, + ); + setFileStatus(`已列出 ${result.packages.length} 个本地试玩包`); + setCommandLog((current) => [...current, 'project.export_list']); + if (announceToChat) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: summarizeProjectExportPackages(result), + draftCommand: + result.packages.length > 0 ? '/open-project' : '/export', + draftCommandLabel: + result.packages.length > 0 ? '显示目录' : '导出试玩包', + }, + ]); + } + } 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, diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 02268ccdd..77fd23893 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -2020,6 +2020,25 @@ describe('AI 游戏创作 App 界面边界', () => { totalBytes: 512, }; } + if (command === 'list_local_project_export_packages') { + return { + projectPath: String(args?.projectPath ?? ''), + packages: [ + { + packagePath: `${String(args?.projectPath ?? '')}/exports/playtest-package-002.zip`, + packageRelativePath: 'exports/playtest-package-002.zip', + totalBytes: 2048, + modifiedAt: 1700000002000, + }, + { + packagePath: `${String(args?.projectPath ?? '')}/exports/playtest-package-001.zip`, + packageRelativePath: 'exports/playtest-package-001.zip', + totalBytes: 1024, + modifiedAt: 1700000001000, + }, + ], + }; + } throw new Error(`unexpected invoke ${command}`); }, ); @@ -2637,6 +2656,28 @@ describe('AI 游戏创作 App 界面边界', () => { ).toBeGreaterThan(0); fireEvent.click(screen.getByRole('button', { name: '取消' })); + submitChat('/exports'); + expect(await screen.findByText(/本地试玩包:/)).not.toBeNull(); + expect( + screen.getByText(/exports\/playtest-package-002\.zip · 2048B/), + ).not.toBeNull(); + expect( + screen.getByText(/exports\/playtest-package-001\.zip · 1024B/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('list_local_project_export_packages', { + projectPath: '/tmp/authorized-game', + }); + const exportListRevealButtons = screen.getAllByRole('button', { + name: '显示目录', + }); + fireEvent.click( + exportListRevealButtons[exportListRevealButtons.length - 1], + ); + expect(screen.getByLabelText('创作想法')).toHaveProperty( + 'value', + '/open-project', + ); + fireEvent.click(screen.getByRole('button', { name: '启动预览' })); expect(await screen.findByText('preview.start')).not.toBeNull(); fireEvent.click(screen.getByRole('button', { name: '确认' })); @@ -7873,6 +7914,7 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); expect(screen.getByText(/\/checkpoint:保存本地项目快照/)).not.toBeNull(); expect(screen.getByText(/\/export:导出本地试玩包/)).not.toBeNull(); + expect(screen.getByText(/\/exports:列出本地试玩包/)).not.toBeNull(); expect( screen.getByText(/\/checkpoints:列出最近 checkpoint/), ).not.toBeNull(); @@ -10437,6 +10479,28 @@ describe('AI 游戏创作 App 界面边界', () => { }, }); }); + + submitChat('/policy-confirm project.export_list'); + + expect( + await screen.findByText( + /确认:project\.checkpoint、project\.restore、project\.export_list/, + ), + ).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', + 'project.export_list', + ], + }, + }); + }); }); it('allows canvas project commands to require project policy confirmation', async () => { @@ -12538,6 +12602,79 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('requires confirmation for export 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: ['project.export_list'], + }, + }; + } + if (command === 'list_local_project_export_packages') { + return { + projectPath: String(args?.projectPath ?? ''), + packages: [ + { + packagePath: `${String(args?.projectPath ?? '')}/exports/playtest-package-001.zip`, + packageRelativePath: 'exports/playtest-package-001.zip', + totalBytes: 1024, + modifiedAt: 1700000001000, + }, + ], + }; + } + 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('/exports'); + + expect(await screen.findByText('准备列出本地试玩包。')).not.toBeNull(); + expect(screen.getByText('project.export_list')).not.toBeNull(); + expect(invoke).not.toHaveBeenCalledWith( + 'list_local_project_export_packages', + expect.anything(), + ); + + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect(await screen.findByText(/本地试玩包:/)).not.toBeNull(); + expect( + screen.getByText(/exports\/playtest-package-001\.zip · 1024B/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('list_local_project_export_packages', { + projectPath: '/tmp/authorized-game', + }); + }); + it('builds the local project index from chat through the authorized project path', async () => { const manifest = createGameCreationAppManifest( 'local-project-draft', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index dc183b3a5..5b7977164 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,6 +20,7 @@ - 背景: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`。 +- 补充:新增 `/exports` 只读聊天入口和 `project.export_list` 自动命令,用于列出当前项目 `exports/playtest-package-*.zip` 历史试玩包;该入口只读、不删除旧包、不做系统分享,给用户继续 `/export` 或显示目录的草稿。 - 影响范围:`apps/ai-game-creator-shell` 的聊天命令、Tauri 本地项目能力、共享命令契约和 AI 游戏创作 App 实施计划。 - 验证方式:运行 AI 游戏创作壳主窗口 smoke、Tauri `export` 定向测试、共享契约测试、类型检查、编码检查和 `git diff --check`。 - 关联文档:`docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md`。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 00a4fbae0..bf9cfb27a 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 运行辅助文件读取命令,不新增普通用户面板;聊天输入 `/export` 确认后把当前可试玩原型导出为本地试玩 ZIP;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,聊天输入 `/exports` 可只读列出已导出的本地试玩包;Agent 状态列表和单 Agent 对话在 `/llm-status` 后显示该 agent 当前 LLM provider / 模型 / 流式 / API Key 读取状态,但不显示密钥本体;最近项目资产入口显示本地路径、kind、mediaType 和来源类型,并可一键填入 `/read` 草稿,开发环境通过独立窗口查看任务拆分、专业组细节、产物、文件面板、嵌入预览以及 `.agent/logs/command.log` / `preview.log` / `agent.log`。 - 生成代码和资产进入用户本地项目目录。 - 本地 HTTP 预览能启动,并在外部浏览器展示可玩原型。 - 美术/音乐资产能从画板链路回流到本地项目。 @@ -117,6 +117,7 @@ game-project/ - `/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`。 +- `/exports` 聊天入口由 `appSurface.test.ts` 主窗口 smoke、项目权限确认测试和 Tauri Rust 测试覆盖:只读执行 `project.export_list`,列出 `exports/playtest-package-*.zip`,跳过符号链接和非试玩包文件,不删除旧包、不做系统分享、不新增普通用户面板。 - 主窗口策略快捷入口只填入 `/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 编译。 @@ -184,7 +185,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`、`/export`、`/open-preview`、`/assets`、`/artifacts`、`/run-artifacts`、`/run-files`、`/logs`、`/agent-resume ` 等安全命令草稿方向,并提供一个首选草稿;该命令不调用 Tauri 读写、不启动或打开预览、不读取文件,用户必须再发送草稿并按原命令权限流继续。 +- 聊天输入 `/next` 只使用主窗口当前已加载的 manifest、最近 run trace 和最近命令摘要,在聊天里给出下一步建议,列出 `/tasks`、`/trace`、`/run`、`/export`、`/exports`、`/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,不新增状态数据库。 @@ -196,6 +197,7 @@ game-project/ - 聊天输入 `/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。 +- 聊天输入 `/exports` 会只读执行 `project.export_list`,列出当前项目 `exports/playtest-package-*.zip` 历史试玩包,并提供显示目录或继续 `/export` 的草稿;该命令不删除文件、不分享文件、不新增面板。 - 聊天输入 `/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 1fb589ffc..9f3d8abd0 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -41,6 +41,11 @@ describe('AI 游戏创作 App 共享契约', () => { (command) => command.id === 'project.export_package', )?.permission, ).toBe('confirm'); + expect( + GAME_CREATION_APP_COMMANDS.find( + (command) => command.id === 'project.export_list', + )?.permission, + ).toBe('auto'); 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 e24665a1e..21297948f 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -21,6 +21,7 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'project.diff', permission: 'auto' }, { id: 'project.restore', permission: 'confirm' }, { id: 'project.export_package', permission: 'confirm' }, + { id: 'project.export_list', permission: 'auto' }, { 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 770365d0d..ea31c7f5b 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; 43] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 44] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), command("project.status", GameCreationAppPermission::Auto), @@ -30,6 +30,7 @@ pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 43] = [ command("project.diff", GameCreationAppPermission::Auto), command("project.restore", GameCreationAppPermission::Confirm), command("project.export_package", GameCreationAppPermission::Confirm), + command("project.export_list", GameCreationAppPermission::Auto), command("project.policy_read", GameCreationAppPermission::Auto), command("project.policy_write", GameCreationAppPermission::Confirm), command("task.list", GameCreationAppPermission::Auto), @@ -648,6 +649,12 @@ mod tests { GameCreationAppPermission::Confirm ); + let export_list = GAME_CREATION_APP_COMMANDS + .iter() + .find(|command| command.id == "project.export_list") + .expect("command should exist"); + assert_eq!(export_list.permission, GameCreationAppPermission::Auto); + let status = GAME_CREATION_APP_COMMANDS .iter() .find(|command| command.id == "project.status")