From 83901d5e78b0c87e72b63eddb88b30f3b48f95fc Mon Sep 17 00:00:00 2001 From: AIGameCreator App Date: Thu, 9 Jul 2026 22:53:08 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90Agent=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E4=BB=BB=E5=8A=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增单Agent后台任务命令,按Agent独立runtime锁并行运行。 开发单聊和项目Agent对话弹窗增加后台运行入口。 后台任务结果写回Agent对话、runtime事件和agent.db审计记录。 补充并行后台任务测试和实施计划决策文档。 --- .../src-tauri/src/agent.rs | 216 ++++++++++++++++++ .../src-tauri/src/commands.rs | 14 ++ .../src-tauri/src/main.rs | 1 + .../src-tauri/src/project.rs | 4 +- .../src-tauri/src/tests.rs | 132 +++++++++++ apps/ai-game-creator-shell/src/App.tsx | 191 ++++++++++++++++ apps/ai-game-creator-shell/src/styles.css | 4 +- .../tests/appSurface.test.ts | 135 +++++++++++ .../shared-memory/decision-log.md | 8 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 4 + 10 files changed, 705 insertions(+), 4 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 f579a08f0..c8f68b8ba 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -222,6 +222,149 @@ pub(crate) fn read_game_creator_agent_runtime_at( }) } +pub(crate) fn start_game_creator_agent_background_task_at( + root: &Path, + agent_id: &str, + task: &str, + run_id: &str, +) -> Result { + let agent_id = normalize_game_creator_runtime_agent_id(agent_id)?.to_string(); + validate_project_root(root)?; + let task = task.trim(); + if task.is_empty() { + return Err("Agent 后台任务不能为空".to_string()); + } + let _runtime_lock = acquire_game_creator_agent_runtime_task_lock(root, &agent_id)?; + let state = start_game_creator_agent_runtime_task_at( + root, + &agent_id, + task, + run_id, + "agent-background-task", + "后台任务已投递", + vec![ + "记录开发者投递的后台任务".to_string(), + "独立读取项目上下文和本 Agent 记忆".to_string(), + "调用当前 Agent LLM 路由完成推理".to_string(), + "把结果写回 Agent 对话、runtime 状态和事件流".to_string(), + ], + )?; + append_local_conversation_message_at( + root, + Some(&agent_id), + LocalConversationMessage { + role: "user".to_string(), + content: task.to_string(), + agent_id: None, + }, + )?; + append_agent_db_record( + root, + serde_json::json!({ + "recordType": "agent.runtime.background_task", + "agentId": state.agent_id, + "taskId": state.task_id, + "sessionId": state.session_id, + "runId": state.run_id, + "source": state.source, + "status": state.status, + "phase": state.phase, + "task": state.current_task, + }), + )?; + + let result = read_game_creator_agent_runtime_at(root, &agent_id)?; + let root = root.to_path_buf(); + let background_agent_id = agent_id.clone(); + let background_task = task.to_string(); + tauri::async_runtime::spawn(async move { + let _runtime_lock = _runtime_lock; + run_game_creator_agent_background_task(root, background_agent_id, background_task, state) + .await; + }); + + Ok(result) +} + +async fn run_game_creator_agent_background_task( + root: PathBuf, + agent_id: String, + task: String, + state: AgentRuntimeState, +) { + let mut runtime = match advance_game_creator_agent_runtime_turn_at( + &root, + state, + "llm", + "后台请求 Agent LLM", + "后台任务已开始执行,正在让 Agent 独立推理。", + ) { + Ok(runtime) => runtime, + Err(error) => { + let fallback = default_game_creator_agent_runtime_state(&agent_id, ""); + let _ = fail_game_creator_agent_runtime_turn_at(&root, fallback, &error); + return; + } + }; + + match chat_with_game_creator_role_agent_at(&root, &agent_id, &task).await { + Ok(reply) => { + if let Ok(completed_runtime) = + finish_game_creator_agent_runtime_turn_at(&root, runtime.clone(), &reply.reply_text) + { + runtime = completed_runtime; + } + let _ = append_local_conversation_message_at( + &root, + Some(&agent_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: reply.reply_text.clone(), + agent_id: None, + }, + ); + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.completed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "responsePreview": runtime.last_response, + }), + ); + } + Err(error) => { + let failed_runtime = fail_game_creator_agent_runtime_turn_at(&root, runtime, &error); + let _ = append_local_conversation_message_at( + &root, + Some(&agent_id), + LocalConversationMessage { + role: "assistant".to_string(), + content: format!("后台任务失败:{error}"), + agent_id: None, + }, + ); + if let Ok(runtime) = failed_runtime { + let _ = append_agent_db_record( + &root, + serde_json::json!({ + "recordType": "agent.runtime.background_task.failed", + "agentId": runtime.agent_id, + "taskId": runtime.task_id, + "sessionId": runtime.session_id, + "runId": runtime.run_id, + "source": runtime.source, + "error": runtime.error, + }), + ); + } + } + } +} + pub(crate) fn start_game_creator_agent_runtime_turn_at( root: &Path, agent_id: &str, @@ -526,6 +669,79 @@ fn game_creator_agent_runtime_event_path(root: &Path, agent_id: &str) -> PathBuf .join(format!("{agent_id}.jsonl")) } +#[derive(Debug)] +struct AgentRuntimeTaskLock { + path: PathBuf, +} + +impl Drop for AgentRuntimeTaskLock { + fn drop(&mut self) { + let _ = fs::remove_file(&self.path); + } +} + +fn acquire_game_creator_agent_runtime_task_lock( + root: &Path, + agent_id: &str, +) -> Result { + let path = root + .join(".agent") + .join("runtime") + .join("locks") + .join(format!("{agent_id}.lock")); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 Agent Runtime 锁目录失败:{}: {error}", + parent.display() + ) + })?; + } + let payload = serde_json::json!({ + "agentId": agent_id, + "pid": std::process::id(), + "createdAt": unix_timestamp(), + }); + let content = serde_json::to_string_pretty(&payload) + .map_err(|error| format!("生成 Agent Runtime 锁失败:{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!("写入 Agent Runtime 锁失败:{}: {error}", path.display()) + })?; + Ok(AgentRuntimeTaskLock { path }) + } + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => { + if read_game_creator_agent_runtime_at(root, agent_id) + .map(|result| result.state.status == "running") + .unwrap_or(false) + { + return Err(format!("Agent {agent_id} 已有后台任务运行中")); + } + let _ = fs::remove_file(&path); + let mut file = fs::OpenOptions::new() + .create_new(true) + .write(true) + .open(&path) + .map_err(|error| { + format!("创建 Agent Runtime 锁失败:{}: {error}", path.display()) + })?; + file.write_all(content.as_bytes()).map_err(|error| { + format!("写入 Agent Runtime 锁失败:{}: {error}", path.display()) + })?; + Ok(AgentRuntimeTaskLock { path }) + } + Err(error) => Err(format!( + "创建 Agent Runtime 锁失败:{}: {error}", + path.display() + )), + } +} + fn write_game_creator_agent_runtime_state( root: &Path, state: &AgentRuntimeState, 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 efa0c2947..a08318348 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -378,6 +378,20 @@ pub(crate) async fn chat_with_game_creator_role_agent_stream( } } +#[tauri::command] +pub(crate) fn start_game_creator_agent_runtime_task( + project_path: String, + agent_id: String, + task: String, + run_id: String, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + enforce_project_permission_policy(root, "conversation.write")?; + enforce_project_permission_policy(root, "agent.run_status")?; + start_game_creator_agent_background_task_at(root, agent_id.trim(), task.trim(), run_id.trim()) +} + #[tauri::command] pub(crate) fn read_game_creator_agent_runtime( project_path: String, 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 d7a36dabf..8c2f4d314 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1023,6 +1023,7 @@ fn main() { chat_with_game_creator_agent, chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, + start_game_creator_agent_runtime_task, read_game_creator_agent_runtime, check_game_creator_llm_config, read_game_creator_app_config, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project.rs b/apps/ai-game-creator-shell/src-tauri/src/project.rs index 6f162835c..c3ff69d3d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project.rs @@ -90,9 +90,9 @@ pub(crate) fn append_agent_db_record( .append(true) .open(&path) .map_err(|error| format!("打开 Agent 本地索引失败:{}: {error}", path.display()))?; - serde_json::to_writer(&mut file, &record) + let line = serde_json::to_string(&record) .map_err(|error| format!("序列化 Agent 本地索引失败:{error}"))?; - file.write_all(b"\n") + file.write_all(format!("{line}\n").as_bytes()) .map_err(|error| format!("写入 Agent 本地索引失败:{}: {error}", path.display())) } 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 700820d30..3d6fabcce 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests.rs @@ -1256,6 +1256,138 @@ async fn role_agent_runtime_turn_persists_session_events_and_index() { fs::remove_dir_all(root).ok(); } +#[tokio::test] +async fn background_agent_runtime_tasks_can_run_in_parallel_and_persist_replies() { + let root = unique_project_path(); + init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init"); + + let barrier = Arc::new((StdMutex::new(0_usize), Condvar::new())); + let (sender, receiver) = mpsc::channel(); + let art_base_url = spawn_barrier_mock_llm_server( + "美术后台任务完成:先给主角规范图。".to_string(), + barrier.clone(), + 2, + sender.clone(), + ); + let design_base_url = spawn_barrier_mock_llm_server( + "策划后台任务完成:先收敛核心循环。".to_string(), + barrier, + 2, + sender, + ); + let _config_guard = write_test_local_config(format!( + r#"{{ + "agentLlm": {{ + "art-director": {{ + "apiKey": "art-key", + "baseUrl": {art_base_url:?}, + "model": "art-background-model", + "apiKind": "openai_responses" + }}, + "design-director": {{ + "apiKey": "design-key", + "baseUrl": {design_base_url:?}, + "model": "design-background-model", + "apiKind": "openai_responses" + }} + }} +}}"# + )); + + let art_started = start_game_creator_agent_background_task_at( + &root, + "art-director", + "后台准备主角规范图", + "art-background-run", + ) + .expect("start art background task"); + let design_started = start_game_creator_agent_background_task_at( + &root, + "design-director", + "后台整理玩法循环", + "design-background-run", + ) + .expect("start design background task"); + + assert_eq!(art_started.state.status, "running"); + assert_eq!(art_started.state.source, "agent-background-task"); + assert_eq!(design_started.state.status, "running"); + assert_eq!(design_started.state.source, "agent-background-task"); + + let first_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("first background llm request"); + let second_request = receiver + .recv_timeout(Duration::from_secs(2)) + .expect("second background llm request"); + let combined_requests = format!("{first_request}\n{second_request}"); + assert!(combined_requests.contains("后台准备主角规范图")); + assert!(combined_requests.contains("后台整理玩法循环")); + + let mut art_runtime = read_game_creator_agent_runtime_at(&root, "art-director") + .expect("read art runtime") + .state; + let mut design_runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read design runtime") + .state; + for _ in 0..50 { + if art_runtime.status == "idle" && design_runtime.status == "idle" { + break; + } + std::thread::sleep(Duration::from_millis(20)); + art_runtime = read_game_creator_agent_runtime_at(&root, "art-director") + .expect("read art runtime") + .state; + design_runtime = read_game_creator_agent_runtime_at(&root, "design-director") + .expect("read design runtime") + .state; + } + + assert_eq!(art_runtime.status, "idle"); + assert_eq!(art_runtime.phase, "completed"); + assert_eq!( + art_runtime.last_response.as_deref(), + Some("美术后台任务完成:先给主角规范图。") + ); + assert_eq!(design_runtime.status, "idle"); + assert_eq!(design_runtime.phase, "completed"); + assert_eq!( + design_runtime.last_response.as_deref(), + Some("策划后台任务完成:先收敛核心循环。") + ); + + let art_conversation = + read_local_conversation_at(&root, Some("art-director")).expect("art conversation"); + assert!(art_conversation + .messages + .iter() + .any(|message| message.role == "user" && message.content == "后台准备主角规范图")); + assert!(art_conversation.messages.iter().any(|message| { + message.role == "assistant" && message.content.contains("美术后台任务完成") + })); + let design_conversation = + read_local_conversation_at(&root, Some("design-director")).expect("design conversation"); + assert!(design_conversation + .messages + .iter() + .any(|message| message.role == "user" && message.content == "后台整理玩法循环")); + assert!(design_conversation.messages.iter().any(|message| { + message.role == "assistant" && message.content.contains("策划后台任务完成") + })); + + let agent_db = fs::read_to_string(root.join(".agent/agent.db")).expect("agent db"); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.background_task\"")); + assert!(agent_db.contains("\"recordType\":\"agent.runtime.background_task.completed\"")); + assert!(agent_db.contains("\"agentId\":\"art-director\"")); + assert!(agent_db.contains("\"agentId\":\"design-director\"")); + assert!(!root.join(".agent/runtime/locks/art-director.lock").exists()); + assert!(!root + .join(".agent/runtime/locks/design-director.lock") + .exists()); + + fs::remove_dir_all(root).ok(); +} + #[tokio::test] async fn agent_loop_uses_per_agent_llm_overrides() { 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 8a9f5897f..f0b3adc5f 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -2314,6 +2314,7 @@ export function WorkspaceLauncher({ const [agentChatInput, setAgentChatInput] = useState(''); const [agentChatStatus, setAgentChatStatus] = useState('请选择项目和 Agent'); const [agentChatBusy, setAgentChatBusy] = useState(false); + const [agentChatBackgroundBusy, setAgentChatBackgroundBusy] = useState(false); const [agentChatLlmConfigStatus, setAgentChatLlmConfigStatus] = useState(null); const [agentChatLlmStatus, setAgentChatLlmStatus] = @@ -3288,6 +3289,68 @@ export function WorkspaceLauncher({ } } + async function handleAgentChatStartBackgroundTask() { + const projectPathForChat = validateAgentChatProjectPath(); + const agent = selectedLauncherAgentChatAgent(); + const content = agentChatInput.trim(); + if (!projectPathForChat || !agent || !content || agentChatBackgroundBusy) { + return; + } + const llmWarning = getCurrentAgentChatLlmWarning(agent); + if (llmWarning) { + setAgentChatStatus(llmWarning); + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + setAgentChatStatus('需要在 Tauri App 内运行'); + return; + } + const saveVersion = agentChatLoadVersionRef.current + 1; + agentChatLoadVersionRef.current = saveVersion; + setAgentChatBackgroundBusy(true); + setAgentChatInput(''); + setAgentChatStatus('正在启动 Agent 后台任务'); + try { + const runtime = await invoke( + 'start_game_creator_agent_runtime_task', + { + projectPath: projectPathForChat, + agentId: agent.id, + task: content, + runId: createAgentChatRunId('launcher-agent-task'), + }, + ); + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatRuntime(runtime.state); + setAgentChatRuntimeError(''); + const conversation = await invoke( + 'read_local_conversation', + { + projectPath: projectPathForChat, + agentId: agent.id, + }, + ); + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatMessages(conversation.messages); + setAgentChatStatus(`已启动后台任务:${runtime.state.runId}`); + } catch (error) { + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + setAgentChatInput(content); + setAgentChatStatus(error instanceof Error ? error.message : String(error)); + } finally { + if (agentChatLoadVersionRef.current === saveVersion) { + setAgentChatBackgroundBusy(false); + } + } + } + const projectRows = recentWorkspaces.map((workspace) => { const directoryStatus = recentWorkspaceStatuses[workspace]; const isPendingStatus = directoryStatus === undefined; @@ -3989,6 +4052,16 @@ export function WorkspaceLauncher({ > 发送 + @@ -11320,6 +11393,8 @@ export function App() { const [agentConversationVisibleCount, setAgentConversationVisibleCount] = useState(CONVERSATION_INITIAL_VISIBLE_COUNT); const [agentConversationSaving, setAgentConversationSaving] = useState(false); + const [agentConversationBackgroundBusy, setAgentConversationBackgroundBusy] = + useState(false); const [agentMemoryStatus, setAgentMemoryStatus] = useState('未读取'); const [agentMemoryContent, setAgentMemoryContent] = useState(''); const [messages, setMessages] = useState( @@ -11354,6 +11429,7 @@ export function App() { const latestMessagesRef = useRef([]); const conversationWriteInFlightRef = useRef(false); const agentConversationSavingRef = useRef(false); + const agentConversationBackgroundBusyRef = useRef(false); const agentConversationLoadVersionRef = useRef(0); const agentRunHistoryLoadingMoreRef = useRef(false); const initialProjectOpenedRef = useRef(false); @@ -12438,6 +12514,7 @@ export function App() { function closeAgentConversation() { agentConversationLoadVersionRef.current += 1; agentConversationSavingRef.current = false; + agentConversationBackgroundBusyRef.current = false; setSelectedAgent(null); setAgentConversationInput(''); setAgentConversationMessages([]); @@ -12445,6 +12522,7 @@ export function App() { setAgentConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setAgentConversationStatus('未选择 agent'); setAgentConversationSaving(false); + setAgentConversationBackgroundBusy(false); setAgentMemoryStatus('未读取'); setAgentMemoryContent(''); } @@ -12762,6 +12840,100 @@ export function App() { } } + async function startSelectedAgentBackgroundTask( + agent: AgentStatusCard, + content: string, + skipPolicyConfirm = false, + ) { + if (!agent || !content || agentConversationBackgroundBusyRef.current) { + return; + } + const invoke = resolveTauriInvoke(); + const nextProjectPath = resolveChatProjectPath(localProject); + if (!invoke) { + setAgentConversationStatus('需要在 Tauri App 内运行'); + return; + } + if (!nextProjectPath) { + setAgentConversationStatus('请先初始化本地项目'); + return; + } + const llmWarning = formatAgentLlmConfigWarning(llmConfigStatus, agent); + if (llmWarning) { + setAgentConversationStatus(llmWarning); + return; + } + const saveVersion = agentConversationLoadVersionRef.current; + try { + if ( + !skipPolicyConfirm && + (await queueProjectPolicyConfirmationIfNeeded( + invoke, + 'conversation.write', + nextProjectPath, + `启动 ${agent.title} 后台任务`, + '准备启动 Agent 后台任务。', + () => void startSelectedAgentBackgroundTask(agent, content, true), + )) + ) { + setAgentConversationStatus('等待确认'); + return; + } + } catch (error) { + setAgentConversationStatus( + error instanceof Error ? error.message : String(error), + ); + return; + } + agentConversationBackgroundBusyRef.current = true; + setAgentConversationBackgroundBusy(true); + setAgentConversationInput(''); + setAgentConversationStatus('正在启动 Agent 后台任务'); + try { + const runtime = await invoke( + 'start_game_creator_agent_runtime_task', + { + projectPath: nextProjectPath, + agentId: agent.id, + task: content, + runId: createAgentChatRunId('agent-background-task'), + }, + ); + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + setAgentConversationRuntime(runtime.state); + setAgentConversationRuntimeError(''); + const conversation = await invoke( + 'read_local_conversation', + { + projectPath: nextProjectPath, + agentId: agent.id, + }, + ); + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + setAgentConversationMessages(conversation.messages); + setAgentConversationStatus(`已启动后台任务:${runtime.state.runId}`); + setCommandLog((current) => [ + ...current, + 'agent.runtime.background_task', + ]); + } catch (error) { + if (agentConversationLoadVersionRef.current !== saveVersion) { + return; + } + setAgentConversationInput(content); + setAgentConversationStatus( + error instanceof Error ? error.message : String(error), + ); + } finally { + agentConversationBackgroundBusyRef.current = false; + setAgentConversationBackgroundBusy(false); + } + } + async function saveSelectedAgentPrivateMemory( agent: AgentStatusCard, content: string, @@ -12875,6 +13047,15 @@ export function App() { void saveSelectedAgentPrivateMemory(agent, content); } + function handleAgentBackgroundTaskSubmit() { + const agent = selectedAgent; + const content = agentConversationInput.trim(); + if (!agent || !content || agentConversationBackgroundBusyRef.current) { + return; + } + void startSelectedAgentBackgroundTask(agent, content); + } + function showChatHelp() { setCommandLog((current) => [...current, 'help.show']); setMessages((current) => [ @@ -19992,6 +20173,16 @@ export function App() { > 发送 +