From 76b5718d77bc096f7add8f98b03ffcbdad159c53 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Wed, 1 Jul 2026 16:26:43 +0800 Subject: [PATCH] =?UTF-8?q?=E8=90=BD=E5=9C=B0=E6=B8=B8=E6=88=8F=E5=88=9B?= =?UTF-8?q?=E4=BD=9C=E6=9C=AC=E5=9C=B0=E5=B7=A5=E7=A8=8B=E6=8A=A4=E6=A0=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增项目 checkpoint、diff、restore、本地索引、写锁和项目级权限策略。 接入聊天命令和共享命令契约,普通用户可确认恢复与策略写入。 补充上下文敏感信息过滤,避免密钥和本机配置进入 LLM prompt。 更新 AI 游戏创作 App 文档、共享记忆和相关测试。 --- .../smoke-agent-run-local-provider.mjs | 4 + .../src-tauri/src/main.rs | 1061 ++++++++++++++++- apps/ai-game-creator-shell/src/App.tsx | 570 ++++++++- .../tests/appSurface.test.ts | 226 +++- .../tests/rememberCommand.test.ts | 27 + .../shared-memory/decision-log.md | 2 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 38 +- .../src/contracts/gameCreationApp.test.ts | 1 + .../shared/src/contracts/gameCreationApp.ts | 13 +- .../shared-contracts/src/game_creation_app.rs | 13 +- 10 files changed, 1847 insertions(+), 108 deletions(-) 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 63ca78f63..f59bb8eac 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 @@ -499,6 +499,7 @@ try { step.agent === 'Planner' && step.inputPaths?.includes('memory/session.md') && step.inputPaths?.includes('memory/project.md') && + step.inputPaths?.includes('memory/blackboard.md') && step.inputPaths?.includes('.agent/manifest.json'), ), 'trace missing planner memory or manifest inputs', @@ -508,6 +509,8 @@ try { (step) => step.agent === '策划组 / Director' && step.pass === 1 && + step.inputPaths?.includes('memory/blackboard.md') && + step.inputPaths?.includes('memory/agents/design/director.md') && step.inputPaths?.includes('.agent/manifest.json') && step.inputPaths?.includes('.agent/passes/pass-1/agenda.md'), ), @@ -518,6 +521,7 @@ try { (step) => step.agent === 'Generator' && step.pass === 2 && + step.inputPaths?.includes('memory/blackboard.md') && 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'), 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 65d04dd76..b5d274220 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -14,7 +14,7 @@ use platform_agent::{ route_game_creation_repair_issues, }; use platform_llm::{ - LlmClient, LlmConfig, LlmMessage, LlmProvider, LlmApiKind, LlmRunRequest, + LlmApiKind, LlmClient, LlmConfig, LlmMessage, LlmProvider, LlmRunRequest, DEFAULT_RETRY_BACKOFF_MS, }; use reqwest::header; @@ -289,6 +289,70 @@ struct LocalProjectFileMutationResult { deleted: bool, } +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectPermissionPolicy { + denied_commands: Vec, + confirm_commands: Vec, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct ProjectPermissionPolicyView { + path: String, + policy: ProjectPermissionPolicy, +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalProjectIndexedFile { + path: String, + size: u64, + checksum: String, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalProjectIndexResult { + project_path: String, + index_path: String, + file_count: usize, + total_bytes: u64, + files: Vec, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalProjectCheckpointResult { + checkpoint_id: String, + checkpoint_path: String, + file_count: usize, + total_bytes: u64, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalProjectDiffEntry { + path: String, + status: String, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalProjectDiffResult { + checkpoint_id: String, + added: Vec, + changed: Vec, + deleted: Vec, +} + +#[derive(Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct LocalProjectRestoreResult { + checkpoint_id: String, + restored_count: usize, +} + #[derive(Default)] struct PreviewRegistry { current: Mutex>, @@ -455,9 +519,13 @@ 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_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 MAX_CANVAS_EXPORT_FILES: usize = 500; const MAX_CANVAS_EXPORT_BYTES: u64 = 512 * 1024 * 1024; -const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 14] = [ +const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 15] = [ ".agent/agent.db", ".agent/spec.md", ".agent/findings.md", @@ -466,6 +534,7 @@ const GAME_CREATOR_AGENT_ARTIFACT_PATHS: [&str; 14] = [ ".agent/logs/preview.log", "memory/session.md", "memory/project.md", + PROJECT_BLACKBOARD_MEMORY_PATH, "game/game_design.md", "game/balance.json", "assets/manifest.art.json", @@ -719,6 +788,7 @@ struct AgentRoleBrief { role_definition: AgentRoleDefinition, markdown: String, relative_path: String, + memory_relative_path: String, status: String, tool_id: String, summary: String, @@ -794,11 +864,10 @@ fn init_local_game_project( project_id: String, name: String, ) -> Result { - init_local_game_project_at( - Path::new(project_path.trim()), - project_id.trim(), - name.trim(), - ) + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.create")?; + let _lock = acquire_project_write_lock(root, "project.create")?; + init_local_game_project_at(root, project_id.trim(), name.trim()) } #[tauri::command] @@ -807,6 +876,8 @@ fn start_local_game_preview( registry: tauri::State<'_, PreviewRegistry>, ) -> Result { let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "preview.start")?; + let _lock = acquire_project_write_lock(root, "preview.start")?; let (preview, stop) = start_local_game_preview_for_project(root)?; if let Err(error) = record_preview_state( root, @@ -844,6 +915,12 @@ fn stop_local_game_preview( .map(str::trim) .filter(|path| !path.is_empty()); let root = project_path.map(Path::new); + let _lock = if let Some(root) = root { + enforce_project_permission_policy(root, "preview.stop")?; + Some(acquire_project_write_lock(root, "preview.stop")?) + } else { + None + }; let (status, _) = registry.stop_for_project(root); if let Some(root) = root { record_preview_state(root, GameCreationAppPreviewStatus::Stopped, None, None)?; @@ -892,8 +969,22 @@ fn control_agent_run( action: String, detail: Option, ) -> Result { + let root = Path::new(project_path.trim()); + let command_id = match action.trim() { + "status" => "agent.run_status", + "kill" => "agent.kill", + "retry" => "agent.retry", + "resume" => "agent.resume", + _ => "agent.run_status", + }; + enforce_project_permission_policy(root, command_id)?; + let _lock = if command_id == "agent.run_status" { + None + } else { + Some(acquire_project_write_lock(root, command_id)?) + }; update_agent_run_lifecycle( - Path::new(project_path.trim()), + root, action.trim(), detail .as_deref() @@ -908,8 +999,11 @@ async fn generate_local_game_draft( project_path: String, prompt: String, ) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "game.generate_draft")?; + let _lock = acquire_project_write_lock(root, "game.generate_draft")?; generate_local_game_draft_at( - Path::new(project_path.trim()), + root, prompt.trim(), Some(&AgentProgressEmitter::new(&app, project_path.trim())), ) @@ -950,12 +1044,10 @@ fn upload_local_asset( media_type: String, bytes: Vec, ) -> Result { - upload_local_asset_at( - Path::new(project_path.trim()), - file_name.trim(), - media_type.trim(), - &bytes, - ) + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.upload")?; + let _lock = acquire_project_write_lock(root, "asset.upload")?; + upload_local_asset_at(root, file_name.trim(), media_type.trim(), &bytes) } #[tauri::command] @@ -972,8 +1064,11 @@ fn register_local_asset( prompt: Option, model: Option, ) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "asset.register")?; + let _lock = acquire_project_write_lock(root, "asset.register")?; register_local_asset_at( - Path::new(project_path.trim()), + root, local_path.trim(), kind.trim(), media_type.trim(), @@ -1003,8 +1098,11 @@ fn import_canvas_asset( prompt: Option, model: Option, ) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.asset_import")?; + let _lock = acquire_project_write_lock(root, "canvas.asset_import")?; import_canvas_asset_at( - Path::new(project_path.trim()), + root, local_path.trim(), kind.trim(), media_type.trim(), @@ -1023,8 +1121,11 @@ fn import_canvas_export( export_path: String, canvas_project_id: String, ) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.export_import")?; + let _lock = acquire_project_write_lock(root, "canvas.export_import")?; import_canvas_export_at( - Path::new(project_path.trim()), + root, Path::new(export_path.trim()), canvas_project_id.trim(), ) @@ -1037,13 +1138,10 @@ async fn sync_canvas_project_assets( api_base_url: Option, api_key: Option, ) -> Result { - sync_canvas_project_assets_at( - Path::new(project_path.trim()), - canvas_project_id.trim(), - api_base_url, - api_key, - ) - .await + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "canvas.project_sync")?; + let _lock = acquire_project_write_lock(root, "canvas.project_sync")?; + sync_canvas_project_assets_at(root, canvas_project_id.trim(), api_base_url, api_key).await } #[tauri::command] @@ -1076,6 +1174,8 @@ fn run_limited_local_command( ) -> Result { let root = Path::new(project_path.trim()); let command_id = command_id.trim(); + enforce_project_permission_policy(root, "command.run_limited")?; + let _lock = acquire_project_write_lock(root, "command.run_limited")?; let result = run_limited_local_command_at(root, command_id)?; if command_id == "game.static_smoke" { append_static_smoke_manual_trace_step(root, &result)?; @@ -1115,11 +1215,10 @@ fn write_local_project_file( relative_path: String, content: String, ) -> Result { - write_local_project_file_at( - Path::new(project_path.trim()), - relative_path.trim(), - &content, - ) + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "file.write")?; + let _lock = acquire_project_write_lock(root, "file.write")?; + write_local_project_file_at(root, relative_path.trim(), &content) } #[tauri::command] @@ -1127,7 +1226,10 @@ fn delete_local_project_file( project_path: String, relative_path: String, ) -> Result { - delete_local_project_file_at(Path::new(project_path.trim()), relative_path.trim()) + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "file.delete")?; + let _lock = acquire_project_write_lock(root, "file.delete")?; + delete_local_project_file_at(root, relative_path.trim()) } #[tauri::command] @@ -1144,7 +1246,10 @@ fn write_local_game_memory( scope: String, content: String, ) -> Result { - write_local_game_memory_at(Path::new(project_path.trim()), scope.trim(), &content) + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "memory.write")?; + let _lock = acquire_project_write_lock(root, "memory.write")?; + write_local_game_memory_at(root, scope.trim(), &content) } #[tauri::command] @@ -1152,7 +1257,67 @@ fn delete_local_game_memory( project_path: String, scope: String, ) -> Result { - delete_local_game_memory_at(Path::new(project_path.trim()), scope.trim()) + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "memory.delete")?; + let _lock = acquire_project_write_lock(root, "memory.delete")?; + delete_local_game_memory_at(root, scope.trim()) +} + +#[tauri::command] +fn build_local_project_index(project_path: String) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.index")?; + let _lock = acquire_project_write_lock(root, "project.index")?; + build_local_project_index_at(root) +} + +#[tauri::command] +fn create_local_project_checkpoint( + project_path: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.checkpoint")?; + let _lock = acquire_project_write_lock(root, "project.checkpoint")?; + create_local_project_checkpoint_at(root) +} + +#[tauri::command] +fn diff_local_project_checkpoint( + project_path: String, + checkpoint_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.diff")?; + diff_local_project_checkpoint_at(root, checkpoint_id.trim()) +} + +#[tauri::command] +fn restore_local_project_checkpoint( + project_path: String, + checkpoint_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.restore")?; + let _lock = acquire_project_write_lock(root, "project.restore")?; + restore_local_project_checkpoint_at(root, checkpoint_id.trim()) +} + +#[tauri::command] +fn read_project_permission_policy( + project_path: String, +) -> Result { + read_project_permission_policy_at(Path::new(project_path.trim())) +} + +#[tauri::command] +fn write_project_permission_policy( + project_path: String, + policy: ProjectPermissionPolicy, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "project.policy_write")?; + let _lock = acquire_project_write_lock(root, "project.policy_write")?; + write_project_permission_policy_at(root, policy) } fn init_local_game_project_at( @@ -1173,7 +1338,14 @@ fn init_local_game_project_at( return Err("项目名称不能为空".to_string()); } - for relative in ["game", "assets", "memory", "exports", ".agent/logs"] { + for relative in [ + "game", + "assets", + "memory", + "memory/agents", + "exports", + ".agent/logs", + ] { fs::create_dir_all(root.join(relative)).map_err(|error| { format!( "创建本地项目目录失败:{}: {error}", @@ -1238,6 +1410,48 @@ fn append_agent_db_record(root: &Path, mut record: serde_json::Value) -> Result< .map_err(|error| format!("写入 Agent 本地索引失败:{}: {error}", path.display())) } +#[derive(Debug)] +struct ProjectWriteLock { + path: PathBuf, +} + +impl Drop for ProjectWriteLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn acquire_project_write_lock(root: &Path, command_id: &str) -> Result { + validate_project_root(root)?; + let path = root.join(PROJECT_WRITE_LOCK_PATH); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建项目锁目录失败:{}: {error}", parent.display()))?; + } + let payload = serde_json::json!({ + "commandId": command_id, + "pid": std::process::id(), + "createdAt": unix_timestamp(), + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成项目写锁失败:{error}"))?; + match fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + { + Ok(mut file) => { + file.write_all(content.as_bytes()) + .map_err(|error| format!("写入项目写锁失败:{}: {error}", path.display()))?; + Ok(ProjectWriteLock { path }) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + Err(format!("项目正在被其他写操作占用:{}", path.display())) + } + Err(error) => Err(format!("创建项目写锁失败:{}: {error}", path.display())), + } +} + async fn generate_local_game_draft_at( root: &Path, prompt: &str, @@ -1251,6 +1465,7 @@ async fn generate_local_game_draft_at( init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; 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 asset_context = render_local_asset_prompt_context(root)?; let long_memory = append_prompt_context(&asset_context, &long_memory); emit_agent_progress( @@ -1265,6 +1480,7 @@ async fn generate_local_game_draft_at( prompt, &short_memory, &long_memory, + &project_blackboard, progress, ) .await?; @@ -1303,6 +1519,7 @@ fn write_local_game_draft_at( } validate_llm_game_draft(prompt, draft)?; init_local_game_project_at(root, "local-project-draft", "未命名游戏原型")?; + let checkpoint = create_local_project_checkpoint_at(root)?; let timestamp = unix_timestamp(); let title = draft.title.trim(); let handoff_summary = draft.handoff_summary.trim(); @@ -1404,6 +1621,17 @@ fn write_local_game_draft_at( }), )?; let manifest = record_draft_task_progress(root, prompt, timestamp, &agent_log_path)?; + let diff = diff_local_project_checkpoint_at(root, &checkpoint.checkpoint_id)?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.diff", + "checkpointId": checkpoint.checkpoint_id, + "added": diff.added.len(), + "changed": diff.changed.len(), + "deleted": diff.deleted.len(), + }), + )?; Ok(GenerateLocalGameDraftResult { project_path: root.to_string_lossy().into_owned(), @@ -1587,6 +1815,7 @@ async fn request_llm_game_draft_with_client( "", "", "", + "", None, ) .await @@ -1598,15 +1827,22 @@ async fn run_game_creator_agent_loop_at( prompt: &str, short_memory: &str, long_memory: &str, + project_blackboard: &str, progress: Option<&AgentProgressEmitter<'_>>, ) -> Result { let spec_path = root.join(".agent/spec.md"); let findings_path = root.join(".agent/findings.md"); let run_id = format!("game-generate-draft-{}", unix_millis()); let mut steps = Vec::new(); - let planner_spec = request_planner_spec_with_client(client, prompt, short_memory, long_memory) - .await - .map(|spec| render_planner_spec(prompt, &spec))?; + let planner_spec = request_planner_spec_with_client( + client, + prompt, + short_memory, + long_memory, + project_blackboard, + ) + .await + .map(|spec| render_planner_spec(prompt, &spec))?; fs::write(&spec_path, &planner_spec) .map_err(|error| format!("写入 Planner 规格失败:{}: {error}", spec_path.display()))?; emit_agent_progress(progress, "llm.planner.done", "Planner 规格已生成"); @@ -1618,6 +1854,7 @@ async fn run_game_creator_agent_loop_at( &[ "memory/session.md", "memory/project.md", + PROJECT_BLACKBOARD_MEMORY_PATH, ".agent/manifest.json", ], &[".agent/spec.md"], @@ -1658,6 +1895,7 @@ async fn run_game_creator_agent_loop_at( ); let spec_markdown = read_optional_text(&spec_path)?; let findings_markdown = read_optional_text(&findings_path)?; + let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?; let agenda = write_agent_pass_agenda(root, pass, &findings_markdown)?; steps.push(agent_trace_step_owned( pass, @@ -1666,6 +1904,7 @@ async fn run_game_creator_agent_loop_at( vec![ ".agent/spec.md".to_string(), ".agent/findings.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), ".agent/manifest.json".to_string(), ], vec![ @@ -1688,6 +1927,7 @@ async fn run_game_creator_agent_loop_at( prompt, short_memory, long_memory, + &project_blackboard, &spec_markdown, &findings_markdown, &agenda, @@ -1760,6 +2000,7 @@ async fn run_game_creator_agent_loop_at( prompt, short_memory, long_memory, + &project_blackboard, &spec_markdown, &findings_markdown, &group_briefs_markdown, @@ -1778,6 +2019,7 @@ async fn run_game_creator_agent_loop_at( let mut generator_input_paths = vec![ "memory/session.md".to_string(), "memory/project.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), ".agent/manifest.json".to_string(), ".agent/spec.md".to_string(), ".agent/findings.md".to_string(), @@ -1840,6 +2082,7 @@ async fn run_game_creator_agent_loop_at( "evaluator.passed", &format!("Evaluator 第 {pass} 轮质量评审通过"), ); + append_agent_success_memories(root, pass, &draft, &group_briefs)?; write_agent_run_trace(root, &run_id, prompt, "passed", pass, &steps, None)?; return Ok(GameCreatorAgentLoopResult { run_id, @@ -1917,6 +2160,7 @@ async fn request_planner_spec_with_client( prompt: &str, short_memory: &str, long_memory: &str, + project_blackboard: &str, ) -> Result { let request = LlmRunRequest::new(vec![ LlmMessage::system(game_creator_planner_system_prompt()), @@ -1924,6 +2168,7 @@ async fn request_planner_spec_with_client( prompt, short_memory, long_memory, + project_blackboard, )), ]) .with_api_kind(read_game_creator_llm_api_kind_from_config()?) @@ -1944,6 +2189,7 @@ async fn request_generator_game_draft_with_client( prompt: &str, short_memory: &str, long_memory: &str, + project_blackboard: &str, spec_markdown: &str, findings_markdown: &str, group_briefs_markdown: &str, @@ -1955,6 +2201,7 @@ async fn request_generator_game_draft_with_client( prompt, short_memory, long_memory, + project_blackboard, spec_markdown, findings_markdown, group_briefs_markdown, @@ -2027,6 +2274,7 @@ async fn request_agent_group_briefs_with_client( prompt: &str, short_memory: &str, long_memory: &str, + project_blackboard: &str, spec_markdown: &str, findings_markdown: &str, agenda: &AgentPassAgenda, @@ -2039,6 +2287,8 @@ async fn request_agent_group_briefs_with_client( let mut role_briefs = Vec::new(); let mut completed_role_context = String::new(); for role_definition in definition.roles { + let agent_memory_relative_path = + agent_role_memory_relative_path(definition, *role_definition); let mut should_run = agenda .active_task_ids .iter() @@ -2065,6 +2315,7 @@ async fn request_agent_group_briefs_with_client( role_definition: *role_definition, markdown, relative_path, + memory_relative_path: agent_memory_relative_path, status: "carried-over".to_string(), tool_id: format!( "agent.task_graph.carryover.{}.{}", @@ -2084,12 +2335,15 @@ async fn request_agent_group_briefs_with_client( if !should_run { continue; } + let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?; let markdown = render_local_agent_role_brief( definition, *role_definition, prompt, short_memory, long_memory, + project_blackboard, + &agent_memory, spec_markdown, findings_markdown, &agenda_markdown, @@ -2104,6 +2358,7 @@ async fn request_agent_group_briefs_with_client( role_definition: *role_definition, markdown, relative_path, + memory_relative_path: agent_memory_relative_path, status: "completed".to_string(), tool_id: role_definition.tool_id.to_string(), summary: format!( @@ -2138,6 +2393,8 @@ fn render_local_agent_role_brief( prompt: &str, short_memory: &str, long_memory: &str, + project_blackboard: &str, + agent_memory: &str, spec_markdown: &str, findings_markdown: &str, agenda_markdown: &str, @@ -2146,7 +2403,7 @@ fn render_local_agent_role_brief( pass: u8, ) -> String { format!( - "本角色判断:pass {pass},{} / {} 负责 {},围绕用户需求“{}”给 Generator 提供约束。\n交付物:{}\n下游约束:{}\n验收风险:{}\n\n## Planner 摘要\n\n{}\n\n## Evaluator 反馈\n\n{}\n\n## 本轮 agenda\n\n{}\n\n## 记忆摘要\n\n短期:{}\n长期:{}\n\n## 已完成上下文\n\n{}\n{}", + "本角色判断:pass {pass},{} / {} 负责 {},围绕用户需求“{}”给 Generator 提供约束。\n交付物:{}\n下游约束:{}\n验收风险:{}\n\n## Planner 摘要\n\n{}\n\n## Evaluator 反馈\n\n{}\n\n## 本轮 agenda\n\n{}\n\n## 记忆摘要\n\n短期:{}\n长期:{}\n项目黑板:{}\n本角色私有记忆:{}\n\n## 已完成上下文\n\n{}\n{}", group_definition.label, role_definition.role, role_definition.task_id, @@ -2159,6 +2416,8 @@ fn render_local_agent_role_brief( truncate_prompt_context(agenda_markdown), truncate_inline(short_memory, 120), truncate_inline(long_memory, 120), + truncate_inline(project_blackboard, 160), + truncate_inline(agent_memory, 160), truncate_prompt_context(completed_group_context), truncate_prompt_context(completed_role_context) ) @@ -2590,6 +2849,65 @@ fn write_agent_role_brief( Ok(relative_path) } +fn agent_role_memory_relative_path( + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, +) -> String { + format!( + "memory/agents/{}/{}", + group_definition.id, role_definition.brief_path_name + ) +} + +fn append_agent_success_memories( + root: &Path, + pass: u8, + draft: &LlmGameDraft, + briefs: &[AgentGroupBrief], +) -> Result<(), String> { + let timestamp = unix_timestamp(); + let title = draft.title.trim(); + let mut blackboard_entry = format!( + "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- 稳定原型:game/index.html\n- 设计:game/game_design.md\n- 数值:game/balance.json\n- 美术:assets/manifest.art.json\n- 音乐音效:assets/manifest.audio.json\n- 发布包装:exports/README.md\n\n## 角色共享摘要\n\n" + ); + + for brief in briefs { + for role_brief in &brief.role_briefs { + blackboard_entry.push_str(&format!( + "- {} / {}:{};status={};brief={}\n", + role_brief.group_definition.label, + role_brief.role_definition.role, + role_brief.summary, + role_brief.status, + role_brief.relative_path + )); + let private_entry = format!( + "\n## pass {pass} - {title}\n\n- 时间:{timestamp}\n- task:{}\n- status:{}\n- brief:{}\n- 摘要:{}\n", + role_brief.role_definition.task_id, + role_brief.status, + role_brief.relative_path, + role_brief.summary + ); + append_markdown_entry( + &root.join(&role_brief.memory_relative_path), + &format!( + "# Agent 私有记忆 - {} / {}\n\n", + role_brief.group_definition.label, role_brief.role_definition.role + ), + &private_entry, + "写入 Agent 私有记忆失败", + )?; + } + } + + append_markdown_entry( + &root.join(PROJECT_BLACKBOARD_MEMORY_PATH), + "# 项目黑板\n\n", + &blackboard_entry, + "写入项目黑板失败", + ) +} + fn append_group_brief_steps( root: &Path, pass: u8, @@ -2603,12 +2921,18 @@ fn append_group_brief_steps( let input_paths = vec![ "memory/session.md".to_string(), "memory/project.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + role_brief.memory_relative_path.clone(), ".agent/manifest.json".to_string(), ".agent/spec.md".to_string(), ".agent/findings.md".to_string(), agenda_relative_path.to_string(), ]; - let output_paths = vec![role_brief.relative_path.clone()]; + let mut output_paths = vec![role_brief.relative_path.clone()]; + if role_brief.status == "completed" || role_brief.status == "carried-over" { + output_paths.push(role_brief.memory_relative_path.clone()); + output_paths.push(PROJECT_BLACKBOARD_MEMORY_PATH.to_string()); + } let mut step = with_task_context( agent_trace_step_owned( pass, @@ -3448,6 +3772,8 @@ fn append_local_artifact_write_step( vec![ "memory/session.md".to_string(), "memory/project.md".to_string(), + PROJECT_BLACKBOARD_MEMORY_PATH.to_string(), + "memory/agents/".to_string(), "game/game_design.md".to_string(), "game/balance.json".to_string(), "assets/manifest.art.json".to_string(), @@ -4030,7 +4356,9 @@ fn write_agent_run_context_bundle( "nextStep": trace.next_step, "memory": { "short": "memory/session.md", - "long": "memory/project.md" + "long": "memory/project.md", + "blackboard": PROJECT_BLACKBOARD_MEMORY_PATH, + "agents": "memory/agents/" }, "manifest": manifest, "trace": ".agent/run.latest.json", @@ -4274,6 +4602,38 @@ fn collect_agent_run_artifacts(root: &Path) -> Result, _>>() + .map_err(|error| { + format!("读取 Agent 私有记忆目录失败:{}: {error}", dir.display()) + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + let entry_path = entry.path(); + let metadata = fs::symlink_metadata(&entry_path).map_err(|error| { + format!( + "读取 Agent 私有记忆元数据失败:{}: {error}", + entry_path.display() + ) + })?; + if metadata.file_type().is_symlink() { + return Err("Agent 私有记忆不能是符号链接".to_string()); + } + if metadata.is_dir() { + dirs.push(entry_path); + } else if metadata.is_file() { + relative_paths.push(relative_project_path(root, &entry_path)?); + } + } + } + } relative_paths.sort(); relative_paths.dedup(); @@ -4604,12 +4964,18 @@ fn game_creator_planner_system_prompt() -> &'static str { r#"你是 Genarrative 的 AI 游戏创作 Planner。输出一份给 Generator 使用的 Markdown 规格,不要生成代码。规格必须包含:核心循环、输入方式、胜负条件、首版关卡、6 个专业组分工、组内角色任务矩阵、Evaluator 验收标准。不要写客套说明。"# } -fn game_creator_planner_user_prompt(prompt: &str, short_memory: &str, long_memory: &str) -> String { +fn game_creator_planner_user_prompt( + prompt: &str, + short_memory: &str, + long_memory: &str, + project_blackboard: &str, +) -> String { format!( - "用户需求:\n{}\n\n短期记忆:\n{}\n\n长期记忆:\n{}\n\n请输出 Planner 规格 Markdown。", + "用户需求:\n{}\n\n短期记忆:\n{}\n\n长期记忆:\n{}\n\n项目黑板:\n{}\n\n请输出 Planner 规格 Markdown。", prompt.trim(), truncate_prompt_context(short_memory), - truncate_prompt_context(long_memory) + truncate_prompt_context(long_memory), + truncate_prompt_context(project_blackboard) ) } @@ -4617,16 +4983,18 @@ fn game_creator_generator_user_prompt( prompt: &str, short_memory: &str, long_memory: &str, + project_blackboard: &str, spec_markdown: &str, findings_markdown: &str, group_briefs_markdown: &str, agenda_markdown: &str, ) -> String { format!( - "用户需求:\n{}\n\n短期记忆:\n{}\n\n长期记忆:\n{}\n\nPlanner 规格文件 .agent/spec.md:\n{}\n\n本轮 Orchestrator agenda:\n{}\n\n六个专业组 agent brief(每组由组内角色汇总而成):\n{}\n\nEvaluator 反馈文件 .agent/findings.md:\n{}\n\n请直接返回满足 schema 的 JSON。如果 findings 有问题,必须优先修复 agenda 中 activeTasks 对应的任务。", + "用户需求:\n{}\n\n短期记忆:\n{}\n\n长期记忆:\n{}\n\n项目黑板:\n{}\n\nPlanner 规格文件 .agent/spec.md:\n{}\n\n本轮 Orchestrator agenda:\n{}\n\n六个专业组 agent brief(每组由组内角色汇总而成):\n{}\n\nEvaluator 反馈文件 .agent/findings.md:\n{}\n\n请直接返回满足 schema 的 JSON。如果 findings 有问题,必须优先修复 agenda 中 activeTasks 对应的任务。", prompt.trim(), truncate_prompt_context(short_memory), truncate_prompt_context(long_memory), + truncate_prompt_context(project_blackboard), truncate_prompt_context(spec_markdown), truncate_prompt_context(agenda_markdown), truncate_prompt_context(group_briefs_markdown), @@ -4701,7 +5069,8 @@ fn asset_source_kind_label(kind: &GameCreationAppAssetSourceKind) -> &'static st fn truncate_prompt_context(value: &str) -> String { const MAX_CHARS: usize = 2400; - let trimmed = value.trim(); + let sanitized = sanitize_prompt_context(value); + let trimmed = sanitized.trim(); let mut output = trimmed.chars().take(MAX_CHARS).collect::(); if trimmed.chars().count() > MAX_CHARS { output.push_str("\n..."); @@ -4709,6 +5078,49 @@ fn truncate_prompt_context(value: &str) -> String { output } +fn sanitize_prompt_context(value: &str) -> String { + value + .lines() + .filter_map(|line| { + let lower = line.to_ascii_lowercase(); + if lower.contains(".env") + || lower.contains("game-creator.config") + || lower.contains("authorization:") + || lower.contains("cookie:") + || lower.contains("api_key") + || lower.contains("apikey") + || lower.contains("api key") + || lower.contains("token=") + || lower.contains("bearer ") + { + Some("[redacted sensitive context]".to_string()) + } else { + Some(redact_secret_tokens(line)) + } + }) + .collect::>() + .join("\n") +} + +fn redact_secret_tokens(line: &str) -> String { + if !line + .split_whitespace() + .any(|part| part.starts_with("sk-") || part.starts_with("tnr_sk_")) + { + return line.to_string(); + } + line.split_whitespace() + .map(|part| { + if part.starts_with("sk-") || part.starts_with("tnr_sk_") { + "[redacted-secret]" + } else { + part + } + }) + .collect::>() + .join(" ") +} + fn read_optional_text(path: &Path) -> Result { match fs::read_to_string(path) { Ok(content) => Ok(content), @@ -4759,9 +5171,7 @@ fn game_creator_app_config_view( config: GameCreatorAppConfig, ) -> Result { Ok(GameCreatorAppConfigView { - path: writable_game_creator_config_path()? - .display() - .to_string(), + path: writable_game_creator_config_path()?.display().to_string(), config, }) } @@ -4910,9 +5320,8 @@ fn normalize_game_creator_app_config( 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)?; - config.llm.api_kind = game_creator_llm_api_kind_name(parse_game_creator_llm_api_kind( - &config.llm.api_kind, - )?); + 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()); } @@ -5901,6 +6310,108 @@ fn run_limited_local_command_at( }) } +impl Default for ProjectPermissionPolicy { + fn default() -> Self { + Self { + denied_commands: Vec::new(), + confirm_commands: GAME_CREATION_APP_COMMANDS + .iter() + .filter(|command| command.permission == GameCreationAppPermission::Confirm) + .map(|command| command.id.to_string()) + .collect(), + } + } +} + +fn read_project_permission_policy_at(root: &Path) -> Result { + validate_project_root(root)?; + let path = root.join(PROJECT_PERMISSION_POLICY_PATH); + if !path.exists() { + return Ok(ProjectPermissionPolicyView { + path: path.to_string_lossy().into_owned(), + policy: ProjectPermissionPolicy::default(), + }); + } + let content = fs::read_to_string(&path) + .map_err(|error| format!("读取项目权限策略失败:{}: {error}", path.display()))?; + let policy = serde_json::from_str::(&content) + .map_err(|error| format!("解析项目权限策略失败:{}: {error}", path.display()))?; + Ok(ProjectPermissionPolicyView { + path: path.to_string_lossy().into_owned(), + policy: normalize_project_permission_policy(policy)?, + }) +} + +fn write_project_permission_policy_at( + root: &Path, + policy: ProjectPermissionPolicy, +) -> Result { + validate_project_root(root)?; + let policy = normalize_project_permission_policy(policy)?; + let path = root.join(PROJECT_PERMISSION_POLICY_PATH); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建项目权限策略目录失败:{}: {error}", parent.display()))?; + } + let content = serde_json::to_string_pretty(&policy) + .map_err(|error| format!("序列化项目权限策略失败:{error}"))?; + fs::write(&path, format!("{content}\n")) + .map_err(|error| format!("写入项目权限策略失败:{}: {error}", path.display()))?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.policy_write", + "deniedCommands": policy.denied_commands, + "confirmCommands": policy.confirm_commands, + }), + )?; + read_project_permission_policy_at(root) +} + +fn normalize_project_permission_policy( + mut policy: ProjectPermissionPolicy, +) -> Result { + policy.denied_commands = normalize_policy_command_ids(policy.denied_commands)?; + policy.confirm_commands = normalize_policy_command_ids(policy.confirm_commands)?; + policy + .confirm_commands + .retain(|command| !policy.denied_commands.contains(command)); + Ok(policy) +} + +fn normalize_policy_command_ids(values: Vec) -> Result, String> { + let mut output = Vec::new(); + for value in values { + let command_id = value.trim(); + if command_id.is_empty() { + continue; + } + if !GAME_CREATION_APP_COMMANDS + .iter() + .any(|command| command.id == command_id) + { + return Err(format!("不支持的内置命令:{command_id}")); + } + if !output.iter().any(|existing| existing == command_id) { + output.push(command_id.to_string()); + } + } + Ok(output) +} + +fn enforce_project_permission_policy(root: &Path, command_id: &str) -> Result<(), String> { + let view = read_project_permission_policy_at(root)?; + if view + .policy + .denied_commands + .iter() + .any(|command| command == command_id) + { + return Err(format!("项目权限策略拒绝执行:{command_id}")); + } + Ok(()) +} + fn list_local_project_files_at(root: &Path) -> Result { validate_project_root(root)?; if !root.exists() { @@ -6025,6 +6536,286 @@ fn delete_local_project_file_at( }) } +fn build_local_project_index_at(root: &Path) -> Result { + validate_project_root(root)?; + let files = collect_project_index_files(root)?; + let total_bytes = files.iter().map(|file| file.size).sum(); + let result = LocalProjectIndexResult { + project_path: root.to_string_lossy().into_owned(), + index_path: root.join(PROJECT_INDEX_PATH).to_string_lossy().into_owned(), + file_count: files.len(), + total_bytes, + files, + }; + if let Some(parent) = root.join(PROJECT_INDEX_PATH).parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建项目索引目录失败:{}: {error}", parent.display()))?; + } + fs::write( + root.join(PROJECT_INDEX_PATH), + format!( + "{}\n", + serde_json::to_string_pretty(&result) + .map_err(|error| format!("序列化项目索引失败:{error}"))? + ), + ) + .map_err(|error| { + format!( + "写入项目索引失败:{}: {error}", + root.join(PROJECT_INDEX_PATH).display() + ) + })?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.index", + "fileCount": result.file_count, + "totalBytes": result.total_bytes, + "indexPath": PROJECT_INDEX_PATH, + }), + )?; + Ok(result) +} + +fn create_local_project_checkpoint_at(root: &Path) -> Result { + validate_project_root(root)?; + let checkpoint_id = format!("checkpoint-{}", unix_millis()); + let checkpoint_root = root.join(".agent/checkpoints").join(&checkpoint_id); + let files = collect_project_index_files(root)?; + for file in &files { + let source = root.join(&file.path); + let target = checkpoint_root.join("files").join(&file.path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!("创建 checkpoint 目录失败:{}: {error}", parent.display()) + })?; + } + fs::copy(&source, &target).map_err(|error| { + format!( + "写入 checkpoint 文件失败:{} -> {}: {error}", + source.display(), + target.display() + ) + })?; + } + let total_bytes = files.iter().map(|file| file.size).sum::(); + let manifest = serde_json::json!({ + "checkpointId": checkpoint_id, + "createdAt": unix_timestamp(), + "files": files, + }); + fs::write( + checkpoint_root.join("manifest.json"), + format!( + "{}\n", + serde_json::to_string_pretty(&manifest) + .map_err(|error| format!("序列化 checkpoint 失败:{error}"))? + ), + ) + .map_err(|error| { + format!( + "写入 checkpoint manifest 失败:{}: {error}", + checkpoint_root.join("manifest.json").display() + ) + })?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.checkpoint", + "checkpointId": checkpoint_id, + "fileCount": files.len(), + "totalBytes": total_bytes, + }), + )?; + Ok(LocalProjectCheckpointResult { + checkpoint_id, + checkpoint_path: checkpoint_root.to_string_lossy().into_owned(), + file_count: files.len(), + total_bytes, + }) +} + +fn diff_local_project_checkpoint_at( + root: &Path, + checkpoint_id: &str, +) -> Result { + validate_project_root(root)?; + let checkpoint_id = normalize_checkpoint_id(checkpoint_id)?; + let checkpoint_files = read_checkpoint_files(root, &checkpoint_id)?; + let current_files = collect_project_index_files(root)?; + let mut added = Vec::new(); + let mut changed = Vec::new(); + let mut deleted = Vec::new(); + for file in ¤t_files { + match checkpoint_files + .iter() + .find(|candidate| candidate.path == file.path) + { + None => added.push(LocalProjectDiffEntry { + path: file.path.clone(), + status: "added".to_string(), + }), + Some(previous) if previous.checksum != file.checksum || previous.size != file.size => { + changed.push(LocalProjectDiffEntry { + path: file.path.clone(), + status: "changed".to_string(), + }); + } + Some(_) => {} + } + } + for file in &checkpoint_files { + if !current_files + .iter() + .any(|candidate| candidate.path == file.path) + { + deleted.push(LocalProjectDiffEntry { + path: file.path.clone(), + status: "deleted".to_string(), + }); + } + } + Ok(LocalProjectDiffResult { + checkpoint_id, + added, + changed, + deleted, + }) +} + +fn restore_local_project_checkpoint_at( + root: &Path, + checkpoint_id: &str, +) -> Result { + validate_project_root(root)?; + let checkpoint_id = normalize_checkpoint_id(checkpoint_id)?; + let files = read_checkpoint_files(root, &checkpoint_id)?; + let checkpoint_files_root = root + .join(".agent/checkpoints") + .join(&checkpoint_id) + .join("files"); + for file in &files { + if should_skip_project_restore_path(&file.path) { + continue; + } + let source = checkpoint_files_root.join(&file.path); + let target = root.join(&file.path); + if let Some(parent) = target.parent() { + fs::create_dir_all(parent) + .map_err(|error| format!("创建恢复目录失败:{}: {error}", parent.display()))?; + } + fs::copy(&source, &target).map_err(|error| { + format!( + "恢复 checkpoint 文件失败:{} -> {}: {error}", + source.display(), + target.display() + ) + })?; + } + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "project.restore", + "checkpointId": checkpoint_id, + "restoredCount": files.len(), + }), + )?; + Ok(LocalProjectRestoreResult { + checkpoint_id, + restored_count: files + .iter() + .filter(|file| !should_skip_project_restore_path(&file.path)) + .count(), + }) +} + +fn collect_project_index_files(root: &Path) -> Result, String> { + validate_project_root(root)?; + if !root.exists() { + return Ok(Vec::new()); + } + let mut files = Vec::new(); + let mut dirs = vec![root.to_path_buf()]; + while let Some(dir) = dirs.pop() { + for entry in fs::read_dir(&dir) + .map_err(|error| format!("读取项目目录失败:{}: {error}", dir.display()))? + { + let entry = + entry.map_err(|error| format!("读取项目文件失败:{}: {error}", dir.display()))?; + let file_type = entry.file_type().map_err(|error| { + format!("读取文件类型失败:{}: {error}", entry.path().display()) + })?; + if file_type.is_symlink() { + continue; + } + let path = entry.path(); + let relative_path = relative_project_path(root, &path)?; + if should_skip_project_index_path(&relative_path) { + continue; + } + if file_type.is_dir() { + dirs.push(path); + } else if file_type.is_file() { + let bytes = fs::read(&path) + .map_err(|error| format!("读取项目文件失败:{}: {error}", path.display()))?; + files.push(LocalProjectIndexedFile { + path: relative_path, + size: bytes.len() as u64, + checksum: format!("fnv1a64:{:016x}", fnv1a64(&bytes)), + }); + } + } + } + files.sort_by(|left, right| left.path.cmp(&right.path)); + Ok(files) +} + +fn should_skip_project_index_path(relative_path: &str) -> bool { + relative_path == PROJECT_WRITE_LOCK_PATH + || relative_path == PROJECT_INDEX_PATH + || relative_path.starts_with(".agent/checkpoints/") +} + +fn should_skip_project_restore_path(relative_path: &str) -> bool { + relative_path == ".agent/agent.db" + || relative_path == PROJECT_PERMISSION_POLICY_PATH + || relative_path == PROJECT_WRITE_LOCK_PATH + || relative_path == PROJECT_INDEX_PATH +} + +fn normalize_checkpoint_id(checkpoint_id: &str) -> Result { + let checkpoint_id = checkpoint_id.trim(); + if checkpoint_id.is_empty() + || checkpoint_id.contains('/') + || checkpoint_id.contains('\\') + || checkpoint_id.contains("..") + { + return Err("checkpoint id 非法".to_string()); + } + Ok(checkpoint_id.to_string()) +} + +fn read_checkpoint_files( + root: &Path, + checkpoint_id: &str, +) -> Result, String> { + let manifest_path = root + .join(".agent/checkpoints") + .join(checkpoint_id) + .join("manifest.json"); + let content = fs::read_to_string(&manifest_path) + .map_err(|error| format!("读取 checkpoint 失败:{}: {error}", manifest_path.display()))?; + let payload = serde_json::from_str::(&content) + .map_err(|error| format!("解析 checkpoint 失败:{}: {error}", manifest_path.display()))?; + serde_json::from_value::>( + payload + .get("files") + .cloned() + .ok_or_else(|| "checkpoint 缺少 files".to_string())?, + ) + .map_err(|error| format!("解析 checkpoint 文件清单失败:{error}")) +} + fn resolve_local_project_path(root: &Path, relative_path: &str) -> Result { validate_project_root(root)?; let normalized = normalize_relative_path(relative_path)?; @@ -6720,6 +7511,12 @@ fn main() { read_local_game_memory, write_local_game_memory, delete_local_game_memory, + build_local_project_index, + create_local_project_checkpoint, + diff_local_project_checkpoint, + restore_local_project_checkpoint, + read_project_permission_policy, + write_project_permission_policy, start_local_game_preview, open_local_game_preview, stop_local_game_preview, @@ -7497,6 +8294,7 @@ mod tests { "做一个月光厨房弹幕游戏", "", "# 项目长期记忆\n", + "", None, ) .await @@ -7540,6 +8338,24 @@ mod tests { assert!(project_memory.contains("月光弹幕厨房")); assert!(project_memory.contains("通过轮次:2/3")); assert!(project_memory.contains("可试玩入口:game/index.html")); + let blackboard = + fs::read_to_string(root.join(PROJECT_BLACKBOARD_MEMORY_PATH)).expect("blackboard"); + assert!(blackboard.contains("# 项目黑板")); + assert!(blackboard.contains("## pass 2 - 月光弹幕厨房")); + assert!(blackboard.contains("程序组 / Code")); + for group in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + for role in group.roles { + let memory_path = agent_role_memory_relative_path(group, *role); + let memory = fs::read_to_string(root.join(&memory_path)).unwrap_or_else(|error| { + panic!("read agent memory {memory_path}: {error}"); + }); + assert!(memory.contains(&format!( + "# Agent 私有记忆 - {} / {}", + group.label, role.role + ))); + assert!(memory.contains("月光弹幕厨房")); + } + } let first_design_director = fs::read_to_string(root.join(".agent/passes/pass-1/groups/design/director.md")) .expect("design director brief"); @@ -7721,6 +8537,11 @@ mod tests { .unwrap() .iter() .any(|path| path == ".agent/manifest.json")); + assert!(steps[0]["inputPaths"] + .as_array() + .unwrap() + .iter() + .any(|path| path == PROJECT_BLACKBOARD_MEMORY_PATH)); assert!(steps.iter().any(|step| step["agent"] == "Orchestrator" && step["pass"].as_u64() == Some(1) && step["toolCalls"][0]["toolId"] == "agent.task_graph.plan_pass" @@ -7741,7 +8562,22 @@ mod tests { .unwrap() .iter() .any(|path| path == ".agent/passes/pass-1/agenda.md") - && step["outputPaths"][0] == ".agent/passes/pass-1/groups/design/director.md")); + && step["inputPaths"] + .as_array() + .unwrap() + .iter() + .any(|path| path == "memory/agents/design/director.md") + && step["outputPaths"][0] == ".agent/passes/pass-1/groups/design/director.md" + && step["outputPaths"] + .as_array() + .unwrap() + .iter() + .any(|path| path == PROJECT_BLACKBOARD_MEMORY_PATH) + && step["outputPaths"] + .as_array() + .unwrap() + .iter() + .any(|path| path == "memory/agents/design/director.md"))); assert!(steps.iter().any(|step| step["agent"] == "策划组 / Director" && step["pass"].as_u64() == Some(2) && step["status"] == "carried-over" @@ -7769,6 +8605,11 @@ mod tests { .unwrap() .iter() .any(|path| path == ".agent/manifest.json") + && step["inputPaths"] + .as_array() + .unwrap() + .iter() + .any(|path| path == PROJECT_BLACKBOARD_MEMORY_PATH) && step["inputPaths"] .as_array() .unwrap() @@ -8019,6 +8860,7 @@ mod tests { role_definition: art_role, markdown: String::new(), relative_path: ".agent/passes/pass-1/groups/art/asset.md".to_string(), + memory_relative_path: agent_role_memory_relative_path(art_group, art_role), status: "completed".to_string(), tool_id: art_role.tool_id.to_string(), summary: String::new(), @@ -8028,6 +8870,7 @@ mod tests { role_definition: audio_role, markdown: String::new(), relative_path: ".agent/passes/pass-1/groups/audio/sfx.md".to_string(), + memory_relative_path: agent_role_memory_relative_path(audio_group, audio_role), status: "completed".to_string(), tool_id: audio_role.tool_id.to_string(), summary: String::new(), @@ -8456,6 +9299,30 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn prompt_context_redacts_secrets_before_truncating() { + let context = [ + "memory/blackboard.md: keep this", + "Authorization: Bearer secret-token", + "Cookie: session=secret", + "game-creator.config.json has apiKey", + "token sk-unit-secret assets/hero.png", + "tnr_sk_unit_secret", + ] + .join("\n"); + + let sanitized = truncate_prompt_context(&context); + + assert!(sanitized.contains("memory/blackboard.md")); + assert!(sanitized.contains("assets/hero.png")); + assert!(sanitized.contains("[redacted sensitive context]")); + assert!(!sanitized.contains("secret-token")); + assert!(!sanitized.contains("session=secret")); + assert!(!sanitized.contains("sk-unit-secret")); + assert!(!sanitized.contains("tnr_sk_unit_secret")); + assert!(!sanitized.contains("game-creator.config.json")); + } + #[test] fn upload_local_asset_rejects_empty_file() { let root = unique_project_path(); @@ -8785,6 +9652,10 @@ mod tests { 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(), @@ -8939,6 +9810,92 @@ mod tests { fs::remove_dir_all(root).ok(); } + #[test] + fn local_project_checkpoint_diff_restore_and_index_are_recorded() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init"); + write_local_project_file_at(&root, "game/notes.txt", "v1").expect("write notes"); + write_local_project_file_at(&root, "exports/README.md", "publish") + .expect("write export readme"); + let checkpoint = + create_local_project_checkpoint_at(&root).expect("create project checkpoint"); + + write_local_project_file_at(&root, "game/notes.txt", "v2").expect("change notes"); + write_local_project_file_at(&root, "game/extra.txt", "new").expect("add file"); + delete_local_project_file_at(&root, "exports/README.md").expect("delete tracked file"); + let diff = + diff_local_project_checkpoint_at(&root, &checkpoint.checkpoint_id).expect("diff"); + + assert!(diff + .changed + .iter() + .any(|entry| entry.path == "game/notes.txt")); + assert!(diff + .added + .iter() + .any(|entry| entry.path == "game/extra.txt")); + assert!(diff + .deleted + .iter() + .any(|entry| entry.path == "exports/README.md")); + + let index = build_local_project_index_at(&root).expect("project index"); + assert!(index.file_count > 0); + assert!(root.join(PROJECT_INDEX_PATH).exists()); + + let restored = + restore_local_project_checkpoint_at(&root, &checkpoint.checkpoint_id).expect("restore"); + assert!(restored.restored_count > 0); + assert_eq!( + fs::read_to_string(root.join("game/notes.txt")).expect("restored notes"), + "v1" + ); + assert!(root.join("exports/README.md").exists()); + + 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\"")); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn project_write_lock_rejects_parallel_writer_and_releases_on_drop() { + let root = unique_project_path(); + let first = acquire_project_write_lock(&root, "file.write").expect("first lock"); + + let error = + acquire_project_write_lock(&root, "file.delete").expect_err("second lock fails"); + assert!(error.contains("项目正在被其他写操作占用")); + + drop(first); + acquire_project_write_lock(&root, "file.delete").expect("lock released"); + + fs::remove_dir_all(root).ok(); + } + + #[test] + fn project_permission_policy_can_deny_mutating_commands() { + 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!["file.write".to_string()], + confirm_commands: Vec::new(), + }, + ) + .expect("write policy"); + + let error = + enforce_project_permission_policy(&root, "file.write").expect_err("denied command"); + assert!(error.contains("项目权限策略拒绝执行")); + enforce_project_permission_policy(&root, "file.read").expect("read allowed"); + + fs::remove_dir_all(root).ok(); + } + #[test] fn local_project_file_commands_reject_unsafe_paths() { 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 a9c2b086a..716fe7910 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -154,6 +154,43 @@ interface LocalProjectFileMutationResult { deleted: boolean; } +interface ProjectPermissionPolicy { + deniedCommands: string[]; + confirmCommands: string[]; +} + +interface ProjectPermissionPolicyView { + path: string; + policy: ProjectPermissionPolicy; +} + +interface LocalProjectIndexResult { + projectPath: string; + indexPath: string; + fileCount: number; + totalBytes: number; + files: Array<{ path: string; size: number; checksum: string }>; +} + +interface LocalProjectCheckpointResult { + checkpointId: string; + checkpointPath: string; + fileCount: number; + totalBytes: number; +} + +interface LocalProjectDiffResult { + checkpointId: string; + added: Array<{ path: string; status: string }>; + changed: Array<{ path: string; status: string }>; + deleted: Array<{ path: string; status: string }>; +} + +interface LocalProjectRestoreResult { + checkpointId: string; + restoredCount: number; +} + interface ChatMessage { role: 'assistant' | 'user'; text: string; @@ -196,6 +233,17 @@ export type PendingCommand = id: 'project.create'; projectPath: string; } + | { + id: 'project.checkpoint'; + } + | { + id: 'project.restore'; + checkpointId: string; + } + | { + id: 'project.policy_write'; + policy: ProjectPermissionPolicy; + } | { id: 'preview.start'; } @@ -296,6 +344,13 @@ const chatCommandHelp = [ '/capabilities:查看 Agent 能力清单', '/audit:审计当前项目的 Agent 能力证据', '/status:查看项目状态', + '/index:刷新本地项目索引', + '/checkpoint:保存本地项目快照', + '/diff checkpoint-id:对比 checkpoint', + '/restore checkpoint-id:恢复 checkpoint 中跟踪的文件', + '/policy:查看项目权限策略', + '/policy-deny 命令:拒绝项目内某个内置命令', + '/policy-allow 命令:移除项目内某个命令拒绝项', '/tasks:查看任务拆分', '/trace 或 /loop:查看最近一次 Agent loop trace', '/agent-status:查看最近 run 生命周期', @@ -415,6 +470,57 @@ function summarizeProjectFiles(files: LocalProjectFileEntry[]) { return `本地项目文件:\n${lines.join('\n')}`; } +function summarizeProjectIndex(result: LocalProjectIndexResult) { + const visibleFiles = result.files.slice(0, 12); + const lines = visibleFiles.map((file) => `- ${file.path} · ${file.size}B`); + if (result.files.length > visibleFiles.length) { + lines.push(`- 还有 ${result.files.length - visibleFiles.length} 项`); + } + return [ + `索引:${result.fileCount} 个文件,${result.totalBytes}B`, + `路径:${result.indexPath}`, + lines.join('\n'), + ] + .filter(Boolean) + .join('\n'); +} + +function summarizeProjectCheckpoint(result: LocalProjectCheckpointResult) { + return [ + `已保存 checkpoint:${result.checkpointId}`, + `文件:${result.fileCount} 个,${result.totalBytes}B`, + `路径:${result.checkpointPath}`, + ].join('\n'); +} + +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; + return ( + [ + `checkpoint:${result.checkpointId}`, + section('新增', result.added), + section('变更', result.changed), + section('删除', result.deleted), + ] + .filter(Boolean) + .join('\n') || '无差异。' + ); +} + +function summarizeProjectPolicy(view: ProjectPermissionPolicyView) { + return [ + `策略:${view.path}`, + `拒绝:${view.policy.deniedCommands.join('、') || '无'}`, + `确认:${view.policy.confirmCommands.join('、') || '无'}`, + ].join('\n'); +} + function summarizeProjectFileContent(result: LocalProjectFileResult) { const limit = 4000; const content = @@ -574,6 +680,21 @@ export function summarizeAgentAudit( const hasLongMemory = hasProjectFile(files, 'memory/project.md') || hasTracePath(trace, 'memory/project.md'); + const hasBlackboardMemory = + hasProjectFile(files, 'memory/blackboard.md') || + hasTracePath(trace, 'memory/blackboard.md'); + const hasAgentPrivateMemory = + files.some((file) => file.path.startsWith('memory/agents/')) || + (trace?.artifacts.some((artifact) => + artifact.path.startsWith('memory/agents/'), + ) ?? + false) || + (trace?.steps.some((step) => + [...step.inputPaths, ...step.outputPaths].some((path) => + path.startsWith('memory/agents/'), + ), + ) ?? + false); const hasCanvasAsset = nextManifest.assets.some( (asset) => asset.source.kind === 'canvas', ); @@ -584,9 +705,7 @@ export function summarizeAgentAudit( ), ) ?? false; const commandRuns = nextManifest.commandRuns ?? []; - const hasPermissionPending = commandLogContent.includes( - 'permission.pending', - ); + const hasPermissionPending = commandLogContent.includes('permission.pending'); const hasPermissionDecision = commandLogContent.includes('permission.confirm') || commandLogContent.includes('permission.cancel'); @@ -633,10 +752,15 @@ export function summarizeAgentAudit( '通过', trace ? '待触发' : '待生成', )}`, - `- 短期/长期记忆:${formatAuditStatus( - hasShortMemory && hasLongMemory, + `- 记忆:${formatAuditStatus( + hasShortMemory && + hasLongMemory && + hasBlackboardMemory && + hasAgentPrivateMemory, )} · session ${hasShortMemory ? '有' : '无'},project ${ hasLongMemory ? '有' : '无' + },blackboard ${hasBlackboardMemory ? '有' : '无'},agent ${ + hasAgentPrivateMemory ? '有' : '无' }`, `- 本地产物:${formatAuditStatus(finalArtifacts.length > 0)} · ${ finalArtifacts.join(',') || '无' @@ -749,6 +873,9 @@ export function needsInitializedChatProject(commandId: PendingCommand['id']) { 'command.run_limited', 'game.generate_draft', 'game.run_local', + 'project.checkpoint', + 'project.restore', + 'project.policy_write', 'preview.open', 'preview.start', 'canvas.project_sync', @@ -944,9 +1071,7 @@ export function summarizeAgentRunTrace(trace: GameCreationAgentRunTrace) { .join('\n'); } -function summarizeAgentRunCompletionForChat( - trace: GameCreationAgentRunTrace, -) { +function summarizeAgentRunCompletionForChat(trace: GameCreationAgentRunTrace) { return `${summarizeAgentRunTrace(trace)}\n完整 trace:/trace`; } @@ -974,6 +1099,15 @@ function pendingCommandTitle(command: PendingCommand) { if (command.id === 'project.create') { return command.id; } + if (command.id === 'project.checkpoint') { + return command.id; + } + if (command.id === 'project.restore') { + return `${command.id} · ${command.checkpointId}`; + } + if (command.id === 'project.policy_write') { + return command.id; + } if ( command.id === 'agent.kill' || command.id === 'agent.retry' || @@ -1018,6 +1152,15 @@ export function pendingCommandDetail( if (command.id === 'project.create') { return `创建 ${command.projectPath}`; } + if (command.id === 'project.checkpoint') { + return `保存 ${projectPath} 当前项目快照`; + } + if (command.id === 'project.restore') { + return `从 ${command.checkpointId} 恢复已跟踪项目文件`; + } + if (command.id === 'project.policy_write') { + return `写入 ${projectPath}/.agent/policy.json`; + } if (command.id === 'preview.start') { return `启动 ${projectPath}/game/ 并交给外部浏览器`; } @@ -1258,7 +1401,9 @@ export function App() { setRuntimeConfigStatus(`已读取:${result.path}`); setCommandLog((current) => [...current, 'runtime_config.read']); } catch (error) { - setRuntimeConfigStatus(error instanceof Error ? error.message : String(error)); + setRuntimeConfigStatus( + error instanceof Error ? error.message : String(error), + ); } } @@ -1281,7 +1426,9 @@ export function App() { setRuntimeConfigStatus(`已保存:${result.path}`); setCommandLog((current) => [...current, 'runtime_config.save']); } catch (error) { - setRuntimeConfigStatus(error instanceof Error ? error.message : String(error)); + setRuntimeConfigStatus( + error instanceof Error ? error.message : String(error), + ); } } @@ -1367,6 +1514,83 @@ export function App() { return; } + if (prompt === '/index') { + void executeProjectIndex(true); + return; + } + + if (prompt === '/checkpoint') { + if (!requireChatProjectForUserAction()) { + return; + } + queuePendingCommand({ id: 'project.checkpoint' }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: '准备保存当前项目快照。' }, + ]); + return; + } + + if (prompt.startsWith('/diff ')) { + const checkpointId = prompt.slice('/diff '.length).trim(); + if (!checkpointId) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '格式:/diff checkpoint-id' }, + ]); + return; + } + void executeProjectDiff(checkpointId, true); + return; + } + + if (prompt.startsWith('/restore ')) { + if (!requireChatProjectForUserAction()) { + return; + } + const checkpointId = prompt.slice('/restore '.length).trim(); + if (!checkpointId) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '格式:/restore checkpoint-id' }, + ]); + return; + } + queuePendingCommand({ id: 'project.restore', checkpointId }); + setMessages((current) => [ + ...current, + { role: 'assistant', text: `准备恢复 checkpoint:${checkpointId}` }, + ]); + return; + } + + if (prompt === '/policy') { + void executeProjectPolicyRead(true); + return; + } + + if ( + prompt.startsWith('/policy-deny ') || + prompt.startsWith('/policy-allow ') + ) { + if (!requireChatProjectForUserAction()) { + return; + } + const deny = prompt.startsWith('/policy-deny '); + const commandId = prompt + .slice(deny ? '/policy-deny '.length : '/policy-allow '.length) + .trim(); + if (!commandId) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '格式:/policy-deny file.write' }, + ]); + return; + } + void queueProjectPolicyMutation(commandId, deny); + return; + } + if (prompt === '/llm-status') { void executeLlmConfigStatus(); return; @@ -1911,6 +2135,12 @@ export function App() { void executeLimitedCommand(command.commandId, true); } else if (command.id === 'project.create') { void executeProjectCreate(command.projectPath, true); + } else if (command.id === 'project.checkpoint') { + void executeProjectCheckpoint(true); + } else if (command.id === 'project.restore') { + void executeProjectRestore(command.checkpointId, true); + } else if (command.id === 'project.policy_write') { + void executeProjectPolicyWrite(command.policy, true); } else if (command.id === 'preview.start') { void executePreviewStart(true); } else if (command.id === 'preview.open') { @@ -2066,6 +2296,321 @@ export function App() { } } + async function executeProjectIndex(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( + 'build_local_project_index', + { projectPath: nextProjectPath }, + ); + setFileStatus(`已索引 ${result.fileCount} 个文件`); + setCommandLog((current) => [...current, 'project.index']); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: summarizeProjectIndex(result) }, + ]); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setFileStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + + async function executeProjectCheckpoint(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( + 'create_local_project_checkpoint', + { projectPath: nextProjectPath }, + ); + setFileStatus(`已保存 checkpoint:${result.checkpointId}`); + setCommandLog((current) => [...current, 'project.checkpoint']); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: summarizeProjectCheckpoint(result) }, + ]); + } + } 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, + ) { + 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( + 'diff_local_project_checkpoint', + { projectPath: nextProjectPath, checkpointId }, + ); + setFileStatus(`已对比 checkpoint:${result.checkpointId}`); + setCommandLog((current) => [...current, 'project.diff']); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: summarizeProjectDiff(result) }, + ]); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setFileStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + + async function executeProjectRestore( + checkpointId: string, + 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( + 'restore_local_project_checkpoint', + { projectPath: nextProjectPath, checkpointId }, + ); + setFileStatus(`已恢复 ${result.restoredCount} 个文件`); + setCommandLog((current) => [...current, 'project.restore']); + void refreshManifest(nextProjectPath); + if (announceToChat) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: `已恢复 ${result.restoredCount} 个文件:${result.checkpointId}`, + }, + ]); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setFileStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + + async function executeProjectPolicyRead(announceToChat: boolean) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setProjectStatus('需要在 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( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + setProjectStatus('已读取权限策略'); + setCommandLog((current) => [...current, 'project.policy_read']); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: summarizeProjectPolicy(result) }, + ]); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setProjectStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + + async function queueProjectPolicyMutation(commandId: string, deny: boolean) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: '需要在 Tauri App 内运行。' }, + ]); + return; + } + const nextProjectPath = requireChatProjectForUserAction(); + if (!nextProjectPath) { + return; + } + + try { + const result = await invoke( + 'read_project_permission_policy', + { projectPath: nextProjectPath }, + ); + const deniedCommands = deny + ? [...new Set([...result.policy.deniedCommands, commandId])] + : result.policy.deniedCommands.filter((value) => value !== commandId); + queuePendingCommand({ + id: 'project.policy_write', + policy: { ...result.policy, deniedCommands }, + }); + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: `准备${deny ? '拒绝' : '允许'}命令:${commandId}`, + }, + ]); + } catch (error) { + setMessages((current) => [ + ...current, + { + role: 'assistant', + text: error instanceof Error ? error.message : String(error), + }, + ]); + } + } + + async function executeProjectPolicyWrite( + policy: ProjectPermissionPolicy, + announceToChat: boolean, + ) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setProjectStatus('需要在 Tauri App 内运行'); + return; + } + const nextProjectPath = announceToChat + ? requireChatProjectForUserAction() + : projectPath; + if (!nextProjectPath) { + return; + } + + try { + const result = await invoke( + 'write_project_permission_policy', + { projectPath: nextProjectPath, policy }, + ); + setProjectStatus('已写入权限策略'); + setCommandLog((current) => [...current, 'project.policy_write']); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: summarizeProjectPolicy(result) }, + ]); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setProjectStatus(message); + if (announceToChat) { + setMessages((current) => [ + ...current, + { role: 'assistant', text: message }, + ]); + } + } + } + async function executeProjectFiles(announceToChat: boolean) { const invoke = resolveTauriInvoke(); if (!invoke) { @@ -3775,7 +4320,10 @@ export function App() { type="checkbox" checked={runtimeConfigDraft.llm.stream} onChange={(event) => - updateRuntimeLlmConfig('stream', event.currentTarget.checked) + updateRuntimeLlmConfig( + 'stream', + event.currentTarget.checked, + ) } /> LLM 流式请求 diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index a98eb3e5b..f9792f00f 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -1,5 +1,11 @@ /** @vitest-environment jsdom */ -import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { + act, + cleanup, + fireEvent, + render, + screen, +} from '@testing-library/react'; import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import React from 'react'; @@ -196,8 +202,19 @@ describe('AI 游戏创作 App 界面边界', () => { expect( screen.getByText(/\/audit:审计当前项目的 Agent 能力证据/), ).not.toBeNull(); - expect(screen.getByText(/\/agent-status:查看最近 run 生命周期/)).not.toBeNull(); - expect(screen.getByText(/\/agent-kill:标记最近 run 为 killed/)).not.toBeNull(); + expect(screen.getByText(/\/checkpoint:保存本地项目快照/)).not.toBeNull(); + expect( + screen.getByText(/\/restore checkpoint-id:恢复 checkpoint/), + ).not.toBeNull(); + expect( + screen.getByText(/\/policy-deny 命令:拒绝项目内某个内置命令/), + ).not.toBeNull(); + expect( + screen.getByText(/\/agent-status:查看最近 run 生命周期/), + ).not.toBeNull(); + expect( + screen.getByText(/\/agent-kill:标记最近 run 为 killed/), + ).not.toBeNull(); expect(screen.queryByLabelText('开发环境')).toBeNull(); }); @@ -422,7 +439,11 @@ describe('AI 游戏创作 App 界面边界', () => { pass: 2, mode: 'repair', summary: '第 2 轮返工', - activeTaskIds: ['code-prototype', 'quality-review', 'preview-readiness'], + activeTaskIds: [ + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], carriedTaskIds: ['design-director'], dependencyWaves: [ ['code-prototype'], @@ -433,7 +454,11 @@ describe('AI 游戏创作 App 界面边界', () => { repairRoutes: [ { issue: '缺少输入监听', - taskIds: ['code-prototype', 'quality-review', 'preview-readiness'], + taskIds: [ + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], reason: 'code-runtime', }, ], @@ -466,6 +491,12 @@ describe('AI 游戏创作 App 界面边界', () => { { path: 'game/index.html', kind: 'file', size: 1024 }, { path: 'memory/session.md', kind: 'file', size: 64 }, { path: 'memory/project.md', kind: 'file', size: 64 }, + { path: 'memory/blackboard.md', kind: 'file', size: 64 }, + { + path: 'memory/agents/design/director.md', + kind: 'file', + size: 64, + }, { path: '.agent/logs/command.log', kind: 'file', size: 64 }, ], }; @@ -511,7 +542,7 @@ describe('AI 游戏创作 App 界面边界', () => { screen.getByText(/Planner\/Orchestrator\/Generator\/Evaluator:通过/), ).not.toBeNull(); expect(screen.getByText(/返工路由\/Carry-over:通过/)).not.toBeNull(); - expect(screen.getByText(/短期\/长期记忆:通过/)).not.toBeNull(); + expect(screen.getByText(/记忆:通过/)).not.toBeNull(); expect(screen.getByText(/本地产物:通过/)).not.toBeNull(); expect(screen.getByText(/本地 HTTP 预览:通过/)).not.toBeNull(); expect(screen.getByText(/画板回流:通过/)).not.toBeNull(); @@ -569,7 +600,9 @@ describe('AI 游戏创作 App 界面边界', () => { if (command === 'list_local_project_files') { return { projectPath: String(args?.projectPath ?? ''), - files: [{ path: '.agent/logs/command.log', kind: 'file', size: 64 }], + files: [ + { path: '.agent/logs/command.log', kind: 'file', size: 64 }, + ], }; } if (command === 'read_local_project_file') { @@ -788,7 +821,11 @@ describe('AI 游戏创作 App 界面边界', () => { taskGraph: { goal: '做一个三轮仍失败的厨房弹幕游戏', readyTaskIds: [], - activeTaskIds: ['code-prototype', 'quality-review', 'preview-readiness'], + activeTaskIds: [ + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], carriedTaskIds: ['design-director'], repairFocus: ['缺少输入监听'], repairRoutes: [ @@ -805,7 +842,11 @@ describe('AI 游戏创作 App 界面边界', () => { pass: 3, mode: 'repair', summary: '第 3 轮返工仍未通过', - activeTaskIds: ['code-prototype', 'quality-review', 'preview-readiness'], + activeTaskIds: [ + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], carriedTaskIds: ['design-director'], dependencyWaves: [ ['code-prototype'], @@ -816,7 +857,11 @@ describe('AI 游戏创作 App 界面边界', () => { repairRoutes: [ { issue: '缺少输入监听', - taskIds: ['code-prototype', 'quality-review', 'preview-readiness'], + taskIds: [ + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], reason: 'code-runtime', }, ], @@ -941,6 +986,62 @@ describe('AI 游戏创作 App 界面边界', () => { }); }); + it('restores checkpoints 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 === 'restore_local_project_checkpoint') { + return { + checkpointId: String(args?.checkpointId ?? ''), + restoredCount: 3, + }; + } + 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('/restore checkpoint-1'); + fireEvent.click(screen.getByRole('button', { name: '确认' })); + + expect( + await screen.findByText(/已恢复 3 个文件:checkpoint-1/), + ).not.toBeNull(); + expect(invoke).toHaveBeenCalledWith('restore_local_project_checkpoint', { + projectPath: '/tmp/authorized-game', + checkpointId: 'checkpoint-1', + }); + expect(invoke).toHaveBeenCalledWith('append_local_permission_log', { + projectPath: '/tmp/authorized-game', + event: 'permission.confirm', + commandId: 'project.restore', + }); + }); + it('does not stop a preview before a local project is initialized', () => { const invoke = vi.fn(); window.__TAURI__ = { core: { invoke } }; @@ -1273,7 +1374,9 @@ describe('AI 游戏创作 App 界面边界', () => { fireEvent.click(screen.getByRole('button', { name: '确认' })); expect( - await screen.findByText(/运行通过,预览已启动:http:\/\/127\.0\.0\.1:3210\//), + await screen.findByText( + /运行通过,预览已启动:http:\/\/127\.0\.0\.1:3210\//, + ), ).not.toBeNull(); expect(screen.getByText(/已交给外部浏览器打开。/)).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('run_limited_local_command', { @@ -1570,10 +1673,12 @@ describe('AI 游戏创作 App 界面边界', () => { }; }, ); - const listen = vi.fn(async (_event: string, handler: typeof progressHandler) => { - progressHandler = handler; - return vi.fn(); - }); + const listen = vi.fn( + async (_event: string, handler: typeof progressHandler) => { + progressHandler = handler; + return vi.fn(); + }, + ); window.__TAURI__ = { core: { invoke }, event: { listen } }; renderAppAt('/'); @@ -1748,13 +1853,23 @@ describe('AI 游戏创作 App 界面边界', () => { taskGraph: { goal: '做一个反弹弹幕厨房游戏', readyTaskIds: ['preview-playtest'], - activeTaskIds: ['code-director', 'code-prototype', 'quality-review', 'preview-readiness'], + activeTaskIds: [ + 'code-director', + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], carriedTaskIds: ['design-director', 'design-foundation'], repairFocus: ['缺少输入监听'], repairRoutes: [ { issue: '缺少输入监听', - taskIds: ['code-director', 'code-prototype', 'quality-review', 'preview-readiness'], + taskIds: [ + 'code-director', + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], reason: 'code-runtime', }, ], @@ -1765,7 +1880,12 @@ describe('AI 游戏创作 App 界面边界', () => { pass: 2, mode: 'repair', summary: '第 2 轮按 Evaluator 反馈返工', - activeTaskIds: ['code-director', 'code-prototype', 'quality-review', 'preview-readiness'], + activeTaskIds: [ + 'code-director', + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], carriedTaskIds: ['design-director', 'design-foundation'], dependencyWaves: [ ['code-director'], @@ -1777,7 +1897,12 @@ describe('AI 游戏创作 App 界面边界', () => { repairRoutes: [ { issue: '缺少输入监听', - taskIds: ['code-director', 'code-prototype', 'quality-review', 'preview-readiness'], + taskIds: [ + 'code-director', + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], reason: 'code-runtime', }, ], @@ -1870,10 +1995,14 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('做一个反弹弹幕厨房游戏'); fireEvent.click(screen.getByRole('button', { name: '确认' })); - expect(screen.getByText(/开始调用 LLM:Planner 正在整理规格。/)).not.toBeNull(); + expect( + screen.getByText(/开始调用 LLM:Planner 正在整理规格。/), + ).not.toBeNull(); expect(screen.getByText(/Generator 生成代码和资产清单/)).not.toBeNull(); expect( - await screen.findByText(/已保存并启动本地预览:http:\/\/127\.0\.0\.1:3210\//), + await screen.findByText( + /已保存并启动本地预览:http:\/\/127\.0\.0\.1:3210\//, + ), ).not.toBeNull(); expect(screen.getByText(/已交给外部浏览器打开。/)).not.toBeNull(); expect(screen.getByText(/Run:run-chat-generate/)).not.toBeNull(); @@ -1882,8 +2011,16 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); expect(screen.getByText(/工具调用:36\/128/)).not.toBeNull(); expect(screen.getByText(/LLM 对话:/)).not.toBeNull(); - expect(screen.getByText(/Planner #0 · completed · planning · llm\.chat\.planner/)).not.toBeNull(); - expect(screen.getByText(/Generator #2 · completed · generate · llm\.chat\.generator/)).not.toBeNull(); + expect( + screen.getByText( + /Planner #0 · completed · planning · llm\.chat\.planner/, + ), + ).not.toBeNull(); + expect( + screen.getByText( + /Generator #2 · completed · generate · llm\.chat\.generator/, + ), + ).not.toBeNull(); expect( screen.getByText( /active 任务:程序组 \/ Director 拆解程序实现\(code-director\), 程序组 \/ Code 生成可运行原型\(code-prototype\), 程序组 \/ Review 执行质量评审\(quality-review\), 程序组 \/ Preview 执行静态自检\(preview-readiness\)/, @@ -1899,9 +2036,13 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); expect(screen.getByText(/编排轮次:/)).not.toBeNull(); expect(screen.getByText(/产物快照:/)).not.toBeNull(); - expect(screen.getByText(/- game\/index\.html · fnv1a64:game/)).not.toBeNull(); + expect( + screen.getByText(/- game\/index\.html · fnv1a64:game/), + ).not.toBeNull(); expect(screen.getByText(/最近步骤:/)).not.toBeNull(); - expect(screen.getByText(/Evaluator #2 · passed · evaluation/)).not.toBeNull(); + expect( + screen.getByText(/Evaluator #2 · passed · evaluation/), + ).not.toBeNull(); expect(screen.getByText(/完整 trace:\/trace/)).not.toBeNull(); expect(screen.queryByLabelText('开发环境')).toBeNull(); expect(screen.queryByText('编排 Trace')).toBeNull(); @@ -2163,13 +2304,23 @@ describe('AI 游戏创作 App 界面边界', () => { taskGraph: { goal: '做一个反弹弹幕厨房游戏', readyTaskIds: ['code-director'], - activeTaskIds: ['code-director', 'code-prototype', 'quality-review', 'preview-readiness'], + activeTaskIds: [ + 'code-director', + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], carriedTaskIds: ['design-director'], repairFocus: ['缺少输入监听'], repairRoutes: [ { issue: '缺少输入监听', - taskIds: ['code-director', 'code-prototype', 'quality-review', 'preview-readiness'], + taskIds: [ + 'code-director', + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], reason: 'code-repair', }, ], @@ -2180,7 +2331,12 @@ describe('AI 游戏创作 App 界面边界', () => { pass: 2, mode: 'repair', summary: '第 2 轮按 Evaluator 反馈返工', - activeTaskIds: ['code-director', 'code-prototype', 'quality-review', 'preview-readiness'], + activeTaskIds: [ + 'code-director', + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], carriedTaskIds: ['design-director'], dependencyWaves: [ ['code-director'], @@ -2192,7 +2348,12 @@ describe('AI 游戏创作 App 界面边界', () => { repairRoutes: [ { issue: '缺少输入监听', - taskIds: ['code-director', 'code-prototype', 'quality-review', 'preview-readiness'], + taskIds: [ + 'code-director', + 'code-prototype', + 'quality-review', + 'preview-readiness', + ], reason: 'code-repair', }, ], @@ -2337,7 +2498,8 @@ describe('AI 游戏创作 App 界面边界', () => { ...resultByAction, activityPath: '/tmp/authorized-game/.agent/activity.jsonl', outputPath: '/tmp/authorized-game/.agent/output.jsonl', - contextBundlePath: '/tmp/authorized-game/.agent/context.bundle.json', + contextBundlePath: + '/tmp/authorized-game/.agent/context.bundle.json', }; } if (command === 'read_local_project_file') { @@ -2361,7 +2523,9 @@ describe('AI 游戏创作 App 界面边界', () => { submitChat('/agent-status'); expect( - await screen.findByText(/run run-control-chat 当前状态:pending \/ pending/), + await screen.findByText( + /run run-control-chat 当前状态:pending \/ pending/, + ), ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('control_agent_run', { projectPath: '/tmp/authorized-game', diff --git a/apps/ai-game-creator-shell/tests/rememberCommand.test.ts b/apps/ai-game-creator-shell/tests/rememberCommand.test.ts index 26e100928..ff73c702f 100644 --- a/apps/ai-game-creator-shell/tests/rememberCommand.test.ts +++ b/apps/ai-game-creator-shell/tests/rememberCommand.test.ts @@ -112,10 +112,37 @@ describe('AI 游戏创作聊天记忆命令', () => { expect(needsInitializedChatProject('canvas.export_import')).toBe(true); expect(needsInitializedChatProject('memory.write')).toBe(true); expect(needsInitializedChatProject('memory.delete')).toBe(true); + expect(needsInitializedChatProject('project.checkpoint')).toBe(true); + expect(needsInitializedChatProject('project.restore')).toBe(true); + expect(needsInitializedChatProject('project.policy_write')).toBe(true); expect(needsInitializedChatProject('project.create')).toBe(false); expect(needsInitializedChatProject('canvas.project_open')).toBe(false); }); + it('describes project safety commands before confirmation', () => { + expect( + pendingCommandDetail({ id: 'project.checkpoint' }, '/tmp/game'), + ).toBe('保存 /tmp/game 当前项目快照'); + expect( + pendingCommandDetail( + { id: 'project.restore', checkpointId: 'checkpoint-1' }, + '/tmp/game', + ), + ).toBe('从 checkpoint-1 恢复已跟踪项目文件'); + expect( + pendingCommandDetail( + { + id: 'project.policy_write', + policy: { + deniedCommands: ['file.write'], + confirmCommands: [], + }, + }, + '/tmp/game', + ), + ).toBe('写入 /tmp/game/.agent/policy.json'); + }); + it('describes agent run lifecycle controls before confirmation', () => { expect(pendingCommandDetail({ id: 'agent.kill' }, '/tmp/game')).toBe( '标记 /tmp/game/.agent/run.latest.json 为 killed,并写入 activity/output', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 484013ccb..46bdcad5f 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3850,6 +3850,8 @@ - 2026-06-30 调整:`game.generate_draft` 在 `editorApi.apiKey` 已配置且美术组缺少 `canvas` 来源图片资产时,直接调用 External Editor API 的 `/api/external/v1/editor/images/generations` 生成首版美术素材,再通过 `/api/external/v1/assets/read-url` 换签下载到本地 `assets/canvas-generated/`,登记为 `canvas` 来源资产并追加 `canvas.asset_generate` 本地索引记录。API Key 不写入 manifest、agent.db、trace 或日志;生成失败不阻断本地原型生成,会在 trace 中记录失败并保留同步建议。 - 2026-06-25 调整,2026-06-30 更新:美术组 `Asset` 和音乐组 `SFX` 角色在 loop 中读取 `.agent/manifest.json`;当本地项目还没有对应类型的 `canvas` 来源资产时,角色 step 会追加 `agent.tool.suggest.canvas.project_sync` toolCall:美术组需要 `image/*` 或 `application/vnd.genarrative.image-sequence`,音乐组需要 `audio/*`。美术组在未配置 `editorApi.apiKey` 或平台生图失败时仍只给出同步建议;音乐组不调用图片生成接口,只建议同步已有音频资源。 - 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-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。 - 2026-06-24 调整:聊天区待确认命令的日志语义必须区分 `permission.pending`、`permission.confirm` 和 `permission.cancel`;待确认卡片必须展示本地写入目标路径,避免用户在不知道落盘位置时确认。 - 2026-06-24 调整:普通用户通过聊天输入 `/status` 读取 `.agent/manifest.json` 的项目状态摘要,只在聊天消息里展示项目目录、任务状态、资产数量、预览状态和最近命令;不得为了状态查看暴露任务、文件或日志面板。 diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 6e913107d..b018cf181 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -12,6 +12,7 @@ - Agent Runtime:扩展 `server-rs/crates/platform-agent`,不引入 LangChain、AutoGen、Microsoft Agent Framework 或 OpenAI Agents SDK sidecar 作为核心。 - 设计参考:借鉴 OpenAI Agents SDK 的 Agent、Tools、Handoffs、Guardrails、Tracing 抽象,但运行时由 Genarrative 自己掌控。 - Run 控制参考:借鉴 Harbour 的控制平面思想,只吸收 `run lifecycle`、activity/output stream、context bundle、kill/retry/resume 等本地运行治理能力;不引入 Harbour 的多租户后台、调度 UI、通用 shell workflow 或远程 runner 作为 v1 依赖。 +- 本地工程参考:借鉴 Godcoder 的本地产物 checkpoint / diff / restore、上下文安全过滤、轻量项目索引、项目级写锁和项目级权限策略;不引入通用 IDE 插件、云工作区或任意代码代理。 ## Runtime 边界 @@ -20,7 +21,7 @@ Agent Runtime 负责: - 总任务拆分和任务图状态流转。 - 6 个专业组调度:策划组、美术组、程序组、数值组、音乐组、运营组。 - 工具注册、权限 gate、工具调用预算和执行日志。 -- 短期记忆、长期记忆、本地 manifest 和产物索引。 +- 短期记忆、长期记忆、项目黑板、角色私有记忆、本地 manifest 和产物索引。 - 本地项目文件、运行命令、HTTP 预览和验收自检。 专业组不是 6 个单 Agent。每组内部按任务动态使用 Director、Gameplay、Code、Asset、Preview、Playtest、Polish、Publish 等角色模板。 @@ -31,9 +32,11 @@ Agent Runtime 负责: - 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。 - 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作。 - 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。 -- 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`,支持读取、保存和删除。 +- 记忆能力:短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目级黑板 `memory/blackboard.md` 和角色私有记忆 `memory/agents//.md`;黑板用于共享重要跨 agent 记忆,角色私有记忆只给对应角色 brief 读取和追加。 - 本地能力:生成代码和资产落盘、本地 manifest、受限运行命令、本地 HTTP 预览。 - 文件能力:`file.list/read/write/delete` 只允许访问项目目录内的相对路径,拒绝绝对路径、`..`、反斜杠和符号链接。 +- 产物治理能力:写入前生成本地 checkpoint,写入后记录相对路径 diff,用户确认后可 restore 到最近 checkpoint。 +- 安全能力:上下文进入 LLM 前先做密钥模式和本机配置痕迹过滤;项目写入走项目级写锁和项目级权限策略。 - 创作能力:策划、美术、程序、数值、音乐、运营 6 组专业 agent,组内按任务使用 Director、Gameplay、Code、Asset、Preview、Playtest、Polish、Publish 等角色模板。 ## 本地项目结构 @@ -43,14 +46,24 @@ game-project/ game/ assets/ memory/ + session.md + project.md + blackboard.md + agents/ + / + .md exports/ .agent/ agent.db manifest.json + policy.json + project.lock + project.index.json run.latest.json activity.jsonl output.jsonl context.bundle.json + checkpoints/ runs/ logs/ ``` @@ -86,7 +99,7 @@ game-project/ - 生成代码和资产进入用户本地项目目录。 - 本地 HTTP 预览能启动,并在外部浏览器展示可玩原型。 - 美术/音乐资产能从画板链路回流到本地项目。 -- 长期记忆可查看、可编辑、可删除。 +- 短期记忆、长期记忆、项目黑板和角色私有记忆按授权本地项目路径读写;普通用户仍只通过聊天命令访问短期 / 长期记忆。 ## v1 验收证据矩阵 @@ -99,7 +112,7 @@ game-project/ ## 当前最小落地 - `apps/ai-game-creator-shell` 是独立 Tauri App,不复用 `apps/desktop-shell`。 -- 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地产物索引 `.agent/agent.db`,并生成默认 `game/index.html`。 +- 本地项目初始化会创建 `game/`、`assets/`、`memory/`、`memory/agents/`、`exports/`、`.agent/logs/`,写入 `.agent/manifest.json`,生成 append-only JSONL 本地项目索引 `.agent/agent.db`,并生成默认 `game/index.html`。 - 普通用户可在聊天框输入 `/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,缺配置时以非零状态退出或在聊天里提示未就绪。 @@ -113,21 +126,24 @@ game-project/ - 主窗口“配置”面板读写 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。 - `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 写入 `.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` 静态自检和预览试玩。 +- `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 等上下文来源;每个 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/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,普通用户窗口不展示。 - `.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`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。 -- `.agent/agent.db` 当前作为最小本地索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题和本地产物路径,上传 / 登记 / 画板导入资产时追加 `asset.register` 或 `asset.update`;v1 不引入 SQLite 依赖。 +- 通过 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.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。 - `.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。 - `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/`、`game/`、`assets/`、`exports/` 和 `.agent/manifest.json` 路径;写入完成后 `nextStep` 指向 `game.static_smoke`。 +- `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`、设计草案 `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 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照;完整证据仍由 `/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` 不覆盖记忆文件,而是继续追加短期对话记录和长期创作目标记录,保留用户迭代历史。 +- 同一项目内多次 `game.generate_draft` 不覆盖记忆文件,而是继续追加短期对话记录、长期创作目标记录、项目黑板摘要和角色私有摘要,保留用户迭代历史。 - `game.generate_draft` 会把本轮协作写入 `.agent/logs/agent.log` 和 manifest `commandRuns`,并将已完成的组内角色任务标记为完成,`preview-readiness` 在生成后等待本地 `game.static_smoke`,预览试玩任务等待用户确认。 - `game.generate_draft` 会把组内角色 brief、专业组汇总 brief、专业组 / 角色 / 产物交接摘要写入 `.agent/passes/pass-N/groups/`、`game/game_design.md`、`exports/README.md` 和 `.agent/logs/agent.log`,用于追踪 6 组 agent 的最小协作结果。 - 文件选择会生成待确认的 `asset.upload` 内置命令;用户确认后才把文件写入 `assets/uploads/` 并登记到 manifest。 diff --git a/packages/shared/src/contracts/gameCreationApp.test.ts b/packages/shared/src/contracts/gameCreationApp.test.ts index e6a7b32d3..1a1bf31ec 100644 --- a/packages/shared/src/contracts/gameCreationApp.test.ts +++ b/packages/shared/src/contracts/gameCreationApp.test.ts @@ -255,6 +255,7 @@ describe('AI 游戏创作 App 共享契约', () => { 'audio-asset-plan', 'code-director', 'code-prototype', + 'quality-review', 'preview-readiness', 'preview-playtest', 'publish-strategy', diff --git a/packages/shared/src/contracts/gameCreationApp.ts b/packages/shared/src/contracts/gameCreationApp.ts index 57fff1909..f61c5792b 100644 --- a/packages/shared/src/contracts/gameCreationApp.ts +++ b/packages/shared/src/contracts/gameCreationApp.ts @@ -16,6 +16,12 @@ export const GAME_CREATION_APP_COMMANDS = [ { id: 'help.show', permission: 'auto' }, { id: 'project.create', permission: 'confirm' }, { id: 'project.status', permission: 'auto' }, + { id: 'project.index', permission: 'auto' }, + { id: 'project.checkpoint', permission: 'confirm' }, + { id: 'project.diff', permission: 'auto' }, + { id: 'project.restore', permission: 'confirm' }, + { id: 'project.policy_read', permission: 'auto' }, + { id: 'project.policy_write', permission: 'confirm' }, { id: 'task.list', permission: 'auto' }, { id: 'agent.trace_read', permission: 'auto' }, { id: 'agent.run_status', permission: 'auto' }, @@ -101,6 +107,8 @@ export const GAME_CREATION_AGENT_CAPABILITIES = [ { id: 'short-term-memory', area: 'agent-runtime', title: '短期记忆' }, { id: 'long-term-memory', 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: '本地项目索引' }, { id: 'local-preview', area: 'local-runtime', title: '本地 HTTP 预览' }, { id: 'canvas-project-sync', @@ -109,6 +117,7 @@ export const GAME_CREATION_AGENT_CAPABILITIES = [ }, { id: 'developer-window', area: 'dev-runtime', title: '开发窗口' }, { id: 'guardrails', area: 'dev-runtime', title: '权限 Gate' }, + { id: 'project-policy', area: 'dev-runtime', title: '项目级权限策略' }, { id: 'trace-log', area: 'dev-runtime', title: '执行日志' }, ] as const satisfies readonly GameCreationAgentCapabilityDescriptor[]; @@ -271,7 +280,9 @@ export const GAME_CREATION_APP_SEED_TASKS = [ status: 'pending', dependencies: ['code-prototype'], artifacts: ['.agent/findings.md', '.agent/run.latest.json'], - acceptanceCriteria: ['玩法、资产、数值、程序和发布包装通过跨专业组质量评审'], + acceptanceCriteria: [ + '玩法、资产、数值、程序和发布包装通过跨专业组质量评审', + ], }, { id: 'preview-readiness', 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 c0b636bd7..ee4c3e272 100644 --- a/server-rs/crates/shared-contracts/src/game_creation_app.rs +++ b/server-rs/crates/shared-contracts/src/game_creation_app.rs @@ -21,10 +21,16 @@ pub struct GameCreationAppCommandDescriptor { pub permission: GameCreationAppPermission, } -pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 33] = [ +pub const GAME_CREATION_APP_COMMANDS: [GameCreationAppCommandDescriptor; 39] = [ command("help.show", GameCreationAppPermission::Auto), command("project.create", GameCreationAppPermission::Confirm), command("project.status", GameCreationAppPermission::Auto), + command("project.index", GameCreationAppPermission::Auto), + command("project.checkpoint", GameCreationAppPermission::Confirm), + command("project.diff", GameCreationAppPermission::Auto), + command("project.restore", GameCreationAppPermission::Confirm), + command("project.policy_read", GameCreationAppPermission::Auto), + command("project.policy_write", GameCreationAppPermission::Confirm), command("task.list", GameCreationAppPermission::Auto), command("agent.trace_read", GameCreationAppPermission::Auto), command("agent.run_status", GameCreationAppPermission::Auto), @@ -72,7 +78,7 @@ pub struct GameCreationAgentCapabilityDescriptor { pub title: &'static str, } -pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 23] = [ +pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescriptor; 26] = [ capability("chat", "user", "聊天入口"), capability("file-upload", "user", "上传文件"), capability("built-in-commands", "agent-runtime", "内置命令调用"), @@ -103,10 +109,13 @@ pub const GAME_CREATION_AGENT_CAPABILITIES: [GameCreationAgentCapabilityDescript capability("short-term-memory", "agent-runtime", "短期记忆"), capability("long-term-memory", "agent-runtime", "长期记忆"), capability("local-artifacts", "local-runtime", "本地产物保存"), + capability("project-checkpoints", "local-runtime", "项目快照与恢复"), + capability("project-index", "local-runtime", "本地项目索引"), capability("local-preview", "local-runtime", "本地 HTTP 预览"), capability("canvas-project-sync", "local-runtime", "画板项目资源同步"), capability("developer-window", "dev-runtime", "开发窗口"), capability("guardrails", "dev-runtime", "权限 Gate"), + capability("project-policy", "dev-runtime", "项目级权限策略"), capability("trace-log", "dev-runtime", "执行日志"), ];