From c0ff9edf97300a446317b2d3bed4abe33c93daa9 Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Wed, 8 Jul 2026 20:54:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=8D=95Agent=E7=9C=9F?= =?UTF-8?q?=E5=AE=9E=E5=AF=B9=E8=AF=9D=E4=B8=8E=E5=B9=B6=E8=A1=8C=E6=89=A7?= =?UTF-8?q?=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增单 Agent 真实 LLM 对话命令并按 agentLlm. 路由 开发聊天窗口切换 Agent 时自动读取历史并持久化真实回复 Agent loop 按 dependencyWaves 并行执行同一 wave 内角色 brief 补充并行执行、单 Agent 对话和界面回归测试 更新 AI 游戏创作 App 技术方案文档 --- .../src-tauri/src/agent.rs | 400 ++++++++++++++---- .../src-tauri/src/commands.rs | 11 + .../src-tauri/src/main.rs | 1 + .../src-tauri/src/tests.rs | 249 ++++++++++- apps/ai-game-creator-shell/src/App.tsx | 41 +- .../tests/appSurface.test.ts | 59 ++- ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 8 +- 7 files changed, 667 insertions(+), 102 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent.rs b/apps/ai-game-creator-shell/src-tauri/src/agent.rs index 9f12c81c4..e8d41fca3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -117,10 +117,102 @@ pub(crate) async fn chat_with_game_creator_agent_at( Ok(GameCreatorChatAgentReply { reply_text }) } +pub(crate) async fn chat_with_game_creator_role_agent_at( + root: &Path, + agent_id: &str, + prompt: &str, +) -> Result { + let agent_id = agent_id.trim(); + let prompt = prompt.trim(); + if agent_id.is_empty() { + return Err("Agent ID 不能为空".to_string()); + } + if prompt.is_empty() { + return Err("聊天内容不能为空".to_string()); + } + validate_project_root(root)?; + let (group_definition, role_definition) = game_creator_agent_role_definition(agent_id) + .ok_or_else(|| format!("未知 Agent:{agent_id}"))?; + + 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 agent_memory = read_local_agent_memory_at(root, agent_id)?.content; + let asset_context = render_local_asset_prompt_context(root)?; + let conversation_context = render_local_conversation_prompt_context(root, Some(agent_id))?; + let identity = format!( + "你当前是 {} / {},taskId={},角色代号={}。请只以这个专业 Agent 的身份回应。", + group_definition.label, role_definition.role, role_definition.task_id, role_definition.id + ); + let context = [ + ("Agent 身份", identity.as_str()), + ("Agent 私有记忆", agent_memory.as_str()), + ("短期记忆", short_memory.as_str()), + ("长期记忆", long_memory.as_str()), + ("项目黑板", project_blackboard.as_str()), + ("资产上下文", asset_context.as_str()), + ("最近项目与本 Agent 对话", conversation_context.as_str()), + ] + .into_iter() + .filter_map(|(title, content)| { + let content = truncate_prompt_context(content); + if content.trim().is_empty() { + None + } else { + Some(format!("# {title}\n\n{content}")) + } + }) + .collect::>() + .join("\n\n"); + + let app_config = load_game_creator_app_config()?; + let llm = resolve_game_creator_llm_config_for_agent(&app_config, agent_id); + let config_path = format!("agentLlm.{agent_id}"); + let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?; + let user_prompt = if context.trim().is_empty() { + format!("用户这轮输入:\n{prompt}") + } else { + format!("项目上下文如下。请只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}") + }; + let request = LlmRunRequest::new(vec![ + LlmMessage::system(game_creator_role_agent_chat_system_prompt()), + LlmMessage::user(user_prompt), + ]) + .with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?) + .with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS); + let response = request_game_creator_llm_text(&client, &llm, request) + .await + .map_err(|error| format!("{config_path} 单 Agent 聊天调用 LLM 失败:{error}"))?; + let reply_text = strip_llm_thinking_blocks(response.text.as_str()); + if reply_text.is_empty() { + return Err(format!("{config_path} 单 Agent 聊天未返回内容")); + } + + Ok(GameCreatorChatAgentReply { reply_text }) +} + pub(crate) fn game_creator_chat_agent_system_prompt() -> &'static str { "你是 Genarrative AI 游戏创作桌面 App 的主聊天 Agent。你要像正常协作型聊天助手一样回应用户,理解需求、澄清不确定点、给出下一步建议,并在需要执行生成、运行、预览、读取文件、写记忆或生成美术时建议用户使用现有 slash 命令。普通聊天中不要假装已经写入文件、生成游戏、调用画板或执行工具;不要输出 JSON;不要泄露密钥;回复保持简洁、具体、中文优先。" } +pub(crate) fn game_creator_role_agent_chat_system_prompt() -> &'static str { + "你是 Genarrative AI 游戏创作多智能体中的一个专业角色 Agent。你正在开发专用单 Agent 聊天窗口中和开发者对话,需要围绕自己的专业职责直接回应、澄清问题、给出可执行建议,并说明哪些信息会影响后续生成。不要假装已经写入文件、生成游戏、调用画板或执行工具;不要泄露密钥;不要输出 JSON;不要包裹代码块;回复保持简洁、具体、中文优先。" +} + +pub(crate) fn game_creator_agent_role_definition( + agent_id: &str, +) -> Option<(&'static AgentGroupDefinition, &'static AgentRoleDefinition)> { + GAME_CREATOR_AGENT_GROUP_DEFINITIONS + .iter() + .find_map(|group_definition| { + group_definition + .roles + .iter() + .find(|role_definition| role_definition.task_id == agent_id) + .map(|role_definition| (group_definition, role_definition)) + }) +} + pub(crate) fn write_local_game_draft_at( root: &Path, prompt: &str, @@ -787,25 +879,53 @@ pub(crate) async fn request_agent_group_briefs_with_client( agenda: &AgentPassAgenda, pass: u8, ) -> Result, String> { - let mut briefs = Vec::new(); + let mut role_briefs_by_task = BTreeMap::::new(); let mut completed_group_context = String::new(); + let mut completed_role_context = String::new(); let agenda_markdown = read_optional_text(&root.join(&agenda.relative_path))?; - for definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { - let mut role_briefs = Vec::new(); - let mut completed_role_context = String::new(); - for role_definition in definition.roles { + + let ordered_roles = ordered_game_creator_agent_roles(); + let mut waves = if agenda.dependency_waves.is_empty() { + vec![ordered_roles + .iter() + .map(|(_, role)| role.task_id.to_string()) + .collect::>()] + } else { + agenda.dependency_waves.clone() + }; + let known_wave_task_ids = waves + .iter() + .flat_map(|wave| wave.iter()) + .cloned() + .collect::>(); + let missing_task_ids = ordered_roles + .iter() + .map(|(_, role)| role.task_id.to_string()) + .filter(|task_id| !known_wave_task_ids.contains(task_id)) + .collect::>(); + if !missing_task_ids.is_empty() { + waves.push(missing_task_ids); + } + + for wave in waves { + let mut role_brief_jobs = tokio::task::JoinSet::new(); + for task_id in wave { + let Some((definition, role_definition)) = game_creator_agent_role_definition(&task_id) + else { + continue; + }; let agent_memory_relative_path = - agent_role_memory_relative_path(definition, *role_definition); - let mut should_run = agenda + agent_role_memory_relative_path(*definition, *role_definition); + let should_run = agenda .active_task_ids .iter() - .any(|task_id| task_id == role_definition.task_id); + .any(|active_task_id| active_task_id == role_definition.task_id); if !should_run { if let Some((source_path, source_markdown)) = - read_previous_agent_role_brief(root, pass, definition, *role_definition)? + read_previous_agent_role_brief(root, pass, *definition, *role_definition)? { let markdown = render_carryover_role_brief( - definition, + *definition, *role_definition, &source_path, &source_markdown, @@ -813,12 +933,12 @@ pub(crate) async fn request_agent_group_briefs_with_client( let relative_path = write_agent_role_brief( root, pass, - definition, + *definition, *role_definition, &markdown, )?; let role_brief = AgentRoleBrief { - group_definition: definition, + group_definition: *definition, role_definition: *role_definition, markdown, relative_path, @@ -834,83 +954,85 @@ pub(crate) async fn request_agent_group_briefs_with_client( ), }; completed_role_context.push_str(&render_agent_role_brief_context(&role_brief)); - role_briefs.push(role_brief); + role_briefs_by_task.insert(role_definition.task_id.to_string(), role_brief); continue; } - should_run = true; } - if !should_run { - continue; - } - let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?; - let agent_conversation_context = - render_local_conversation_prompt_context(root, Some(role_definition.task_id))?; - let role_short_memory = - append_prompt_context(&agent_conversation_context, short_memory); - let local_markdown = render_local_agent_role_brief( - definition, - *role_definition, - prompt, - &role_short_memory, - long_memory, - project_blackboard, - &agent_memory, - spec_markdown, - findings_markdown, - &agenda_markdown, - &completed_group_context, - &completed_role_context, + let root = root.to_path_buf(); + let app_config = app_config.clone(); + let prompt = prompt.to_string(); + let short_memory = short_memory.to_string(); + let long_memory = long_memory.to_string(); + let project_blackboard = project_blackboard.to_string(); + let spec_markdown = spec_markdown.to_string(); + let findings_markdown = findings_markdown.to_string(); + let agenda_markdown = agenda_markdown.clone(); + let completed_group_context = completed_group_context.clone(); + let completed_role_context = completed_role_context.clone(); + role_brief_jobs.spawn(async move { + build_agent_role_brief_draft( + root, + app_config, + *definition, + *role_definition, + prompt, + short_memory, + long_memory, + project_blackboard, + spec_markdown, + findings_markdown, + agenda_markdown, + completed_group_context, + completed_role_context, + pass, + ) + .await + }); + } + let mut role_brief_drafts = Vec::new(); + while let Some(result) = role_brief_jobs.join_next().await { + let draft = result.map_err(|error| format!("角色 Agent 并行任务失败:{error}"))??; + role_brief_drafts.push(draft); + } + role_brief_drafts.sort_by_key(|draft| { + ordered_roles + .iter() + .position(|(_, role)| role.task_id == draft.role_definition.task_id) + .unwrap_or(usize::MAX) + }); + for draft in role_brief_drafts { + let relative_path = write_agent_role_brief( + root, pass, - ); - let (markdown, tool_id, summary) = - if has_game_creator_agent_llm_override(app_config, role_definition.task_id) { - let markdown = request_agent_role_brief_with_config( - app_config, - role_definition.task_id, - &local_markdown, - ) - .await?; - ( - markdown, - format!("llm.chat.{}", role_definition.task_id), - format!( - "{} / {} 使用 agentLlm.{} 生成 brief", - definition.label, role_definition.role, role_definition.task_id - ), - ) - } else { - ( - local_markdown, - role_definition.tool_id.to_string(), - format!( - "本地编排生成 {} / {} brief", - definition.label, role_definition.role - ), - ) - }; - let relative_path = - write_agent_role_brief(root, pass, definition, *role_definition, &markdown)?; + draft.group_definition, + draft.role_definition, + &draft.markdown, + )?; let role_brief = AgentRoleBrief { - group_definition: definition, - role_definition: *role_definition, - markdown, + group_definition: draft.group_definition, + role_definition: draft.role_definition, + markdown: draft.markdown, relative_path, - memory_relative_path: agent_memory_relative_path, - status: "completed".to_string(), - tool_id, - summary, + memory_relative_path: draft.memory_relative_path, + status: draft.status, + tool_id: draft.tool_id, + summary: draft.summary, }; completed_role_context.push_str(&render_agent_role_brief_context(&role_brief)); - role_briefs.push(role_brief); + role_briefs_by_task.insert(role_brief.role_definition.task_id.to_string(), role_brief); } + completed_group_context = render_completed_agent_group_context(&role_briefs_by_task); + } + + let mut briefs = Vec::new(); + for definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + let role_briefs = definition + .roles + .iter() + .filter_map(|role_definition| role_briefs_by_task.get(role_definition.task_id).cloned()) + .collect::>(); let markdown = render_agent_group_brief_markdown(&role_briefs); let relative_path = write_agent_group_brief(root, pass, definition, &markdown)?; - completed_group_context.push_str(&format!( - "## {} / {}\n\n{}\n\n", - definition.label, - definition.role, - markdown.trim() - )); briefs.push(AgentGroupBrief { definition, markdown, @@ -921,6 +1043,128 @@ pub(crate) async fn request_agent_group_briefs_with_client( Ok(briefs) } +#[derive(Debug)] +struct AgentRoleBriefDraft { + group_definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, + markdown: String, + memory_relative_path: String, + status: String, + tool_id: String, + summary: String, +} + +fn ordered_game_creator_agent_roles( +) -> Vec<(&'static AgentGroupDefinition, &'static AgentRoleDefinition)> { + GAME_CREATOR_AGENT_GROUP_DEFINITIONS + .iter() + .flat_map(|group_definition| { + group_definition + .roles + .iter() + .map(move |role_definition| (group_definition, role_definition)) + }) + .collect() +} + +fn render_completed_agent_group_context( + role_briefs_by_task: &BTreeMap, +) -> String { + let mut output = String::new(); + for group_definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS { + let role_briefs = group_definition + .roles + .iter() + .filter_map(|role_definition| role_briefs_by_task.get(role_definition.task_id).cloned()) + .collect::>(); + if role_briefs.is_empty() { + continue; + } + output.push_str(&format!( + "## {} / {}\n\n{}\n\n", + group_definition.label, + group_definition.role, + render_agent_group_brief_markdown(&role_briefs).trim() + )); + } + output +} + +#[allow(clippy::too_many_arguments)] +async fn build_agent_role_brief_draft( + root: PathBuf, + app_config: GameCreatorAppConfig, + definition: AgentGroupDefinition, + role_definition: AgentRoleDefinition, + prompt: String, + short_memory: String, + long_memory: String, + project_blackboard: String, + spec_markdown: String, + findings_markdown: String, + agenda_markdown: String, + completed_group_context: String, + completed_role_context: String, + pass: u8, +) -> Result { + let agent_memory_relative_path = agent_role_memory_relative_path(definition, role_definition); + let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?; + let agent_conversation_context = + render_local_conversation_prompt_context(&root, Some(role_definition.task_id))?; + let role_short_memory = append_prompt_context(&agent_conversation_context, &short_memory); + let local_markdown = render_local_agent_role_brief( + definition, + role_definition, + &prompt, + &role_short_memory, + &long_memory, + &project_blackboard, + &agent_memory, + &spec_markdown, + &findings_markdown, + &agenda_markdown, + &completed_group_context, + &completed_role_context, + pass, + ); + let (markdown, tool_id, summary) = + if has_game_creator_agent_llm_override(&app_config, role_definition.task_id) { + let markdown = request_agent_role_brief_with_config( + &app_config, + role_definition.task_id, + &local_markdown, + ) + .await?; + ( + markdown, + format!("llm.chat.{}", role_definition.task_id), + format!( + "{} / {} 使用 agentLlm.{} 生成 brief", + definition.label, role_definition.role, role_definition.task_id + ), + ) + } else { + ( + local_markdown, + role_definition.tool_id.to_string(), + format!( + "本地编排生成 {} / {} brief", + definition.label, role_definition.role + ), + ) + }; + + Ok(AgentRoleBriefDraft { + group_definition: definition, + role_definition, + markdown, + memory_relative_path: agent_memory_relative_path, + status: "completed".to_string(), + tool_id, + summary, + }) +} + pub(crate) fn has_game_creator_agent_llm_override( config: &GameCreatorAppConfig, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/commands.rs b/apps/ai-game-creator-shell/src-tauri/src/commands.rs index fe45ff391..2364f2f57 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -237,6 +237,17 @@ pub(crate) async fn chat_with_game_creator_agent( chat_with_game_creator_agent_at(root, prompt.trim()).await } +#[tauri::command] +pub(crate) async fn chat_with_game_creator_role_agent( + project_path: String, + agent_id: String, + prompt: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + chat_with_game_creator_role_agent_at(root, agent_id.trim(), prompt.trim()).await +} + #[tauri::command] pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus { check_game_creator_llm_config_from_config() 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 9fa0a4677..08757cd68 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -927,6 +927,7 @@ fn main() { control_agent_run, generate_local_game_draft, chat_with_game_creator_agent, + chat_with_game_creator_role_agent, check_game_creator_llm_config, read_game_creator_app_config, write_game_creator_app_config, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tests.rs index a555886b1..14dbd1838 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -2,7 +2,7 @@ use super::*; use serde_json::Value; use std::io::{Read, Write}; use std::sync::atomic::{AtomicU64, Ordering}; -use std::sync::{Mutex as StdMutex, MutexGuard as StdMutexGuard}; +use std::sync::{Arc, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard}; use std::time::{SystemTime, UNIX_EPOCH}; use zip::write::SimpleFileOptions; @@ -589,6 +589,63 @@ fn spawn_mock_llm_server_responses_with_capture( base_url } +fn spawn_barrier_mock_llm_server( + response_content: String, + barrier: Arc<(StdMutex, Condvar)>, + expected_requests: usize, + request_sender: mpsc::Sender, +) -> String { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock llm bind"); + let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr")); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("mock llm accept"); + let mut request_buffer = [0_u8; 8192]; + let read_len = stream.read(&mut request_buffer).unwrap_or(0); + let _ = + request_sender.send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned()); + let (lock, cvar) = &*barrier; + let mut count = lock.lock().expect("barrier lock"); + *count += 1; + cvar.notify_all(); + while *count < expected_requests { + let wait_result = cvar + .wait_timeout(count, Duration::from_secs(2)) + .expect("barrier wait"); + count = wait_result.0; + if wait_result.1.timed_out() && *count < expected_requests { + let body = r#"{"error":"parallel barrier timeout"}"#; + let response = format!( + "HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("mock llm timeout response"); + return; + } + } + drop(count); + let body = serde_json::json!({ + "id": "resp_game_creator_mock", + "model": "mock-game-model", + "output_text": response_content, + "status": "completed", + "usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 } + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .expect("mock llm response"); + }); + base_url +} + fn spawn_mock_external_canvas_api_server() -> String { let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock canvas api bind"); let base_url = format!( @@ -848,10 +905,16 @@ async fn chat_with_game_creator_agent_uses_project_context_and_chat_llm_route() init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); fs::write(root.join("memory/session.md"), "短期记忆:用户偏好轻快节奏") .expect("write session memory"); - fs::write(root.join("memory/project.md"), "长期记忆:项目核心是月光厨房") - .expect("write project memory"); - fs::write(root.join(PROJECT_BLACKBOARD_MEMORY_PATH), "黑板:角色要先有规范图") - .expect("write blackboard memory"); + fs::write( + root.join("memory/project.md"), + "长期记忆:项目核心是月光厨房", + ) + .expect("write project memory"); + fs::write( + root.join(PROJECT_BLACKBOARD_MEMORY_PATH), + "黑板:角色要先有规范图", + ) + .expect("write blackboard memory"); upload_local_asset_at(&root, "hero.png", "image/png", b"fake-png").expect("asset upload"); append_local_conversation_message_at( &root, @@ -911,6 +974,81 @@ async fn chat_with_game_creator_agent_uses_project_context_and_chat_llm_route() fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn chat_with_game_creator_role_agent_uses_agent_context_and_route() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + fs::write(root.join("memory/session.md"), "短期记忆:用户偏好轻快节奏") + .expect("write session memory"); + fs::write( + root.join("memory/project.md"), + "长期记忆:项目核心是月光厨房", + ) + .expect("write project memory"); + fs::write( + root.join(PROJECT_BLACKBOARD_MEMORY_PATH), + "黑板:角色要先有规范图", + ) + .expect("write blackboard memory"); + write_local_agent_memory_at(&root, "art-director", "私有记忆:偏好厚涂剪影") + .expect("write art memory"); + append_local_conversation_message_at( + &root, + Some("art-director"), + LocalConversationMessage { + role: "user".to_string(), + content: "上一轮:美术要强化开罗风轮廓".to_string(), + agent_id: None, + }, + ) + .expect("append agent conversation"); + + let (sender, receiver) = mpsc::channel(); + let base_url = spawn_mock_llm_server_responses_with_capture( + vec!["可以,我会先收敛角色规范图的视觉方向。".to_string()], + Some(sender), + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "llm": {{ + "apiKey": "global-key", + "baseUrl": "https://global.example.test/v1", + "model": "global-model", + "apiKind": "openai_responses" + }}, + "agentLlm": {{ + "art-director": {{ + "apiKey": "art-key", + "baseUrl": {base_url:?}, + "model": "art-chat-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let reply = + chat_with_game_creator_role_agent_at(&root, "art-director", "我要生成一个开罗风格的 dota") + .await + .expect("role chat reply"); + + assert_eq!(reply.reply_text, "可以,我会先收敛角色规范图的视觉方向。"); + let request = receiver + .recv_timeout(Duration::from_secs(1)) + .expect("captured role chat llm request"); + assert!(request.contains("POST /responses HTTP/1.1")); + assert!(request.contains("art-chat-model")); + assert!(request.contains("美术组 / Director")); + assert!(request.contains("taskId=art-director")); + assert!(request.contains("私有记忆:偏好厚涂剪影")); + assert!(request.contains("上一轮:美术要强化开罗风轮廓")); + assert!(request.contains("我要生成一个开罗风格的 dota")); + assert!(!request.contains("global-model")); + assert!(!request.contains("global-key")); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn agent_loop_uses_per_agent_llm_overrides() { let root = unique_project_path(); @@ -998,6 +1136,107 @@ async fn agent_loop_uses_per_agent_llm_overrides() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn agent_role_briefs_run_same_wave_llm_agents_in_parallel() { + let root = unique_project_path(); + init_local_game_project_at(&root, "local-project-draft", "未命名游戏原型") + .expect("init project"); + let agenda_path = root.join(".agent/passes/pass-1/agenda.md"); + fs::create_dir_all(agenda_path.parent().expect("agenda parent")).expect("agenda dir"); + fs::write(&agenda_path, "# Test Agenda\n").expect("write agenda"); + + let barrier = Arc::new((StdMutex::new(0_usize), Condvar::new())); + let (sender, receiver) = mpsc::channel(); + let design_director_base_url = spawn_barrier_mock_llm_server( + "Director 并行 brief".to_string(), + Arc::clone(&barrier), + 2, + sender.clone(), + ); + let design_foundation_base_url = spawn_barrier_mock_llm_server( + "Gameplay 并行 brief".to_string(), + Arc::clone(&barrier), + 2, + sender, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "llm": {{ + "apiKey": "global-key", + "baseUrl": "https://global.example.test/v1", + "model": "global-model", + "apiKind": "openai_responses" + }}, + "agentLlm": {{ + "design-director": {{ + "apiKey": "design-director-key", + "baseUrl": {design_director_base_url:?}, + "model": "design-director-model", + "apiKind": "openai_responses" + }}, + "design-foundation": {{ + "apiKey": "design-foundation-key", + "baseUrl": {design_foundation_base_url:?}, + "model": "design-foundation-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + let app_config = load_game_creator_app_config().expect("load app config"); + let agenda = AgentPassAgenda { + relative_path: ".agent/passes/pass-1/agenda.md".to_string(), + task_graph_relative_path: ".agent/passes/pass-1/task-graph.json".to_string(), + active_task_ids: vec![ + "design-director".to_string(), + "design-foundation".to_string(), + ], + carried_task_ids: vec![], + dependency_waves: vec![vec![ + "design-director".to_string(), + "design-foundation".to_string(), + ]], + repair_focus: vec![], + repair_routes: vec![], + summary: "并行测试".to_string(), + }; + + let briefs = request_agent_group_briefs_with_client( + &root, + &app_config, + "做一个开罗风格动作游戏", + "短期记忆", + "长期记忆", + "项目黑板", + "Planner 规格", + "Evaluator 反馈", + &agenda, + 1, + ) + .await + .expect("role briefs"); + + let captured_requests = receiver + .try_iter() + .filter(|request| request.contains("POST /responses HTTP/1.1")) + .collect::>(); + assert_eq!(captured_requests.len(), 2); + assert!(captured_requests + .iter() + .any(|request| request.contains("design-director-model"))); + assert!(captured_requests + .iter() + .any(|request| request.contains("design-foundation-model"))); + let design_group = briefs + .iter() + .find(|brief| brief.definition.id == "design") + .expect("design group"); + assert!(design_group.markdown.contains("Director 并行 brief")); + assert!(design_group.markdown.contains("Gameplay 并行 brief")); + + fs::remove_dir_all(root).ok(); +} + #[test] fn llm_config_check_reports_status_without_leaking_key() { let missing = check_game_creator_llm_config_values( diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 2ab271d84..a51c313ee 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -2774,8 +2774,8 @@ export function WorkspaceLauncher({ setRecentWorkspaceStatuses({}); } - function validateAgentChatProjectPath() { - const trimmedProjectPath = agentChatProjectPath.trim(); + function validateAgentChatProjectPath(projectPath = agentChatProjectPath) { + const trimmedProjectPath = projectPath.trim(); if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) { setAgentChatStatus('请提供项目绝对路径'); return null; @@ -2787,10 +2787,12 @@ export function WorkspaceLauncher({ return trimmedProjectPath; } - function selectedLauncherAgentChatAgent(): LauncherAgentChatAgent | null { + function selectedLauncherAgentChatAgent( + agentId = agentChatSelectedAgentId, + ): LauncherAgentChatAgent | null { return ( launcherAgentChatAgents.find( - (agent) => agent.id === agentChatSelectedAgentId, + (agent) => agent.id === agentId, ) ?? launcherAgentChatAgents[0] ?? null @@ -2812,16 +2814,20 @@ export function WorkspaceLauncher({ setAgentChatStatus('已取消'); return; } - setAgentChatProjectPath(selectedPath); setAgentChatStatus('已选择项目目录'); + setAgentChatProjectPath(selectedPath); + void loadAgentChatConversation(agentChatSelectedAgentId, selectedPath); } catch (error) { setAgentChatStatus(error instanceof Error ? error.message : String(error)); } } - async function loadAgentChatConversation() { - const projectPathForChat = validateAgentChatProjectPath(); - const agent = selectedLauncherAgentChatAgent(); + async function loadAgentChatConversation( + agentId = agentChatSelectedAgentId, + projectPath = agentChatProjectPath, + ) { + const projectPathForChat = validateAgentChatProjectPath(projectPath); + const agent = selectedLauncherAgentChatAgent(agentId); if (!projectPathForChat || !agent) { return; } @@ -2896,6 +2902,18 @@ export function WorkspaceLauncher({ return; } setAgentChatMessages(savedUserResult.messages); + setAgentChatStatus('Agent 正在思考'); + const reply = await invoke( + 'chat_with_game_creator_role_agent', + { + projectPath: projectPathForChat, + agentId: agent.id, + prompt: content, + }, + ); + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } const assistantResult = await invoke( 'append_local_conversation_message', { @@ -2903,7 +2921,7 @@ export function WorkspaceLauncher({ agentId: agent.id, message: { role: 'assistant', - content: localAgentConversationReceipt(agent), + content: reply.replyText, agentId: null, }, }, @@ -2922,7 +2940,7 @@ export function WorkspaceLauncher({ if (savedUserResult) { setAgentChatMessages(savedUserResult.messages); setAgentChatStatus( - `已保存用户消息;Agent 回执失败:${ + `已保存用户消息;Agent 回复失败:${ error instanceof Error ? error.message : String(error) }`, ); @@ -3548,8 +3566,7 @@ export function WorkspaceLauncher({ } onClick={() => { setAgentChatSelectedAgentId(agent.id); - setAgentChatMessages([]); - setAgentChatStatus('已切换 Agent,请读取历史'); + void loadAgentChatConversation(agent.id); }} > {agent.title} diff --git a/apps/ai-game-creator-shell/tests/appSurface.test.ts b/apps/ai-game-creator-shell/tests/appSurface.test.ts index 781e4bc27..5e674b049 100644 --- a/apps/ai-game-creator-shell/tests/appSurface.test.ts +++ b/apps/ai-game-creator-shell/tests/appSurface.test.ts @@ -925,6 +925,11 @@ describe('AI 游戏创作 App 界面边界', () => { })), }; } + if (command === 'chat_with_game_creator_role_agent') { + return { + replyText: `Agent 回复:${String(args?.prompt ?? '')}`, + }; + } throw new Error(`unexpected invoke ${command}`); }, ); @@ -954,7 +959,7 @@ describe('AI 游戏创作 App 界面边界', () => { ).not.toBeNull(); expect( await screen.findByText( - '已记录给 拆解创作方向。下一次生成会把这条对话作为该 agent 的上下文读取。', + 'Agent 回复:请单独评估这个角色设定流程', ), ).not.toBeNull(); expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', { @@ -971,11 +976,59 @@ describe('AI 游戏创作 App 界面边界', () => { agentId: 'design-director', message: { role: 'assistant', - content: - '已记录给 拆解创作方向。下一次生成会把这条对话作为该 agent 的上下文读取。', + content: 'Agent 回复:请单独评估这个角色设定流程', agentId: null, }, }); + expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_role_agent', { + projectPath: '/tmp/authorized-game', + agentId: 'design-director', + prompt: '请单独评估这个角色设定流程', + }); + }); + + it('loads selected developer agent history immediately after switching agents', async () => { + const invoke = vi.fn( + async (command: string, args?: Record) => { + if (command === 'read_local_conversation') { + if (args?.agentId === 'art-director') { + return { + path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl', + agentId: 'art-director', + messages: [ + { + schemaVersion: '1', + role: 'assistant', + content: '美术 Agent 历史已加载', + agentId: null, + updatedAt: 1000, + }, + ], + }; + } + return { + path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl', + agentId: args?.agentId, + messages: [], + }; + } + throw new Error(`unexpected invoke ${command}`); + }, + ); + window.__TAURI__ = { core: { invoke } }; + renderLauncherAgentChatAt('/?agent-chat'); + + fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), { + target: { value: '/tmp/authorized-game' }, + }); + fireEvent.click(screen.getByRole('button', { name: /确定视觉方向/ })); + + expect(await screen.findByText('美术 Agent 历史已加载')).not.toBeNull(); + expect(screen.queryByText('暂无对话')).toBeNull(); + expect(invoke).toHaveBeenCalledWith('read_local_conversation', { + projectPath: '/tmp/authorized-game', + agentId: 'art-director', + }); }); it('refreshes recent project status before entering the project placeholder', async () => { diff --git a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md index 6f08f8f0f..4041844d9 100644 --- a/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md +++ b/docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md @@ -29,7 +29,7 @@ Agent Runtime 负责: ## Agent 能力清单 - 用户能力:聊天入口、上传文件;正式用户窗口不展示任务、文件、预览、日志、能力清单或开发专用单 Agent 聊天入口。 -- 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/.jsonl`,用于单独调试某个 Agent 的长期对话上下文。 +- 开发窗口能力:debug 构建额外打开 `developer` 窗口,走 `index.html?agent-chat`;开发者可选择 Agent、授权本地项目路径,并通过 `read_local_conversation` / `append_local_conversation_message` 读写 `.agent/conversations/agents/.jsonl`,通过 `agentLlm.` 调用该 Agent 的独立 LLM 路由做真实对话,用于单独调试某个 Agent 的长期对话上下文。 - 命令能力:内置命令调用、权限 gate、执行日志;v1 只允许白名单受限命令,不执行任意 shell。 - 编排能力:任务拆分、任务图依赖、专业组调度、多智能体协作。 - 任务图能力:每轮 Orchestrator agenda、ready / active task 选择、Evaluator 结构化返工路由、返工轮 carry-over。 @@ -160,7 +160,7 @@ game-project/ - 结构化对话记录按授权本地项目路径追加 JSONL;普通聊天、`/history`、工作区历史和单 agent 对话都读取 `.agent/conversations/`,最近 project / agent 对话可进入生成 prompt 上下文,但 v1 不提供 fork、archive 或云端同步。 - Agent 状态列表从 `.agent/manifest.json` 的任务 / 角色清单和 `.agent/run.latest.json` / `.agent/runs/.json` 的 step、taskGraph、passPlans、lifecycleStatus 派生;v1 不新增独立状态数据库,也不承诺完整后台 runner。 - App 启动先检查平台登录态;登录后进入同一个客户端首页,不再有面向用户的启动器 / 主窗口切换概念。首页按 `做游戏` / `做素材` / `做方案` 保存 `game` / `art` / `doc` 初始意图,发送时弹出原生目录选择,目标目录存在且非空时必须二次确认;确认后只调用 `init_local_game_project` 初始化本地项目、`upload_local_asset` 导入附件、`append_local_conversation_message` 记录首条需求和接收回执,再写入最近项目并切到项目开发占位页。本流程不调用 `generate_local_game_draft`、`generate_platform_art_asset` 或 LLM 聊天。 -- debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口当前只用于开发者单独选择 Agent 并持久化 `.agent/conversations/agents/.jsonl`,普通用户窗口不得出现 `Agent 聊天` 导航或入口。 +- debug 构建启动后在用户 `client` 窗口之外额外打开 `developer` 窗口;该窗口用于开发者单独选择 Agent、切换时自动读取该 Agent 历史,并把用户消息和真实 Agent 回复持久化到 `.agent/conversations/agents/.jsonl`,普通用户窗口不得出现 `Agent 聊天` 导航或入口。 - 首页最近项目只展示最近 3 个有效项目;项目组页在同一窗口管理最近项目、打开项目、新建项目和显示目录。打开项目只读取已初始化项目并切到项目开发占位页,不打开第二窗口;新建项目仍沿用非空目录确认,不自动重建无效历史路径。 - 项目开发占位页保留左侧栏和顶部栏,展示项目名、路径、创建模式、首条需求、附件导入结果、最近 run 状态和后续“项目开发画布”占位;本轮不落地真正画板 + Agent 双栏。 @@ -259,12 +259,12 @@ game-project/ - 主窗口“配置”面板和聊天 `/config` 命令读写 Tauri 应用配置目录中的 `game-creator.config.json`,覆盖 LLM API Key、base URL、模型、API 类型、流式请求、超时、重试和画板 External API 配置;主聊天 Agent、Planner、Orchestrator、Generator、Evaluator 和 16 个角色 agent 都可在 `agentLlm` 中单独覆盖 API Key、base URL、模型、API 类型和流式请求,空项继承全局 LLM 配置;主聊天 Agent 使用 `agentLlm.chat`,默认生成链路仍只让 Planner / Generator 调 LLM,配置了 `agentLlm.` 的角色 agent 会改用自己的 provider 生成 brief,未配置的角色 agent 继续使用本地 brief;生成游戏、主聊天或平台美术时如果返回 LLM / editorApi 缺配置错误,主窗口自动打开同一个运行时配置弹窗;API Key 输入框使用密码字段并关闭自动填充,数值项在 UI 层夹住下限,Rust 写配置时也拒绝过低超时,保存时只写运行时配置文件,不写仓库模板、本地项目、trace 或 manifest。 - 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认全局 LLM 以及各 agent resolved 后的 base_url、model、API 类型和 API Key 是否已从客户端配置读取;状态消息不会显示或保存 API Key;当前生成链路只要求 Planner / Generator 就绪。`/llm-routes` 复用同一检查结果,但输出按 agent 展开的路由清单和缺口摘要,用于确认哪些 agent 解析后走全局路由、哪些 agent 走单独 provider。 - `game.generate_draft` 的 LLM JSON 必须包含 `handoffs` 数组,覆盖 `design`、`balance`、`art`、`audio`、`code`、`publishing` 6 个专业组;每组必须给出 role、summary、outputs 和 next,缺组或交接内容不完整会判定为模型输出无效并进入返工。 -- `game.generate_draft` 的真实生成路径使用最小 Planner / Orchestrator / 组内角色 agent / Generator / Evaluator loop:Planner 写 `.agent/spec.md`;每轮 Orchestrator 先写 `.agent/passes/pass-N/agenda.md` 和 `.agent/passes/pass-N/task-graph.json`,首轮全量调度 16 个角色任务,返工轮按 `.agent/findings.md` 生成结构化 `repairRoutes`,重跑命中问题的角色任务及其下游依赖任务,其余角色 brief 从上一轮 carry-over;`task-graph.json` 记录 activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和按依赖排序的 dependencyWaves;每个角色 brief 必须读取自己的私有记忆 `memory/agents//.md` 和项目黑板 `memory/blackboard.md`,写入 `.agent/passes/pass-N/groups//*.md`,再汇总为 `.agent/passes/pass-N/groups/*.md`;Generator 必须读取用户需求、记忆、`.agent/spec.md`、本轮 `agenda.md`、`task-graph.json`、`.agent/findings.md` 和 6 组汇总 brief 后返回结构化 JSON;每轮会把 Generator 草案拆成 6 组交接快照,写入 `.agent/passes/pass-N/`;Evaluator 做质量评审并写 `.agent/findings.md`,通过后才进入 `game.static_smoke` 静态自检和预览试玩。 +- `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 执行层按 dependencyWaves 调度,wave 之间串行、同一 wave 内多个角色 Agent 以 async task 并行运行;每个角色 brief 必须读取自己的私有记忆 `memory/agents//.md`、本 Agent 对话和项目黑板 `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/conversations/project.jsonl`、`.agent/conversations/agents/`、`.agent/manifest.json` 和 agenda 等上下文来源,其中角色 agent 必须包含自己的 `memory/agents//.md` 和 `memory/blackboard.md`;conversation 输入只取最近少量 project / agent 对话摘要,不读取全量历史;每个 step 必须带 phase、taskId、group 和 role,`.agent/run.latest.json.taskGraph` 必须记录 goal、readyTaskIds、activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和当前任务状态;`.agent/run.latest.json.passPlans` 必须按轮记录 mode、summary、activeTaskIds、carriedTaskIds、dependencyWaves、repairFocus 和 repairRoutes,作为 `/trace` 与开发窗口判断编排 loop 是否真实发生的直接证据;`run.latest.json` 是当前指针,`.agent/runs/` 保留历史 run trace;开发窗口读取 latest 展示编排过程,并复用 `file.list/read` 按文件修改时间先载入最近 20 个历史 run,滚动时再按批次读取剩余历史,普通用户窗口不展示。 - `.agent/run.latest.json` 的 `artifacts` 使用结构化记录,包含相对路径、字节数和 `fnv1a64:` checksum;除最终本地产物外,也会收集 `.agent/passes/pass-N/` 快照,便于确认返工前后的产物差异。 - 通过 Evaluator 和 `game.static_smoke` 后,Agent loop 会把本次 runId、状态、轮次、下一步、active / carry-over 任务和最终本地产物摘要追加到 `memory/session.md` 与 `memory/project.md`,把重要跨 agent 决策 / 依赖 / 风险摘要追加到 `memory/blackboard.md`,并把各角色本轮成功产出的角色摘要追加到 `memory/agents//.md`;下一次 Planner、组内角色和 Generator 会通过记忆输入自然读取上一轮稳定原型状态,而不只依赖开发窗口 trace。 -- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再追加一条本地 agent 回执,二者都写入对应 `.agent/conversations/agents/.jsonl`。单 agent 面板可把当前输入手动追加到对应 `memory/agents//.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents//.md`;v1 不把本地回执伪装成实时 LLM 回复。 +- 单 agent 对话入口读取对应 agent conversation;用户提交后先追加用户消息,再调用 `chat_with_game_creator_role_agent` 让对应 `agentLlm.` 结合项目上下文、Agent 私有记忆和本 Agent 历史对话生成回复,随后把回复写入对应 `.agent/conversations/agents/.jsonl`。单 agent 面板可把当前输入手动追加到对应 `memory/agents//.md`,写入前复用 `memory.write` 项目策略和本地项目锁;最近对话可作为本次生成 prompt 上下文读取,但只有经过显式总结、用户显式手动沉淀或生成 loop 成功沉淀的稳定结论,才追加到 `memory/blackboard.md` 或 `memory/agents//.md`。 - `.agent/agent.db` 当前作为最小本地项目索引文件使用 JSONL:初始化写入 `project.init`,每次 `game.generate_draft` 追加目标、标题、本地产物路径、checkpoint 和 diff 摘要,上传 / 登记 / 画板导入资产时追加 `asset.register` 或 `asset.update`;v1 不引入 SQLite 依赖。 - `game.generate_draft`、资产登记 / 导入、记忆写入、预览状态写入、checkpoint / restore、agent 生命周期控制、画板资源回流 / 生成和 policy 写入会先按 `.agent/policy.json` 判断本次命令是否被项目策略拒绝,再拿项目级 `.agent/project.lock` 串行化;锁只保护同一本地项目,v1 不做后台锁管理。`confirmCommands` 可把索引、状态读取、资产登记、checkpoint、预览、agent 生命周期、画板资源回流 / 生成、memory 读写删除和 conversation 读写等命令转成项目策略确认,命中时用户确认后才执行;用户可用 `/policy-confirm 命令` 加入确认列表,用 `/policy-auto 命令` 移除确认项。 - `ArtifactWriter` 写入最终产物前把当前项目文件保存到 `.agent/checkpoints//`,写入后把新增、修改、删除计数记录到 `.agent/agent.db`;聊天命令 `/checkpoint`、`/checkpoints`、`/diff checkpoint-id` 和 `/restore checkpoint-id` 允许用户手动保存、列出最近 checkpoint、对比和确认回滚到 checkpoint,回滚时会删除 checkpoint 后新增的受跟踪项目文件。