From d8a7e814f20c4a2b9d24dad2deb532de4306978e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 23 Aug 2026 01:54:05 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=9E=E7=8E=B0AGC=E7=9B=B4=E8=BF=9E?= =?UTF-8?q?=E5=A2=9E=E9=87=8F=E8=BF=9B=E5=BA=A6=E5=8F=8D=E9=A6=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 direct turn update 事件与安全活动、正文增量、序号及终态生命周期 接入 Codex app-server 活动观察并限制高频活动通知 在 AGC 项目聊天渲染过程卡并保护项目与回合隔离、乱序门禁和自动跟随 补齐 direct 增量反馈的前端、Rust 回归测试与 Direct Interaction Event v1 文档 --- .../src-tauri/src/agent/codex_app_server.rs | 287 +++++++++++++++-- .../src-tauri/src/agent/direct_runtime.rs | 197 ++++++++++-- .../src/agent/runtime_driver/entrypoints.rs | 67 ++++ .../src-tauri/src/main.rs | 12 + apps/ai-game-creator-shell/src/App.tsx | 288 +++++++++++++++--- apps/ai-game-creator-shell/src/app/types.ts | 46 +-- .../ProjectSupervisorView.tsx | 30 +- .../SupervisorChatOnlyView.tsx | 54 ++-- apps/ai-game-creator-shell/src/styles.css | 88 +++++- .../tests/appSurface/home.suite.ts | 6 +- .../appSurface/project-development.suite.ts | 255 +++++++++++++++- ...½计划】AGC直连Codex Runtime迁移-2026-08-15.md | 52 +++- 12 files changed, 1222 insertions(+), 160 deletions(-) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs index 32487264b..6a8cb991b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server.rs @@ -21,12 +21,16 @@ const GAME_CREATOR_CODEX_APP_SERVER_RPC_TIMEOUT_MS: u64 = 30_000; const DIRECT_PROJECT_IDLE_TIMEOUT_MS: u64 = 15 * 60 * 1_000; const DIRECT_PROJECT_ACTIVE_MCP_TOOL_TIMEOUT_MS: u64 = 110 * 60 * 1_000; const DIRECT_PROJECT_TURN_HARD_TIMEOUT_MS: u64 = 120 * 60 * 1_000; +const DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL: std::time::Duration = + std::time::Duration::from_millis(250); pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_TERMINAL_UNKNOWN_PREFIX: &str = "codex-app-server-terminal-unknown:"; pub(in crate::agent) const GAME_CREATOR_CODEX_APP_SERVER_ERROR_KIND_PREFIX: &str = "codex-app-server-error:"; -const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str = +const CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK: &str = "You are Codex working directly in the user's Genarrative game project. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence."; +const DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK: &str = + "You are Taonier (陶泥儿), Genarrative's game-creation assistant. Present yourself to users as 陶泥儿; do not use Codex, ChatGPT, OpenAI, model, or generic AI assistant as your name or public identity. Codex app-server is only an internal execution technology: mention it only when the user explicitly asks about the underlying implementation, while still identifying yourself as 陶泥儿. Follow the AGC system instructions, inspect and modify files in the current workspace when needed, and report concrete progress and failures. Do not invent completion evidence."; type RpcResult = Result; @@ -305,6 +309,7 @@ impl From<&AgentRuntimeProviderRequestSnapshot> for CodexNodeThreadKey { #[derive(Clone, Debug)] enum CodexTurnEvent { AgentMessageDelta(String), + Activity(&'static str), Item { completed: bool, params: serde_json::Value, @@ -313,6 +318,12 @@ enum CodexTurnEvent { TransportClosed(String), } +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum DirectCodexTurnObservation { + AccumulatedText(String), + Activity(&'static str), +} + #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum CodexAppServerWorkspaceMode { /// The legacy AGC ToolHost path: use a throwaway workspace and keep the @@ -380,6 +391,51 @@ fn update_active_direct_mcp_tool_calls( } } +fn direct_codex_safe_activity_for_item(item_type: &str) -> &'static str { + match item_type { + "fileChange" => "file-change", + "commandExecution" => "validation", + "mcpToolCall" => "controlled-tool", + "contextCompaction" | "webSearch" => "project-inspection", + "agentMessage" => "response-finalization", + "userMessage" | "plan" | "reasoning" => "understanding", + _ => "understanding", + } +} + +fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static str> { + match method { + "turn/started" + | "turn/plan/updated" + | "item/plan/delta" + | "item/reasoning/summaryTextDelta" + | "item/reasoning/summaryPartAdded" + | "item/reasoning/textDelta" => Some("understanding"), + "item/mcpToolCall/progress" | "serverRequest/resolved" => Some("controlled-tool"), + "item/fileChange/outputDelta" | "item/fileChange/patchUpdated" => Some("file-change"), + "command/exec/outputDelta" + | "process/outputDelta" + | "item/commandExecution/outputDelta" + | "model/verification" => Some("validation"), + _ => None, + } +} + +fn should_emit_direct_codex_activity( + last_activity: &mut Option<(&'static str, std::time::Instant)>, + activity: &'static str, +) -> bool { + let now = std::time::Instant::now(); + if last_activity.is_some_and(|(previous, observed_at)| { + previous == activity + && now.saturating_duration_since(observed_at) < DIRECT_CODEX_ACTIVITY_EMIT_MIN_INTERVAL + }) { + return false; + } + *last_activity = Some((activity, now)); + true +} + fn game_creator_codex_app_server_idle_timeout_ms( workspace_mode: CodexAppServerWorkspaceMode, request_timeout_ms: u64, @@ -1388,7 +1444,7 @@ impl CodexAppServerConnection { let base_instructions = if self.inner.workspace_mode.uses_direct_conversation() { direct_codex_base_instructions(&request) } else { - DIRECT_CODEX_BASE_INSTRUCTIONS_FALLBACK.to_string() + CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK.to_string() }; let params = codex_app_server_thread_start_params( model, @@ -1464,11 +1520,23 @@ impl CodexAppServerConnection { } async fn run_turn( + &self, + snapshot: &AgentRuntimeProviderRequestSnapshot, + llm: &GameCreatorLlmConfig, + request: LlmRunRequest, + on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, + ) -> Result { + self.run_turn_with_direct_observer(snapshot, llm, request, on_agent_message_delta, None) + .await + } + + async fn run_turn_with_direct_observer( &self, snapshot: &AgentRuntimeProviderRequestSnapshot, llm: &GameCreatorLlmConfig, request: LlmRunRequest, mut on_agent_message_delta: Option<&mut (dyn FnMut(&platform_llm::LlmStreamDelta) + Send)>, + mut direct_observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, ) -> Result { let _turn_guard = self.inner.turn_gate.lock().await; let thread_lease = self.thread_for(snapshot, &request, llm).await?; @@ -1587,6 +1655,11 @@ impl CodexAppServerConnection { match event { Some(CodexTurnEvent::AgentMessageDelta(delta)) => { streamed_text.push_str(&delta); + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::AccumulatedText( + streamed_text.clone(), + )); + } if let Some(callback) = on_agent_message_delta.as_deref_mut() { callback(&platform_llm::LlmStreamDelta { accumulated_text: streamed_text.clone(), @@ -1595,12 +1668,22 @@ impl CodexAppServerConnection { }); } } + Some(CodexTurnEvent::Activity(activity)) => { + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::Activity(activity)); + } + } Some(CodexTurnEvent::Item { completed, params }) => { if let Some(item) = params.get("item") { let item_type = item .get("type") .and_then(serde_json::Value::as_str) .unwrap_or_default(); + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::Activity( + direct_codex_safe_activity_for_item(item_type), + )); + } if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { @@ -1839,6 +1922,8 @@ async fn read_game_creator_codex_app_server_stdout( stdout: tokio::process::ChildStdout, ) { let mut reader = BufReader::new(stdout); + let mut last_direct_activity_by_turn = + HashMap::>::new(); loop { let mut buffer = match read_bounded_game_creator_codex_app_server_line(&mut reader).await { Ok(Some(buffer)) => buffer, @@ -2028,10 +2113,12 @@ async fn read_game_creator_codex_app_server_stdout( .get("method") .and_then(serde_json::Value::as_str) .unwrap_or_default(); + let safe_activity = direct_codex_safe_activity_for_notification(method); if !matches!( method, "item/agentMessage/delta" | "item/started" | "item/completed" | "turn/completed" - ) { + ) && safe_activity.is_none() + { continue; } let params = message @@ -2050,24 +2137,37 @@ async fn read_game_creator_codex_app_server_stdout( let Some(turn_id) = turn_id else { continue; }; - let event = match method { - "item/agentMessage/delta" => { - let Some(delta) = params - .get("delta") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - else { - continue; - }; - CodexTurnEvent::AgentMessageDelta(delta.to_string()) + if let Some(activity) = safe_activity { + let last_activity = last_direct_activity_by_turn + .entry(turn_id.clone()) + .or_default(); + if !should_emit_direct_codex_activity(last_activity, activity) { + continue; + } + } + let event = if let Some(activity) = safe_activity { + CodexTurnEvent::Activity(activity) + } else { + match method { + "item/agentMessage/delta" => { + let Some(delta) = params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + else { + continue; + }; + CodexTurnEvent::AgentMessageDelta(delta.to_string()) + } + "item/started" | "item/completed" => CodexTurnEvent::Item { + completed: method == "item/completed", + params, + }, + _ => CodexTurnEvent::Terminal(params), } - "item/started" | "item/completed" => CodexTurnEvent::Item { - completed: method == "item/completed", - params, - }, - _ => CodexTurnEvent::Terminal(params), }; let sender = if method == "turn/completed" { + last_direct_activity_by_turn.remove(&turn_id); inner.turns.lock().await.remove(&turn_id) } else { inner.turns.lock().await.get(&turn_id).cloned() @@ -2334,6 +2434,31 @@ pub(crate) async fn direct_game_creator_codex_chat_at( root: &std::path::Path, system_prompt: String, user_prompt: String, +) -> Result { + direct_game_creator_codex_chat_at_with_optional_observer(root, system_prompt, user_prompt, None) + .await +} + +pub(crate) async fn direct_game_creator_codex_chat_at_with_observer( + root: &std::path::Path, + system_prompt: String, + user_prompt: String, + observer: &mut (dyn FnMut(DirectCodexTurnObservation) + Send), +) -> Result { + direct_game_creator_codex_chat_at_with_optional_observer( + root, + system_prompt, + user_prompt, + Some(observer), + ) + .await +} + +async fn direct_game_creator_codex_chat_at_with_optional_observer( + root: &std::path::Path, + system_prompt: String, + user_prompt: String, + observer: Option<&mut (dyn FnMut(DirectCodexTurnObservation) + Send)>, ) -> Result { let codex_root = if let Some(path) = root .to_str() @@ -2381,7 +2506,7 @@ pub(crate) async fn direct_game_creator_codex_chat_at( .await .map_err(|error| error.to_string())?; connection - .run_turn(&snapshot, &config.llm, request, None) + .run_turn_with_direct_observer(&snapshot, &config.llm, request, None, observer) .await .map(|value| value.text) .map_err(|error| error.to_string()) @@ -2470,6 +2595,36 @@ pub(in crate::agent) fn shutdown_game_creator_codex_app_servers_impl() -> Result mod tests { use super::*; + #[test] + fn direct_item_activities_are_closed_safe_categories() { + let allowed = [ + "understanding", + "project-inspection", + "file-change", + "controlled-tool", + "validation", + "response-finalization", + ]; + for item_type in [ + "fileChange", + "commandExecution", + "mcpToolCall", + "contextCompaction", + "webSearch", + "agentMessage", + "userMessage", + "plan", + "reasoning", + "SECRET_TOOL_/private/project/api_key=leak", + ] { + let activity = direct_codex_safe_activity_for_item(item_type); + assert!(allowed.contains(&activity)); + assert!(!activity.contains("SECRET_TOOL")); + assert!(!activity.contains("/private/project")); + assert!(!activity.contains("api_key")); + } + } + fn test_llm() -> GameCreatorLlmConfig { GameCreatorLlmConfig { api_key: "fixture-secret".to_string(), @@ -2525,6 +2680,16 @@ mod tests { assert!(!direct_codex_user_prompt(&request).contains("AGC 系统规则")); } + #[test] + fn direct_codex_missing_system_prompt_uses_taonier_identity_only() { + let request = LlmRunRequest::single_turn("", "你是谁"); + let instructions = direct_codex_base_instructions(&request); + assert!(instructions.contains("Taonier (陶泥儿)")); + assert!(instructions.contains("still identifying yourself as 陶泥儿")); + assert!(!instructions.starts_with("You are Codex")); + assert!(CODEX_APP_SERVER_BASE_INSTRUCTIONS_FALLBACK.starts_with("You are Codex")); + } + #[test] fn direct_project_uses_bounded_idle_and_active_mcp_windows() { let request_timeout_ms = 180_000; @@ -3121,8 +3286,16 @@ printf '%s\n' '{"id":2,"result":{"thread":{"id":"thread-1"}}}' IFS= read -r turn_start case "$turn_start" in *'"method":"turn/start"'*'"outputSchema"'*) ;; *) exit 46 ;; esac printf '%s\n' '{"id":3,"result":{"turn":{"id":"turn-1","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"turn/started","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"inProgress"}}}' +printf '%s\n' '{"method":"item/mcpToolCall/progress","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"tool-1","message":"SECRET_TOOL /private/project api_key=must-not-leak"}}' +printf '%s\n' '{"method":"item/mcpToolCall/progress","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"tool-1","message":"SECOND_SECRET_PROGRESS"}}' +printf '%s\n' '{"method":"item/fileChange/patchUpdated","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"change-1","patch":"*** SECRET PATCH /private/project"}}' +printf '%s\n' '{"method":"item/commandExecution/outputDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"command-1","delta":"Bearer secret-command-output"}}' +printf '%s\n' '{"method":"turn/plan/updated","params":{"threadId":"thread-1","turnId":"turn-1","explanation":"private reasoning must not leak","plan":[]}}' +printf '%s\n' '{"method":"item/reasoning/summaryTextDelta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"reasoning-1","delta":"hidden reasoning must not leak"}}' printf '%s\n' '{"method":"item/agentMessage/delta","params":{"threadId":"thread-1","turnId":"turn-1","itemId":"item-1","delta":"{\"toolCalls\":"}}' -printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","text":"{\"toolCalls\":[{\"name\":\"runtime_tool_file_read\",\"arguments\":\"{\\\"path\\\":\\\"game/index.html\\\"}\"}]}"}}}' +printf '%s\n' '{"method":"item/started","params":{"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","rawParams":"SECRET_TOOL /private/project api_key=must-not-leak"}}}' +printf '%s\n' '{"method":"item/completed","params":{"completedAtMs":1,"threadId":"thread-1","turnId":"turn-1","item":{"id":"item-1","type":"agentMessage","rawParams":"SECRET_TOOL /private/project api_key=must-not-leak","text":"{\"toolCalls\":[{\"name\":\"runtime_tool_file_read\",\"arguments\":\"{\\\"path\\\":\\\"game/index.html\\\"}\"}]}"}}}' printf '%s\n' '{"method":"turn/completed","params":{"threadId":"thread-1","turn":{"id":"turn-1","items":[],"status":"completed"}}}' while IFS= read -r line; do :; done "#, @@ -3142,11 +3315,83 @@ while IFS= read -r line; do :; done let mut streamed = String::new(); let mut on_delta = |delta: &platform_llm::LlmStreamDelta| streamed.push_str(&delta.delta_text); + let mut observations = Vec::new(); + let mut observer = |observation| observations.push(observation); let response = connection - .run_turn(&test_snapshot(), &llm, tool_request(), Some(&mut on_delta)) + .run_turn_with_direct_observer( + &test_snapshot(), + &llm, + tool_request(), + Some(&mut on_delta), + Some(&mut observer), + ) .await .expect("run fake app-server turn"); + drop(observer); assert_eq!(streamed, "{\"toolCalls\":"); + assert_eq!( + observations.first(), + Some(&DirectCodexTurnObservation::Activity("understanding")), + "turn/started must produce safe activity before terminal completion" + ); + let delta_index = observations + .iter() + .position(|observation| { + *observation + == DirectCodexTurnObservation::AccumulatedText("{\"toolCalls\":".to_string()) + }) + .expect("agent message delta observation"); + assert!( + observations[..delta_index] + .iter() + .filter(|observation| matches!( + observation, + DirectCodexTurnObservation::Activity(_) + )) + .count() + >= 4, + "real long-tool protocol activity must be visible before final answer delta" + ); + assert_eq!( + observations + .iter() + .filter(|observation| { + **observation == DirectCodexTurnObservation::Activity("controlled-tool") + }) + .count(), + 1, + "rapid same-category MCP progress must be coalesced" + ); + for expected in ["file-change", "validation"] { + assert!(observations.contains(&DirectCodexTurnObservation::Activity(expected))); + } + assert!( + observations + .iter() + .filter(|observation| { + **observation == DirectCodexTurnObservation::Activity("understanding") + }) + .count() + >= 2, + "turn start and later plan activity must both remain visible" + ); + assert_eq!( + observations + .iter() + .filter(|observation| { + **observation == DirectCodexTurnObservation::Activity("response-finalization") + }) + .count(), + 2, + "item/started and item/completed must both emit a safe activity" + ); + let observation_debug = format!("{observations:?}"); + assert!(!observation_debug.contains("SECRET_TOOL")); + assert!(!observation_debug.contains("SECOND_SECRET_PROGRESS")); + assert!(!observation_debug.contains("/private/project")); + assert!(!observation_debug.contains("api_key")); + assert!(!observation_debug.contains("Bearer")); + assert!(!observation_debug.contains("hidden reasoning")); assert_eq!(response.response_id.as_deref(), Some("thread-1")); assert_eq!(response.tool_calls.len(), 1); assert_eq!(response.tool_calls[0].name, "runtime_tool_file_read"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs index f5906e0f3..2053b66eb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime.rs @@ -10,7 +10,10 @@ const MAX_DIRECT_SYSTEM_PROMPT_CHARS: usize = 64 * 1024; const MAX_DIRECT_HOME_ATTACHMENTS: usize = 8; const MAX_DIRECT_HOME_ATTACHMENT_NAME_CHARS: usize = 160; const MAX_DIRECT_HOME_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96; -const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前工作目录是唯一项目根;`game/index.html` 是可运行入口,`game/style.css` 与 `game/game.js` 是标准游戏代码,`assets/` 保存项目媒体,`.agent/` 是客户端维护的持久状态。Codex 可以按用户意图读写游戏源码,但不得手改 `.agent` 中的 manifest、revision、版本、来源身份、生成账本或试玩回执。DirectProject 为无人值守安全边界,shell/unified_exec 和任意网络被禁用;客户端会在系统上下文提供有界的当前游戏文件快照,并通过 `agc_tools` 返回结构化试玩证据。原生文件读取能力未出现时,直接使用这些快照和工具证据,不要声称因此无法验收。需要平台美术或真实浏览器试玩时,只调用审核 Skill 声明的 `agc_tools` 工具;工具不可用或失败时如实报告。文件真实变化后由客户端确定性投影资源和版本,Codex 不直接保存或伪造项目版本。"; +const MIN_DIRECT_CLIENT_TURN_ID_CHARS: usize = 6; +const MAX_DIRECT_CLIENT_TURN_ID_CHARS: usize = 160; +const DIRECT_TAONIER_IDENTITY_GUIDANCE: &str = "对外身份合同:你是“陶泥儿”,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型、通用 AI 助手或内部执行智能体当作自己的名称或对外身份。Codex app-server 仅是客户端内部执行技术;只有用户明确询问底层实现时才可如实说明,同时仍以陶泥儿自称。"; +const DIRECT_AGC_ENGINEERING_GUIDANCE: &str = "AGC 工程合同:当前工作目录是唯一项目根;`game/index.html` 是可运行入口,`game/style.css` 与 `game/game.js` 是标准游戏代码,`assets/` 保存项目媒体,`.agent/` 是客户端维护的持久状态。你可以按用户意图读写游戏源码,但不得手改 `.agent` 中的 manifest、revision、版本、来源身份、生成账本或试玩回执。DirectProject 为无人值守安全边界,shell/unified_exec 和任意网络被禁用;客户端会在系统上下文提供有界的当前游戏文件快照,并通过 `agc_tools` 返回结构化试玩证据。原生文件读取能力未出现时,直接使用这些快照和工具证据,不要声称因此无法验收。需要平台美术或真实浏览器试玩时,只调用审核 Skill 声明的 `agc_tools` 工具;工具不可用或失败时如实报告。文件真实变化后由客户端确定性投影资源和版本,Codex 不直接保存或伪造项目版本。"; const DIRECT_CODEX_ART_SPEC_ASSET_PATH: &str = "assets/art-spec.png"; const DIRECT_CODEX_BACKGROUND_ASSET_PATH: &str = "assets/direct-game-background.png"; const DIRECT_CODEX_SPRITESHEET_ASSET_PATH: &str = "assets/art-spritesheet.png"; @@ -1517,13 +1520,14 @@ fn read_markdown_files(root: &Path, relative_dir: &str) -> Vec<(String, String)> pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result { let skill_index = render_agc_skill_pack_index()?; let mut sections = vec![ - "你是 Genarrative 的唯一执行智能体。用户聊天内容会原样直接发送给你;不要等待 Supervisor、专业 Agent、harness 或宿主规划器。先自行理解用户意图:普通对话(例如问候、日期或项目无关问题)直接正常回答且不触碰工作区;项目请求再按实际需要检查工程、修改工作区、运行验证,并用简洁中文报告真实结果。客户端不会根据关键词替你决定新建、续做、生图、试玩、返工或版本登记。优先使用 Codex 原生文件变更能力直接写入文件;不要用 shell 命令拼接或重定向来创建文件。".to_string(), + "你是陶泥儿,是 Genarrative 面向用户的游戏创作助手,也是当前唯一执行主体和唯一执行智能体。用户聊天内容会原样直接发送给你;不要等待 Supervisor、专业 Agent、harness 或宿主规划器。先自行理解用户意图:普通对话(例如问候、日期或项目无关问题)直接正常回答且不触碰工作区;项目请求再按实际需要检查工程、修改工作区、运行验证,并用简洁中文报告真实结果。客户端不会根据关键词替你决定新建、续做、生图、试玩、返工或版本登记。优先使用内部执行引擎提供的原生文件变更能力直接写入文件;不要用 shell 命令拼接或重定向来创建文件。".to_string(), + DIRECT_TAONIER_IDENTITY_GUIDANCE.to_string(), "工作区边界:只在当前项目目录内工作;不要读取或输出凭据、Token、Cookie、auth.json、.env 或宿主私密路径。遇到阻断必须说明具体原因、文件和下一步,不要声称未验证的成功。".to_string(), "提示词与技能:系统上下文只给出审核 Skill 索引,不预装完整正文。根据当前用户意图选择最少的相关 Skill,并通过 Codex 原生 Skill 机制按需读取其 `SKILL.md` 和一层直接引用。Skill 是执行约束,不是新的 Agent;工具未出现或调用失败时必须如实说明,不能用文字假装获得能力。不要启动 Supervisor、child、harness 或外部编排。".to_string(), DIRECT_AGC_ENGINEERING_GUIDANCE.to_string(), skill_index, ]; - sections.push("工程摘要:这是一个 Tauri + React AGC 客户端,项目游戏入口位于 `game/index.html`,样式和脚本放在 `game/`,项目状态位于 `.agent/`。普通聊天由当前 Codex 直接完成文件修改和验证;不要创建 Supervisor、专业 Agent、harness 或平行项目。修改后优先检查实际文件和运行结果。".to_string()); + sections.push("工程摘要:这是一个 Tauri + React AGC 客户端,项目游戏入口位于 `game/index.html`,样式和脚本放在 `game/`,项目状态位于 `.agent/`。项目请求由你直接完成文件修改和验证;不要创建 Supervisor、专业 Agent、harness 或平行项目。修改后优先检查实际文件和运行结果。".to_string()); for relative in ["game/index.html", "game/style.css", "game/game.js"] { if let Some(text) = read_context_file_with_limit(root, relative, MAX_DIRECT_PROJECT_FILE_BYTES) @@ -1545,6 +1549,7 @@ pub(crate) fn build_direct_codex_system_prompt(root: &Path) -> Result String { [ - "你是陶泥儿。用户的这条首页消息正文会原样直接发送给你;如有附件,正文后会单独附带仅含文件名、媒体类型和大小的附件说明。你是唯一的对话执行主体,不要等待或启动 Supervisor、专业 Agent、harness 或宿主规划器。", + "你是陶泥儿,是 Genarrative 的游戏创作助手。用户询问你是谁、你的名称或能力时,以陶泥儿的身份回答;不要把 Codex、ChatGPT、OpenAI、模型或通用 AI 助手当作自己的名称或对外身份。只有用户明确询问底层实现时才可如实说明内部使用 Codex app-server,同时仍以陶泥儿自称。用户的这条首页消息正文会原样直接发送给你;如有附件,正文后会单独附带仅含文件名、媒体类型和大小的附件说明。你是唯一的对话执行主体,不要等待或启动 Supervisor、专业 Agent、harness 或宿主规划器。", "当前没有打开任何用户项目。普通对话(例如问候、日期、知识问答)请直接正常回答。不要创建、读取或修改项目文件,不要生成素材,不要启动预览、试玩、发布、版本登记或任何付费外部动作。", "如果用户明确希望开始创作游戏,且需求已经足以开始,请把回复的第一行严格写为 [[AGC_CREATE_PROJECT]],随后用简洁中文说明将创建项目并继续创作。这个标记是唯一允许的受限创建项目请求;绝不能为普通问答、素材查看、知识问答或含糊想法输出它。不要自行创建目录。只有用户已在项目工作台中打开工作区后,才能在该项目对话中执行文件修改或游戏验证。", "不要输出或请求 API Key、Token、Cookie、auth.json、.env、用户路径或内部实现细节。遇到当前无项目无法执行的请求,请如实说明边界和下一步。", @@ -1726,6 +1731,21 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( root: &Path, prompt: &str, creation_type: Option<&str>, +) -> Result { + run_direct_game_creator_turn_at_with_creation_type_and_emitter( + root, + prompt, + creation_type, + None, + ) + .await +} + +async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( + root: &Path, + prompt: &str, + creation_type: Option<&str>, + turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, ) -> Result { if !root.is_absolute() || !root.is_dir() { return Err("当前项目目录不存在或不是绝对路径".to_string()); @@ -1738,9 +1758,23 @@ pub(crate) async fn run_direct_game_creator_turn_at_with_creation_type( } direct_creation_type_system_context(creation_type)?; emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); - match run_direct_game_creator_turn_inner(root, prompt, creation_type).await { - Ok(reply) => Ok(reply), - Err(failure) => Err(record_direct_codex_turn_failure(root, failure)), + if let Some(emitter) = turn_emitter { + emitter.emit("accepted", Some("request-accepted"), None); + } + match run_direct_game_creator_turn_inner(root, prompt, creation_type, turn_emitter).await { + Ok(reply) => { + if let Some(emitter) = turn_emitter { + emitter.emit("completed", Some("none"), Some(reply.clone())); + } + Ok(reply) + } + Err(failure) => { + let error = record_direct_codex_turn_failure(root, failure); + if let Some(emitter) = turn_emitter { + emitter.emit("failed", Some("none"), None); + } + Err(error) + } } } @@ -1748,16 +1782,68 @@ async fn run_direct_game_creator_turn_inner( root: &Path, prompt: &str, creation_type: Option<&str>, + turn_emitter: Option<&DirectGameCreatorTurnUpdateEmitter>, ) -> Result { - run_direct_game_creator_turn_with_creation_type( - root, - prompt, - creation_type, - |system_prompt, user_prompt| async move { - direct_game_creator_codex_chat_at(root, system_prompt, user_prompt).await - }, - ) - .await + emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); + if let Some(emitter) = turn_emitter { + emitter.emit("running", Some("understanding"), None); + } + let previous_output_fingerprint = direct_codex_output_fingerprint(root); + let system_prompt = build_direct_codex_system_prompt_with_creation_type(root, creation_type) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error) + })?; + let reply = if let Some(emitter) = turn_emitter { + let emitter = emitter.clone(); + let mut has_streamed = false; + let mut latest_accumulated_text = None; + let mut observer = move |observation: DirectCodexTurnObservation| match observation { + DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { + has_streamed = true; + latest_accumulated_text = Some(accumulated_text.clone()); + emitter.emit("streaming", None, Some(accumulated_text)); + } + DirectCodexTurnObservation::Activity(activity) => { + emitter.emit( + if has_streamed { "streaming" } else { "running" }, + Some(activity), + latest_accumulated_text.clone(), + ); + } + }; + direct_game_creator_codex_chat_at_with_observer( + root, + system_prompt, + prompt.to_string(), + &mut observer, + ) + .await + } else { + direct_game_creator_codex_chat_at(root, system_prompt, prompt.to_string()).await + } + .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; + if let Some(emitter) = turn_emitter { + emitter.emit( + "finalizing", + Some("response-finalization"), + Some(reply.clone()), + ); + } + if direct_codex_output_fingerprint(root) != previous_output_fingerprint { + emit_direct_game_creator_progress( + root, + "project.sync", + "检测到游戏文件更新,正在同步客户端资源", + ); + if let Some(emitter) = turn_emitter { + emitter.emit("finalizing", Some("file-change"), Some(reply.clone())); + } + sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) + .map_err(|error| { + DirectCodexTurnFailure::new(DirectCodexFailureStage::VersionRegistration, error) + })?; + } + Ok(reply) } /// Default product path: one user message becomes one turn on the same @@ -2008,16 +2094,41 @@ async fn run_direct_game_creator_turn_with_private_editor_credentials( )) } +fn normalize_direct_client_turn_id(client_turn_id: Option<&str>) -> Result { + let Some(client_turn_id) = client_turn_id else { + return Ok(uuid::Uuid::new_v4().simple().to_string()); + }; + let client_turn_id = client_turn_id.trim(); + let valid_length = (MIN_DIRECT_CLIENT_TURN_ID_CHARS..=MAX_DIRECT_CLIENT_TURN_ID_CHARS) + .contains(&client_turn_id.len()); + let mut bytes = client_turn_id.bytes(); + let valid_first = bytes + .next() + .is_some_and(|byte| byte.is_ascii_alphanumeric()); + let valid_rest = bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-'); + if !valid_length || !valid_first || !valid_rest { + return Err(format!( + "clientTurnId 必须为 {MIN_DIRECT_CLIENT_TURN_ID_CHARS} 到 {MAX_DIRECT_CLIENT_TURN_ID_CHARS} 位 ASCII 字母、数字或连字符,且首位必须为字母或数字" + )); + } + Ok(client_turn_id.to_string()) +} + #[tauri::command] pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, prompt: String, creation_type: Option, + client_turn_id: Option, ) -> Result { - run_direct_game_creator_turn_at_with_creation_type( - Path::new(project_path.trim()), + let root = Path::new(project_path.trim()); + let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?; + let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id); + run_direct_game_creator_turn_at_with_creation_type_and_emitter( + root, &prompt, creation_type.as_deref(), + Some(&turn_emitter), ) .await } @@ -2034,11 +2145,55 @@ pub(crate) async fn chat_with_game_creator_home_direct_codex( mod tests { use super::*; + #[test] + fn client_turn_id_is_strictly_normalized_and_bounded() { + assert_eq!( + normalize_direct_client_turn_id(Some(" Abc123-def ")).expect("valid id"), + "Abc123-def" + ); + assert!(normalize_direct_client_turn_id(Some("short")).is_err()); + assert!(normalize_direct_client_turn_id(Some("abcdef/path")).is_err()); + assert!(normalize_direct_client_turn_id(Some(&"a".repeat(161))).is_err()); + let generated = normalize_direct_client_turn_id(None).expect("generated id"); + assert_eq!(generated.len(), 32); + assert!(generated.bytes().all(|byte| byte.is_ascii_hexdigit())); + } + + #[test] + fn direct_turn_update_payload_is_the_exact_camel_case_contract() { + let value = serde_json::to_value(GameCreatorDirectTurnUpdateEvent { + project_path: "/project".to_string(), + turn_id: "abcdef-123456".to_string(), + sequence: 7, + status: "streaming".to_string(), + activity: None, + accumulated_text: Some("partial".to_string()), + updated_at: 42, + }) + .expect("serialize direct update"); + assert_eq!( + value, + serde_json::json!({ + "projectPath": "/project", + "turnId": "abcdef-123456", + "sequence": 7, + "status": "streaming", + "activity": null, + "accumulatedText": "partial", + "updatedAt": 42, + }) + ); + } + #[test] fn system_prompt_is_bounded_and_declares_direct_runtime() { let prompt = build_direct_codex_system_prompt(Path::new(".")).expect("build direct system prompt"); - assert!(prompt.contains("唯一执行智能体")); + assert!(prompt.contains("你是陶泥儿")); + assert!(prompt.contains("对外身份合同")); + assert!(prompt.contains("不要把 Codex")); + assert!(prompt.contains("仍以陶泥儿自称")); + assert!(!prompt.contains("你是 Codex")); assert!(prompt.contains("不要等待 Supervisor")); assert!(prompt.contains("提示词与技能")); } @@ -2112,6 +2267,10 @@ mod tests { fn home_prompt_has_no_project_or_side_effect_path_and_declares_the_only_creation_marker() { let prompt = build_direct_codex_home_system_prompt(); + assert!(prompt.contains("你是陶泥儿")); + assert!(prompt.contains("不要把 Codex")); + assert!(prompt.contains("仍以陶泥儿自称")); + assert!(!prompt.contains("你是 Codex")); assert!(prompt.contains("当前没有打开任何用户项目")); assert!(prompt.contains("不要创建、读取或修改项目文件")); assert!(prompt.contains("不要生成素材")); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs index 09659a149..ebc60d098 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/entrypoints.rs @@ -28,6 +28,73 @@ pub(crate) fn emit_direct_game_creator_progress(root: &Path, stage: &str, messag ); } +#[derive(Clone)] +pub(crate) struct DirectGameCreatorTurnUpdateEmitter { + project_path: String, + turn_id: String, + sequence: Arc, +} + +impl DirectGameCreatorTurnUpdateEmitter { + pub(crate) fn new(root: &Path, turn_id: String) -> Self { + Self { + project_path: root.to_string_lossy().into_owned(), + turn_id, + sequence: Arc::new(AtomicU64::new(0)), + } + } + + pub(crate) fn emit( + &self, + status: &'static str, + activity: Option<&'static str>, + accumulated_text: Option, + ) { + let status_is_allowed = matches!( + status, + "accepted" | "running" | "streaming" | "finalizing" | "completed" | "failed" + ); + let activity_is_allowed = activity.is_none_or(|activity| { + matches!( + activity, + "request-accepted" + | "understanding" + | "project-inspection" + | "file-change" + | "controlled-tool" + | "validation" + | "response-finalization" + | "none" + ) + }); + debug_assert!(status_is_allowed && activity_is_allowed); + if !status_is_allowed || !activity_is_allowed { + return; + } + let Some(app) = GAME_CREATOR_AGENT_RUNTIME_UPDATE_APP_HANDLE.get() else { + return; + }; + let sequence = self.sequence.fetch_add(1, Ordering::AcqRel) + 1; + let updated_at = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64; + let _ = app.emit( + "game-creator-direct-turn-update", + GameCreatorDirectTurnUpdateEvent { + project_path: self.project_path.clone(), + turn_id: self.turn_id.clone(), + sequence, + status: status.to_string(), + activity: activity.map(str::to_string), + accumulated_text, + updated_at, + }, + ); + } +} + pub(crate) fn start_game_creator_manifest_invalidation_event_sink( app: tauri::AppHandle, ) -> Result { 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 c22579221..b139eed49 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -723,6 +723,18 @@ struct GameCreatorAgentProgressEvent { message: String, } +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +struct GameCreatorDirectTurnUpdateEvent { + project_path: String, + turn_id: String, + sequence: u64, + status: String, + activity: Option, + accumulated_text: Option, + updated_at: u64, +} + #[derive(Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] struct GameCreatorLlmConfigStatus { diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 64581eaa3..4f63aa7ea 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -51,6 +51,7 @@ import type { ChatMessage, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, + GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, GameCreatorRoleAgentChatStreamEvent, @@ -258,6 +259,37 @@ const GAME_CHAT_AUTO_PREVIEW_AUTHORIZATION_STORAGE_KEY = const DIRECT_CODEX_PRODUCT_RUNTIME = true; const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; +function directCodexActivityText(activity: string | null | undefined) { + switch (activity) { + case 'request-accepted': + return '已接收需求'; + case 'understanding': + return '正在理解需求'; + case 'project-inspection': + return '正在检查项目'; + case 'file-change': + return '正在修改项目文件'; + case 'controlled-tool': + return '正在执行受控工具'; + case 'validation': + return '正在验证结果'; + case 'response-finalization': + return '正在整理回复'; + case 'none': + default: + return '陶泥儿正在处理'; + } +} + +const DIRECT_CODEX_TURN_UPDATE_STATUSES = new Set([ + 'accepted', + 'running', + 'streaming', + 'finalizing', + 'completed', + 'failed', +]); + function directCodexConversationMessageId( turnId: string, role: ChatMessage['role'], @@ -747,6 +779,20 @@ export function App({ ); const [chatAgentBusy, setChatAgentBusy] = useState(false); const [directCodexProgress, setDirectCodexProgress] = useState(''); + const [directCodexProgressUpdatedAt, setDirectCodexProgressUpdatedAt] = + useState(null); + const [directCodexTransientReply, setDirectCodexTransientReply] = + useState(''); + const [ + directCodexTransientReplyUpdatedAt, + setDirectCodexTransientReplyUpdatedAt, + ] = useState(null); + const activeDirectCodexTurnRef = useRef<{ + projectPath: string; + turnId: string; + lastSequence: number; + receivedDirectUpdate: boolean; + } | null>(null); const directCodexConversationTurnSequenceRef = useRef(0); const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null @@ -766,6 +812,28 @@ export function App({ } return `${Date.now().toString(36)}-${directCodexConversationTurnSequenceRef.current.toString(36)}`; } + + function resetDirectCodexTurn() { + activeDirectCodexTurnRef.current = null; + setDirectCodexProgress(''); + setDirectCodexProgressUpdatedAt(null); + setDirectCodexTransientReply(''); + setDirectCodexTransientReplyUpdatedAt(null); + } + + function clearDirectCodexTransientReply(projectPath: string, turnId: string) { + const activeTurn = activeDirectCodexTurnRef.current; + if ( + activeTurn?.projectPath !== projectPath || + activeTurn.turnId !== turnId + ) { + return false; + } + activeDirectCodexTurnRef.current = null; + setDirectCodexTransientReply(''); + setDirectCodexTransientReplyUpdatedAt(null); + return true; + } const [projectSupervisorRuntime, setProjectSupervisorRuntime] = useState(null); const [projectSupervisorResponseStream, setProjectSupervisorResponseStream] = @@ -1444,6 +1512,76 @@ export function App({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [gameChatOnly, initialProjectPath, projectSupervisorOnly]); + useEffect(() => { + if (!directCodexProductRuntime) { + return; + } + const listen = window.__TAURI__?.event?.listen; + if (!listen) { + return; + } + let cleanup: (() => void) | null = null; + let disposed = false; + void listen( + 'game-creator-direct-turn-update', + (event) => { + const payload = event.payload; + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !activeTurn || + payload.projectPath !== localProjectPathRef.current || + payload.projectPath !== activeTurn.projectPath || + payload.turnId !== activeTurn.turnId || + !Number.isSafeInteger(payload.sequence) || + payload.sequence < 0 || + !DIRECT_CODEX_TURN_UPDATE_STATUSES.has(payload.status) || + payload.sequence <= activeTurn.lastSequence + ) { + return; + } + activeTurn.lastSequence = payload.sequence; + activeTurn.receivedDirectUpdate = true; + const updatedAt = + Number.isFinite(payload.updatedAt) && payload.updatedAt > 0 + ? payload.updatedAt + : Date.now(); + if (payload.status === 'failed') { + activeDirectCodexTurnRef.current = null; + setDirectCodexProgress('处理失败,正在同步错误'); + setDirectCodexProgressUpdatedAt(updatedAt); + setDirectCodexTransientReply(''); + setDirectCodexTransientReplyUpdatedAt(null); + return; + } + if (payload.status === 'completed') { + setDirectCodexProgress('回复已生成,正在提交'); + setDirectCodexProgressUpdatedAt(updatedAt); + } else if (payload.activity != null) { + setDirectCodexProgress(directCodexActivityText(payload.activity)); + setDirectCodexProgressUpdatedAt(updatedAt); + } + if (typeof payload.accumulatedText === 'string') { + setDirectCodexTransientReply(payload.accumulatedText); + setDirectCodexTransientReplyUpdatedAt(updatedAt); + } + }, + ) + .then((unlisten) => { + if (disposed) { + unlisten(); + return; + } + cleanup = unlisten; + }) + .catch(() => { + // The existing safe progress event remains the activity fallback. + }); + return () => { + disposed = true; + cleanup?.(); + }; + }, [directCodexProductRuntime]); + useEffect(() => { if (projectSupervisorOnly && !directCodexProductRuntime) { return; @@ -1459,7 +1597,16 @@ export function App({ return; } if (directCodexProductRuntime) { + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !activeTurn || + activeTurn.projectPath !== event.payload.projectPath || + activeTurn.receivedDirectUpdate + ) { + return; + } setDirectCodexProgress(event.payload.message); + setDirectCodexProgressUpdatedAt(Date.now()); return; } setMessages((current) => [ @@ -2132,7 +2279,9 @@ export function App({ useLayoutEffect(() => { if ( - (!supervisorChatOnly && !gameChatOnly) || + (!supervisorChatOnly && + !gameChatOnly && + !(projectSupervisorOnly && directCodexProductRuntime)) || !supervisorChatShouldFollowLatestRef.current ) { return; @@ -2146,7 +2295,13 @@ export function App({ projectSupervisorResponseStream?.sequence, projectSupervisorRuntime?.updatedAt, projectSupervisorRuntimeError, + directCodexProgress, + directCodexProgressUpdatedAt, + directCodexTransientReply, + directCodexTransientReplyUpdatedAt, + directCodexProductRuntime, gameChatOnly, + projectSupervisorOnly, supervisorChatOnly, ]); @@ -3259,6 +3414,7 @@ export function App({ const projectScopeVersion = projectScopeVersionRef.current + 1; projectScopeVersionRef.current = projectScopeVersion; resetProjectSupervisorState(); + resetDirectCodexTurn(); if ( gameChatAutoPreviewAuthorizationRef.current?.projectPath !== nextProjectPath @@ -5937,20 +6093,17 @@ export function App({ const directInvoke = resolveTauriInvoke(); const directProjectPath = resolveChatProjectPath(localProject); if (directProjectPath && directInvoke) { - const directAssistantMessageId = directConversationTurnId - ? directCodexConversationMessageId( - directConversationTurnId, - 'assistant', - ) - : undefined; + const clientTurnId = + directConversationTurnId ?? createDirectCodexConversationTurnId(); + const directAssistantMessageId = directCodexConversationMessageId( + clientTurnId, + 'assistant', + ); const appendDirectUserMessageIfMissing = ( current: ChatMessage[], ): ChatMessage[] => { - if (!directConversationTurnId) { - return current; - } const directUserMessageId = directCodexConversationMessageId( - directConversationTurnId, + clientTurnId, 'user', ); return current.some( @@ -5968,17 +6121,50 @@ export function App({ }, ]; }; + const appendDirectAssistantMessage = ( + current: ChatMessage[], + text: string, + ): ChatMessage[] => { + const nextMessage: ChatMessage = { + role: 'assistant', + text, + runtimeOwned: true, + messageId: directAssistantMessageId, + updatedAt: Date.now(), + }; + const withUser = appendDirectUserMessageIfMissing(current); + const existingIndex = withUser.findIndex( + (message) => message.messageId === directAssistantMessageId, + ); + if (existingIndex < 0) { + return [...withUser, nextMessage]; + } + return withUser.map((message, index) => + index === existingIndex ? nextMessage : message, + ); + }; + activeDirectCodexTurnRef.current = { + projectPath: directProjectPath, + turnId: clientTurnId, + lastSequence: -1, + receivedDirectUpdate: false, + }; setChatAgentBusy(true); setDirectCodexProgress('已发送消息,正在等待陶泥儿回复'); + setDirectCodexProgressUpdatedAt(Date.now()); + setDirectCodexTransientReply(''); + setDirectCodexTransientReplyUpdatedAt(null); setProjectSupervisorRuntimeError(''); try { const directTurnInput: { projectPath: string; prompt: string; + clientTurnId: string; creationType?: HomeCreationType; } = { projectPath: directProjectPath, prompt, + clientTurnId, }; if (creationType) { directTurnInput.creationType = creationType; @@ -5988,19 +6174,12 @@ export function App({ directTurnInput, ); if (localProjectPathRef.current === directProjectPath) { - setMessages((current) => [ - ...appendDirectUserMessageIfMissing(current), - { - role: 'assistant', - text: reply, - runtimeOwned: true, - ...(directAssistantMessageId - ? { messageId: directAssistantMessageId } - : {}), - updatedAt: Date.now(), - }, - ]); - setDirectCodexProgress('陶泥儿已回复,正在刷新项目状态'); + clearDirectCodexTransientReply(directProjectPath, clientTurnId); + setMessages((current) => + appendDirectAssistantMessage(current, reply), + ); + setDirectCodexProgress('正在刷新项目状态'); + setDirectCodexProgressUpdatedAt(Date.now()); await refreshDirectProjectManifest(directProjectPath); } } catch (error) { @@ -6012,23 +6191,22 @@ export function App({ true, ); if (localProjectPathRef.current === directProjectPath) { + clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError(visibleMessage); - setMessages((current) => [ - ...appendDirectUserMessageIfMissing(current), - { - role: 'assistant', - text: visibleMessage, - runtimeOwned: true, - ...(directAssistantMessageId - ? { messageId: directAssistantMessageId } - : {}), - updatedAt: Date.now(), - }, - ]); + setMessages((current) => + appendDirectAssistantMessage(current, visibleMessage), + ); } } finally { setChatAgentBusy(false); - setDirectCodexProgress(''); + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !activeTurn || + (activeTurn.projectPath === directProjectPath && + activeTurn.turnId === clientTurnId) + ) { + resetDirectCodexTurn(); + } } return; } @@ -11173,8 +11351,8 @@ export function App({ ); const projectSupervisorHasConversationControls = Boolean( projectSupervisorRuntime?.pendingToolAction || - projectSupervisorRuntime?.userInputRequest || - projectSupervisorNeedsUserInput, + projectSupervisorRuntime?.userInputRequest || + projectSupervisorNeedsUserInput, ); const visibleAgentConversationMessages = latestVisibleItems( agentConversationMessages, @@ -11284,7 +11462,7 @@ export function App({ if (!prompt || chatAgentBusy) { return; } - if (supervisorChatOnly || gameChatOnly) { + if (supervisorChatOnly || gameChatOnly || directCodexProductRuntime) { supervisorChatShouldFollowLatestRef.current = true; } const directConversationTurnId = directCodexProductRuntime @@ -11362,12 +11540,20 @@ export function App({ allowAdvancedExternalEditorConfig ? false : runtimeConfigOpen } runtimeError={projectSupervisorRuntimeError} + directActivity={directCodexProductRuntime ? directCodexProgress : ''} + directActivityUpdatedAt={ + directCodexProductRuntime ? directCodexProgressUpdatedAt : null + } transientReply={ directCodexProductRuntime - ? directCodexProgress + ? directCodexTransientReply : projectSupervisorTransientReply } - transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} + transientReplyUpdatedAt={ + directCodexProductRuntime + ? directCodexTransientReplyUpdatedAt + : projectSupervisorResponseStream?.updatedAt + } hasConversationControls={ directCodexProductRuntime ? false @@ -11421,12 +11607,20 @@ export function App({ runtime={projectSupervisorRuntime} runtimeConfigOpen={runtimeConfigOpen} runtimeError={projectSupervisorRuntimeError} + directActivity={directCodexProductRuntime ? directCodexProgress : ''} + directActivityUpdatedAt={ + directCodexProductRuntime ? directCodexProgressUpdatedAt : null + } transientReply={ directCodexProductRuntime - ? directCodexProgress + ? directCodexTransientReply : projectSupervisorTransientReply } - transientReplyUpdatedAt={projectSupervisorResponseStream?.updatedAt} + transientReplyUpdatedAt={ + directCodexProductRuntime + ? directCodexTransientReplyUpdatedAt + : projectSupervisorResponseStream?.updatedAt + } hasConversationControls={projectSupervisorHasConversationControls} hiddenConversationCount={hiddenConversationCount} needsUserInput={projectSupervisorNeedsUserInput} @@ -11442,7 +11636,9 @@ export function App({ void handlePendingCommandConfirm()} - onScroll={handleConversationScroll} + onScroll={handleSupervisorChatScroll} onShowEarlierMessages={showEarlierConversationMessages} onSubmit={handleProjectSupervisorOnlySubmit} pendingConfirmation={ @@ -11461,7 +11657,7 @@ export function App({ projectPath={localProject?.projectPath ?? projectPath} transientReply={ directCodexProductRuntime - ? directCodexProgress + ? directCodexTransientReply : projectSupervisorTransientReply } visibleMessages={visibleMessages} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 132e92587..cd3b5c62a 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -364,11 +364,7 @@ export interface AgentRuntimeResult { } export type AgentRuntimeResponseStreamStatus = - | 'streaming' - | 'ready' - | 'committed' - | 'discarded' - | 'failed'; + 'streaming' | 'ready' | 'committed' | 'discarded' | 'failed'; export interface AgentRuntimeResponseStream { schemaVersion: string; @@ -489,22 +485,13 @@ export interface GameCreatorAgentLlmConfigStatus { } export type GameCreatorLlmApiKind = - | 'openai_responses' - | 'openai_chat' - | 'anthropic'; + 'openai_responses' | 'openai_chat' | 'anthropic'; export type GameCreatorAgentMode = - | 'codex_app_server' - | 'codex_cli' - | 'provider'; + 'codex_app_server' | 'codex_cli' | 'provider'; export type RuntimeLlmProviderPresetId = - | 'custom' - | 'openai' - | 'deepseek' - | 'anthropic' - | 'ark'; + 'custom' | 'openai' | 'deepseek' | 'anthropic' | 'ark'; export type RuntimeAgentLlmProviderPresetId = - | 'inherit' - | RuntimeLlmProviderPresetId; + 'inherit' | RuntimeLlmProviderPresetId; export interface GameCreatorLlmConfig { apiKey: string; @@ -806,6 +793,29 @@ export interface AgentProgressEvent { message: string; } +export type GameCreatorDirectTurnUpdateStatus = + 'accepted' | 'running' | 'streaming' | 'finalizing' | 'completed' | 'failed'; + +export type GameCreatorDirectTurnActivity = + | 'request-accepted' + | 'understanding' + | 'project-inspection' + | 'file-change' + | 'controlled-tool' + | 'validation' + | 'response-finalization' + | 'none'; + +export interface GameCreatorDirectTurnUpdateEvent { + projectPath: string; + turnId: string; + sequence: number; + status: GameCreatorDirectTurnUpdateStatus; + activity?: GameCreatorDirectTurnActivity | null; + accumulatedText?: string | null; + updatedAt: number; +} + export interface AgentRunControlResult { runId: string; status: string; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 9f87daa95..f51a37029 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -3,6 +3,7 @@ import type { ComponentProps, Dispatch, FormEventHandler, + RefObject, SetStateAction, UIEventHandler, } from 'react'; @@ -33,7 +34,9 @@ type RuntimePanelProps = ComponentProps; type ProjectSupervisorViewProps = RuntimePanelProps & { chatInput: string; directCodex?: boolean; + directActivity?: string; hiddenConversationCount: number; + messagesRef: RefObject; needsUserInput: boolean; onCancelConfirmation: () => void; onCancelPendingCommand: () => void; @@ -56,7 +59,9 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { export function ProjectSupervisorView({ chatInput, directCodex = false, + directActivity = '', hiddenConversationCount, + messagesRef, needsUserInput, onCancelConfirmation, onCancelPendingCommand, @@ -83,6 +88,7 @@ export function ProjectSupervisorView({ >
))} - {transientReply ? ( + {directCodex && + (runtimePanelProps.controlBusy || Boolean(transientReply)) ? ( +
+
+
+ {transientReply ? ( +

{transientReply}

+ ) : null} +
+ ) : transientReply ? (

diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx index 6496fc446..c55cf18e0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/SupervisorChatOnlyView.tsx @@ -201,10 +201,10 @@ function gameChatRuntimeActivityTimes(runtime: AgentRuntimeState) { function gameChatRuntimeIsExpectedWait(runtime: AgentRuntimeState) { return Boolean( runtime.pendingToolAction || - runtime.userInputRequest || - [runtime.status, runtime.phase, runtime.goalStatus].some((state) => - GAME_CHAT_EXPECTED_WAIT_STATES.has(state ?? ''), - ), + runtime.userInputRequest || + [runtime.status, runtime.phase, runtime.goalStatus].some((state) => + GAME_CHAT_EXPECTED_WAIT_STATES.has(state ?? ''), + ), ); } @@ -903,6 +903,8 @@ type SupervisorChatOnlyViewProps = { runtime: AgentRuntimeState | null; runtimeConfigOpen: boolean; runtimeError: string; + directActivity?: string; + directActivityUpdatedAt?: number | null; transientReply: string; transientReplyUpdatedAt?: number | null; hasConversationControls: boolean; @@ -948,6 +950,8 @@ export function SupervisorChatOnlyView({ runtime, runtimeConfigOpen, runtimeError, + directActivity = '', + directActivityUpdatedAt = null, transientReply, transientReplyUpdatedAt = null, hasConversationControls, @@ -980,11 +984,11 @@ export function SupervisorChatOnlyView({ !directCodex && Boolean(expectedRunId && runtime?.runId !== expectedRunId); const running = Boolean( chatAgentBusy || - (!directCodex && synchronizingAcceptedRun) || - (runtime && - runtime.status !== 'needs-reconciliation' && - runtime.phase !== 'needs-reconciliation' && - !isAgentRuntimeTerminalState(runtime)), + (!directCodex && synchronizingAcceptedRun) || + (runtime && + runtime.status !== 'needs-reconciliation' && + runtime.phase !== 'needs-reconciliation' && + !isAgentRuntimeTerminalState(runtime)), ); const gameChatInterruptionText = !directCodex && gameChatMode && runtime @@ -1059,11 +1063,13 @@ export function SupervisorChatOnlyView({ 0, ) : 0; - const latestActivityAt = runtime - ? [runtime, ...collaboratingRuntimes] - .flatMap(gameChatRuntimeActivityTimes) - .reduce((latest, candidate) => Math.max(latest, candidate), 0) - : 0; + const latestActivityAt = directCodex + ? (directActivityUpdatedAt ?? transientReplyUpdatedAt ?? 0) + : runtime + ? [runtime, ...collaboratingRuntimes] + .flatMap(gameChatRuntimeActivityTimes) + .reduce((latest, candidate) => Math.max(latest, candidate), 0) + : 0; const activeCollaboratingRuntimes = collaboratingRuntimes.filter( (candidate) => !isAgentRuntimeTerminalState(candidate), ); @@ -1107,18 +1113,18 @@ export function SupervisorChatOnlyView({ : null; const expectedRuntimeWait = Boolean( needsUserInput || - pendingConfirmation || - pendingCommand || - (activeRuntimeLanes.length > 0 && - activeRuntimeLanes.every(gameChatRuntimeIsExpectedWait)), + pendingConfirmation || + pendingCommand || + (activeRuntimeLanes.length > 0 && + activeRuntimeLanes.every(gameChatRuntimeIsExpectedWait)), ); const runtimeAppearsStalled = Boolean( running && - runtime && - !runtimeTerminal && - !expectedRuntimeWait && - inactiveRuntimeMs !== null && - inactiveRuntimeMs > GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS, + runtime && + !runtimeTerminal && + !expectedRuntimeWait && + inactiveRuntimeMs !== null && + inactiveRuntimeMs > GAME_CHAT_RUNTIME_STALL_THRESHOLD_MS, ); const runStateLabel = (() => { if (gameChatInterruptionText) { @@ -1358,7 +1364,7 @@ export function SupervisorChatOnlyView({ {supervisorProgress?.currentWork || (projectReady ? directCodex - ? '直接与陶泥儿对话' + ? directActivity || '直接与陶泥儿对话' : '等待新的运行事件' : '请选择项目目录')} diff --git a/apps/ai-game-creator-shell/src/styles.css b/apps/ai-game-creator-shell/src/styles.css index 646b360ea..6c0eb6197 100644 --- a/apps/ai-game-creator-shell/src/styles.css +++ b/apps/ai-game-creator-shell/src/styles.css @@ -1597,7 +1597,8 @@ textarea { .launcher-development-canvas { display: grid; min-height: 300px; - background: linear-gradient(#f3f4f6 1px, transparent 1px), + background: + linear-gradient(#f3f4f6 1px, transparent 1px), linear-gradient(90deg, #f3f4f6 1px, transparent 1px), #fff; background-size: 24px 24px; place-items: center; @@ -2434,7 +2435,8 @@ textarea { min-width: 0; min-height: 0; overflow: hidden; - background: linear-gradient(45deg, #101827 25%, transparent 25%), + background: + linear-gradient(45deg, #101827 25%, transparent 25%), linear-gradient(-45deg, #101827 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #101827 75%), linear-gradient(-45deg, transparent 75%, #101827 75%), #0b1220; @@ -2596,6 +2598,65 @@ textarea { background: #fff; } +.project-supervisor-process-card { + display: grid; + gap: 9px; + margin-top: 12px; + padding: 12px 14px; + border: 1px solid #b9d8c5; + border-radius: 8px; + background: #f1f8f3; + color: #1f3d2a; +} + +.project-supervisor-process-card > header { + display: flex; + align-items: center; + gap: 8px; +} + +.project-supervisor-process-card > header > span { + width: 8px; + height: 8px; + flex: 0 0 auto; + border-radius: 50%; + background: #2f855a; + box-shadow: 0 0 0 4px rgb(47 133 90 / 14%); + animation: project-supervisor-process-pulse 1.2s ease-in-out infinite; +} + +@keyframes project-supervisor-process-pulse { + 50% { + opacity: 0.42; + transform: scale(0.76); + } +} + +@media (prefers-reduced-motion: reduce) { + .project-supervisor-process-card > header > span { + animation: none; + } +} + +.project-supervisor-process-card p { + margin: 0; + color: #263142; + font-size: 13px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.game-workbench-chat .project-supervisor-process-card { + border-color: var(--platform-surface-border); + background: var(--platform-warm-bg); + color: var(--platform-text-strong); +} + +.game-workbench-chat .project-supervisor-process-card p { + color: var(--platform-text-base); +} + .project-supervisor-surface .agent-runtime-status { margin: 0; } @@ -2950,24 +3011,28 @@ textarea { } .launcher-card-collage span:nth-child(1) { - background: radial-gradient(circle at 45% 38%, #6b5c43 0 18%, transparent 19%), + background: + radial-gradient(circle at 45% 38%, #6b5c43 0 18%, transparent 19%), radial-gradient(circle at 62% 54%, #2f2a23 0 25%, transparent 26%), linear-gradient(135deg, #91a17e, #2c3327); } .launcher-card-collage span:nth-child(2) { - background: radial-gradient(circle at 58% 45%, #b28b5a 0 16%, transparent 17%), + background: + radial-gradient(circle at 58% 45%, #b28b5a 0 16%, transparent 17%), radial-gradient(circle at 48% 55%, #302114 0 24%, transparent 25%), linear-gradient(135deg, #d9e8ef, #9fb0a0); } .launcher-card-collage span:nth-child(3) { - background: radial-gradient(circle at 50% 54%, #eac9c0 0 22%, transparent 23%), + background: + radial-gradient(circle at 50% 54%, #eac9c0 0 22%, transparent 23%), linear-gradient(135deg, #f7d9d5, #dab6aa); } .launcher-card-collage span:nth-child(4) { - background: radial-gradient(circle at 56% 50%, #32271b 0 22%, transparent 23%), + background: + radial-gradient(circle at 56% 50%, #32271b 0 22%, transparent 23%), linear-gradient(135deg, #8e6c4e, #2b211b); } @@ -2991,12 +3056,8 @@ textarea { .launcher-card-pattern > div { display: grid; grid-template-columns: repeat(2, 1fr); - background: linear-gradient( - 45deg, - transparent 46%, - #fff 46% 54%, - transparent 54% - ), + background: + linear-gradient(45deg, transparent 46%, #fff 46% 54%, transparent 54%), linear-gradient(135deg, #55405a, #e4b2a7); } @@ -5322,7 +5383,8 @@ iframe.preview-frame { .game-resource-card-visual, .game-resource-card[data-preview-kind='media-image'] .game-resource-card-visual, .game-resource-card[data-preview-kind='video'] .game-resource-card-visual { - background: linear-gradient(45deg, #f1ebe7 25%, transparent 25%), + background: + linear-gradient(45deg, #f1ebe7 25%, transparent 25%), linear-gradient(-45deg, #f1ebe7 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #f1ebe7 75%), linear-gradient(-45deg, transparent 75%, #f1ebe7 75%), #faf7f5; diff --git a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts index 76f81e0a0..c9e169960 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/home.suite.ts @@ -897,8 +897,7 @@ export function registerClientHomeTests() { ]; const streamedReply = '流式正文已经完整结束。'; let streamHandler: - | ((event: { payload: Record }) => void) - | null = null; + ((event: { payload: Record }) => void) | null = null; const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'read_local_conversation') { @@ -1364,6 +1363,7 @@ export function registerHomeProjectCreationTests() { projectPath: automaticProjectPath, prompt: '你好,今天多少号', creationType: 'art', + clientTurnId: expect.any(String), }); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_home_direct_codex', @@ -1453,6 +1453,7 @@ export function registerHomeProjectCreationTests() { projectPath: automaticProjectPath, prompt: '按这个角色做游戏', creationType: 'game', + clientTurnId: expect.any(String), }); expect(invoke).not.toHaveBeenCalledWith( 'chat_with_game_creator_home_direct_codex', @@ -1549,6 +1550,7 @@ export function registerHomeProjectCreationTests() { { projectPath, prompt: '继续修改已有项目', + clientTurnId: expect.any(String), }, ); }); diff --git a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts index be3c1bd81..7d911c847 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts @@ -4340,6 +4340,7 @@ export function registerProjectSupervisorSurfaceTests() { expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_direct_codex', { projectPath, prompt: '从旧任务状态继续生成,但使用 direct Codex', + clientTurnId: expect.any(String), }); expect(invoke).not.toHaveBeenCalledWith( 'cancel_game_creator_agent_runtime_task', @@ -9159,6 +9160,27 @@ export function registerProjectSupervisorSurfaceTests() { const supervisorHarness = createProjectSupervisorRuntimeHarness({ projectPath, }); + let directTurnUpdateHandler: + ((event: { payload: Record }) => void) | null = null; + const listen = vi.fn( + async ( + eventName: string, + handler: (event: { payload: Record }) => void, + ) => { + if (eventName === 'game-creator-direct-turn-update') { + directTurnUpdateHandler = handler; + return () => { + if (directTurnUpdateHandler === handler) { + directTurnUpdateHandler = null; + } + }; + } + return supervisorHarness.listen( + eventName, + handler as Parameters[1], + ); + }, + ); const invoke = vi.fn( async (command: string, args?: Record) => { if (command === 'inspect_local_project_directory') { @@ -9183,7 +9205,7 @@ export function registerProjectSupervisorSurfaceTests() { ); window.__TAURI__ = { core: { invoke }, - event: { listen: supervisorHarness.listen }, + event: { listen }, }; renderLauncherProjectsAt('/?launcher'); @@ -9220,11 +9242,16 @@ export function registerProjectSupervisorSurfaceTests() { ([command]) => command === 'read_project_permission_policy', ).length; - const directReply = createDeferred(); + const firstDirectReply = createDeferred(); + const secondDirectReply = createDeferred(); + let directReplyCount = 0; invoke.mockImplementation( async (command: string, args?: Record) => { if (command === 'chat_with_game_creator_direct_codex') { - return directReply.promise; + directReplyCount += 1; + return directReplyCount === 1 + ? firstDirectReply.promise + : secondDirectReply.promise; } return supervisorHarness.invoke(command, args); }, @@ -9238,17 +9265,152 @@ export function registerProjectSupervisorSurfaceTests() { ); await waitFor(() => expect( - within(supervisorSurface).getByText('已发送消息,正在等待陶泥儿回复'), + within( + within(supervisorSurface).getByLabelText('陶泥儿执行过程'), + ).getByText('已发送消息,正在等待陶泥儿回复'), ).not.toBeNull(), ); - await act(async () => { - directReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解'); + const directMessageList = + within(supervisorSurface).getByLabelText('陶泥儿消息'); + Object.defineProperties(directMessageList, { + clientHeight: { configurable: true, value: 180 }, + scrollHeight: { configurable: true, value: 640 }, + scrollTop: { configurable: true, value: 0, writable: true }, }); + const waitingProcessCard = + within(directMessageList).getByLabelText('陶泥儿执行过程'); expect( - await within(supervisorSurface).findByText( - 'DIRECT_REPLY:先完成正式客户端玩法拆解', - ), + within(waitingProcessCard).getByText('已发送消息,正在等待陶泥儿回复'), ).not.toBeNull(); + expect( + within(waitingProcessCard).queryByLabelText('陶泥儿实时回复'), + ).toBeNull(); + const firstDirectCall = invoke.mock.calls.find( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ); + const firstTurnId = String( + (firstDirectCall?.[1] as Record | undefined) + ?.clientTurnId ?? '', + ); + expect(firstTurnId).not.toBe(''); + expect(firstDirectCall?.[1]).toEqual({ + projectPath, + prompt: '先完成正式客户端玩法拆解', + clientTurnId: firstTurnId, + }); + + await act(async () => { + supervisorHarness.emitProgress('direct', '正在准备安全阶段'); + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 0, + status: 'accepted', + activity: 'understanding', + accumulatedText: 'DIRECT_STREAM:先完成', + updatedAt: 1000, + }, + }); + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 2, + status: 'streaming', + activity: 'controlled-tool', + accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解', + updatedAt: 2000, + }, + }); + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 1, + status: 'streaming', + activity: 'file-change', + accumulatedText: '乱序事件不能回退正文', + updatedAt: 1500, + }, + }); + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: 'old-direct-turn', + sequence: 99, + status: 'streaming', + activity: 'validation', + accumulatedText: '旧回合不能覆盖正文', + updatedAt: 3000, + }, + }); + directTurnUpdateHandler?.({ + payload: { + projectPath: '/tmp/wrong-direct-project', + turnId: firstTurnId, + sequence: 100, + status: 'streaming', + activity: 'response-finalization', + accumulatedText: '错误项目不能覆盖正文', + updatedAt: 4000, + }, + }); + supervisorHarness.emitProgress('direct', '旧 fallback 不能覆盖精确事件'); + }); + const streamingProcessCard = + within(directMessageList).getByLabelText('陶泥儿执行过程'); + expect( + within(streamingProcessCard).getByText('正在执行受控工具'), + ).not.toBeNull(); + expect( + within(streamingProcessCard).getByLabelText('陶泥儿实时回复').textContent, + ).toContain('DIRECT_STREAM:先完成正式客户端玩法拆解'); + expect(directMessageList.scrollTop).toBe(640); + expect(within(supervisorSurface).queryByText(/不能覆盖正文/u)).toBeNull(); + expect( + within(supervisorSurface).queryByText('旧 fallback 不能覆盖精确事件'), + ).toBeNull(); + directMessageList.scrollTop = 100; + fireEvent.scroll(directMessageList); + await act(async () => { + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: firstTurnId, + sequence: 3, + status: 'streaming', + activity: 'validation', + accumulatedText: 'DIRECT_STREAM:先完成正式客户端玩法拆解', + updatedAt: 4500, + }, + }); + }); + expect(directMessageList.scrollTop).toBe(100); + expect( + within(streamingProcessCard).getByText('正在验证结果'), + ).not.toBeNull(); + await act(async () => { + firstDirectReply.resolve('DIRECT_REPLY:先完成正式客户端玩法拆解'); + }); + await waitFor(() => { + expect( + within(supervisorSurface).getAllByText( + 'DIRECT_REPLY:先完成正式客户端玩法拆解', + ), + ).toHaveLength(1); + expect( + within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), + ).toBeNull(); + expect( + within(supervisorSurface).queryByText( + 'DIRECT_STREAM:先完成正式客户端玩法拆解', + ), + ).toBeNull(); + expect( + within(directMessageList).queryByLabelText('陶泥儿执行过程'), + ).toBeNull(); + }); await waitFor(() => { expect( ( @@ -9271,9 +9433,80 @@ export function registerProjectSupervisorSurfaceTests() { { projectPath, prompt: '补充:优先复用现有素材', + clientTurnId: expect.any(String), }, ); }); + const directCalls = invoke.mock.calls.filter( + ([command]) => command === 'chat_with_game_creator_direct_codex', + ); + const secondTurnId = String( + (directCalls[1]?.[1] as Record | undefined) + ?.clientTurnId ?? '', + ); + expect(secondTurnId).not.toBe(firstTurnId); + await act(async () => { + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: secondTurnId, + sequence: 0, + status: 'streaming', + activity: 'not-a-public-activity', + accumulatedText: '即将失败的临时正文', + updatedAt: 5000, + }, + }); + }); + const failedTurnProcessCard = + within(directMessageList).getByLabelText('陶泥儿执行过程'); + expect( + within(failedTurnProcessCard).getByText('陶泥儿正在处理'), + ).not.toBeNull(); + expect( + within(failedTurnProcessCard).getByLabelText('陶泥儿实时回复') + .textContent, + ).toContain('即将失败的临时正文'); + await act(async () => { + directTurnUpdateHandler?.({ + payload: { + projectPath, + turnId: secondTurnId, + sequence: 1, + status: 'failed', + activity: 'none', + accumulatedText: '失败事件不得保留这段正文', + updatedAt: 5100, + }, + }); + }); + expect( + within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), + ).toBeNull(); + expect( + within(directMessageList).getByLabelText('陶泥儿执行过程'), + ).not.toBeNull(); + expect( + within(supervisorSurface).getByText('处理失败,正在同步错误'), + ).not.toBeNull(); + await act(async () => { + secondDirectReply.reject(new Error('模拟 direct 失败')); + }); + await waitFor(() => { + expect( + within(supervisorSurface).queryByLabelText('陶泥儿实时回复'), + ).toBeNull(); + expect( + within(directMessageList).queryByLabelText('陶泥儿执行过程'), + ).toBeNull(); + expect( + ( + within(supervisorSurface).getByRole('button', { + name: '发送', + }) as HTMLButtonElement + ).disabled, + ).toBe(false); + }); expect( invoke.mock.calls.filter( ([command]) => command === 'start_game_creator_supervisor_runtime_task', @@ -9555,6 +9788,7 @@ export function registerProjectSupervisorSurfaceTests() { { projectPath, prompt: '修改玩家移动脚本', + clientTurnId: expect.any(String), }, ); }); @@ -9883,8 +10117,7 @@ export function registerProjectAgentStatusTests() { }, ); let runtimeUpdateHandler: - | ((event: { payload: Record }) => void) - | null = null; + ((event: { payload: Record }) => void) | null = null; const listen = vi.fn( async ( eventName: string, diff --git a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md index 3f34e1b25..5f58fe621 100644 --- a/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md +++ b/docs/project-memory/plans/【实施计划】AGC直连Codex Runtime迁移-2026-08-15.md @@ -164,12 +164,14 @@ 3. 回合结束时客户端只确定性复核安全与最低来源边界:可信平台图片、身份与同 Canvas 关系成立,`game/index.html` 存在,游戏源码至少引用一份已登记陶泥儿图片。随后由受限 Chromium 对 desktop/mobile 做真实运行、截图、异常、交互及 Canvas/WebGL 图片渲染观察,并把缺口回灌同一 Codex thread 有界整改。固定四切片、固定文件名和固定 `drawImage` 次数不再阻断完成;最终仍未观察到平台素材进入任一视口核心渲染时才拒绝登记版本。详细契约以第 13 节为准。 4. 资源画布增加“游戏代码”分区。HTML、CSS、JavaScript 和其它可执行源码进入该分区;玩法说明、配置和 Agent 文本回执仍归“文档”。已有资源布局若没有代码分区位置,将由现有布局 reconcile 自动补位。 5. 产品界面只展示“陶泥儿”“智能创作”等产品术语;Codex app-server 仅保留为内部执行实现和开发诊断名称,不在普通项目聊天、首页创建状态或设置说明中直接暴露。 -6. 每次直连回合在游戏代码复核与登记成功后,必须在项目写锁内推进一次 durable project revision,并以该 revision 创建首个 `initial-*` 或后续 `agent-*` 正式版本。禁止修改 manifest 后沿用旧 revision;外层工作台会把同 revision 的不同 manifest 判为冲突并拒绝投影,表现为磁盘已有游戏代码和版本、客户端仍显示 0 项。 -7. direct app-server 禁用 shell / unified exec 时仍必须能可靠修改已有游戏。每轮系统提示词在大段仓库文档之前注入当前 `game/index.html`、`game/style.css`、`game/game.js` 的有界脱敏快照,使 Codex 的原生 file-change 能基于真实旧内容生成补丁;不得退化为猜测变量名的盲补丁。回合前后以三个代码文件的内容指纹判断是否发生真实修改;无修改且 manifest 已同步的回合不得推进 revision 或伪造新项目版本。 +6. 项目与首页 direct 系统提示词必须把对外产品身份固定为“陶泥儿”。询问身份、名称或能力时以陶泥儿自称,不把 Codex、ChatGPT、OpenAI、模型或通用 AI 助手当作名称;只有用户明确询问底层实现时才可说明内部使用 Codex app-server,并继续保持陶泥儿这一对外身份。direct 缺失 system message 时使用同一陶泥儿兜底,legacy ToolHost 的内部 Codex 技术提示保持独立。 +7. 每次直连回合在游戏代码复核与登记成功后,必须在项目写锁内推进一次 durable project revision,并以该 revision 创建首个 `initial-*` 或后续 `agent-*` 正式版本。禁止修改 manifest 后沿用旧 revision;外层工作台会把同 revision 的不同 manifest 判为冲突并拒绝投影,表现为磁盘已有游戏代码和版本、客户端仍显示 0 项。 +8. direct app-server 禁用 shell / unified exec 时仍必须能可靠修改已有游戏。每轮系统提示词在大段仓库文档之前注入当前 `game/index.html`、`game/style.css`、`game/game.js` 的有界脱敏快照,使 Codex 的原生 file-change 能基于真实旧内容生成补丁;不得退化为猜测变量名的盲补丁。回合前后以三个代码文件的内容指纹判断是否发生真实修改;无修改且 manifest 已同步的回合不得推进 revision 或伪造新项目版本。 ### 验收 - Rust 单测覆盖:可信完整图集缺少固定切片仍可进入 Codex;只有规范图和背景图但历史图集不可安全恢复时不得重复发起付费生成;缺可信平台图片、来源身份不成立、PNG 不可解码或源码完全没有平台素材引用时继续拒绝完成。浏览器证据测试必须区分旁侧 `` 与 Canvas/WebGL 实际渲染,并证明 desktop/mobile 任一视口缺少核心素材观察都会回灌同 thread 整改。 +- 身份合同测试覆盖项目 prompt、首页 prompt 与 direct 缺省 baseInstructions 均以陶泥儿为对外身份,同时证明 legacy ToolHost 的内部 Codex fallback 未被改写。客户端完整重启后,用同一项目分别询问“你是谁”和“你是不是 Codex”,回复必须以陶泥儿自称;仅在第二问可补充底层执行技术。 - 资源投影与 AppSurface 测试覆盖:`game/index.html`、`game/style.css`、`game/game.js` 显示于“游戏代码”,不再显示于“文档”。 - 连续两次直连回合必须保留同一批游戏代码资产 ID,project revision 单调推进,并形成 `initial-* -> agent-*` 的父子版本链;外层资源管理在新 revision 到达后显示游戏代码和项目版本,不能停留在生成前快照。 - 已有项目修改测试必须证明系统提示词包含有界当前游戏源码、敏感行被过滤,基于该上下文的 file-change 可以命中;Codex 明确未修改文件时,revision 与版本数量保持不变。 @@ -357,3 +359,49 @@ DirectHome 继续禁用 MCP、命令和写入。DirectProject 仍禁用通用 sh - 同一真实项目已由 Codex 把平台背景、规范图棋子和核心图集实际接入 `game/index.html / style.css / game.js`。真实 Chromium 报告 `passed=true`:desktop/mobile 均为 `readyState=complete`、Canvas 非空、无 console error 与 exception;desktop 仅有非致命 `favicon.ico` 404。两张截图确认桌面和手机均完整显示甜点星球三消画面,且结构化运行时证据观察到平台图片进入渲染。 - 真实复跑同时暴露并修复两个收尾缺陷:DirectProject 不能沿用普通 LLM 的 180 秒整回合超时,现改为 15 分钟基础空闲窗口、MCP 工具活动期 110 分钟空闲窗口、整个 turn 120 分钟硬上限;DirectHome 与旧 ToolHost 继续保持原超时。系统提示词和浏览器整改回灌同时明确 shell/unified_exec 被安全禁用时应使用已注入的游戏文件快照与结构化证据,不得误报“没有读取工具所以无法验收”,也不得要求 Codex 直接保存 `.agent` 版本。定向回归为 Direct Runtime 35/35、Codex app-server 23/23(另 1 项真实账号测试按设计 ignored)。 - 最后一轮改后 GUI 复验在桌面控制被物理 Escape 中止后未继续自动操作;非 UI CLI 又因不继承 GUI 登录态而得到 `usage-limit-exceeded`。因此本节只把已落盘的真实生图、幂等复用和双视口浏览器证据记为已完成,不把改后最终聊天回复或新增项目版本伪报为已验收;下次从当前客户端发送普通项目消息即可复核新的等待窗口与证据回灌文案。 + +## 15. Direct Interaction Event v1 合同(2026-08-22) + +### 15.1 现役缺口与目标 + +现役 direct 链路虽然在 app-server 内部能接收消息增量,但普通项目聊天仍主要等待 Tauri command 完整返回后才展示助手正文;旧 `game-creator-agent-progress` 只能表达少量阶段,不能作为 direct 回合的正文增量、顺序、终态和隔离合同。本次新增 Direct Interaction Event v1,让用户在终态返回前看到安全活动状态和已产生的用户可见回复,不恢复 Supervisor 或引入第二个 Runtime 真相源。 + +### 15.2 事件与字段 + +Tauri 事件名固定为 `game-creator-direct-turn-update`,payload 只包含以下字段: + +- `projectPath`:发起回合的项目键,只用于本地路由与隔离,不渲染到用户文案。 +- `turnId`:本次 direct 提交的稳定回合标识,与项目键共同定位唯一的临时回复。 +- `sequence`:回合内严格递增的非负整数,用于拒绝重复、迟到和乱序回退。 +- `status`:只允许 `accepted | running | streaming | finalizing | completed | failed`。 +- `activity`:只允许 `request-accepted | understanding | project-inspection | file-change | controlled-tool | validation | response-finalization | none` 这些安全类别;它是粗粒度活动标识,不是工具日志。 +- `accumulatedText`:截至当前序号的完整助手可见正文,前端原位替换临时回复,不将其追加为多条消息。 +- `updatedAt`:事件产生时间,只用于展示和诊断,不参与顺序判定。 + +`activity` 及其对应的展示文案不得泄漏模型 reasoning、工具原始参数、内部路径、Provider、认证信息或未脱敏错误。`accumulatedText` 只能来自助手面向用户的正文增量,不得混入 reasoning、tool call/item、raw arguments、stderr 或内部诊断。 + +app-server 的 `turn/plan/updated`、reasoning summary、MCP progress、文件 patch/output、命令 output 和验证类通知只能按通知方法名映射为上述固定 `activity`,不得把通知 params 传入 observer。连续同类高频活动应在进入 turn channel 前有界合并;该合并不得影响 `AgentMessageDelta` 正文和 terminal 终态。 + +### 15.3 生命周期与前端门禁 + +1. 每次 direct 提交必须先建立 `projectPath + turnId` 的临时回复,生命周期按 `accepted -> running -> streaming -> finalizing -> completed` 前进;没有正文增量时可跳过 `streaming`,任一非终态都可进入 `failed`,终态后不再接受该回合事件。 +2. 前端只处理 `projectPath` 等于当前项目且 `turnId` 等于当前活动回合的事件。对同一 `projectPath + turnId`,只接受 `sequence` 大于已接收最大值的事件;时间戳更新不能绕过该单调门禁。 +3. 组件存活期内可保留一个全局 Tauri 事件监听。切换项目、切换活动 `turnId` 或 command 完成、失败时,必须清理对应的临时回复、活动文案和最大 `sequence` 等回合关联状态;组件卸载时再清理该全局监听。迟到事件不得污染新项目或新回合。 +4. `failed` 必须结束流式态并清理未完成正文,失败展示继续经现有安全错误映射,不把增量文本伪装成已完成回复。 +5. WorkspaceLauncher 实际项目工作台必须在消息列表内渲染持续可见的同回合过程卡:没有正文时展示当前安全活动,正文 delta 到达后在同一卡内展开累计正文。过程卡不能退化为输入框下方的小号 workspace 状态;用户位于列表底部时活动和正文更新应自动跟随,用户主动上滚后不得强制拉回。 + +### 15.4 权威与持久化边界 + +`game-creator-direct-turn-update` 是 Tauri 进程内的易失通知,只用于提升当前页面的过程可见性;不将事件本身写入项目对话、manifest 或其它 durable 状态,不用它推导跨进程回合已完成。 + +Tauri command 成功返回的 final `String` 是本次回合唯一的终态助手正文和持久化权威。前端收到 command 结果后,用该 `String` 原位收口同一 `turnId` 的临时回复,并且只持久化一条最终 assistant 消息。`completed.accumulatedText` 仍只是临时展示,不得先行或重复持久化,也不得覆盖 command 的 final `String`。 + +本合同是 direct 链路的独立交互投影,不复用 legacy `AgentRuntimeResult`,不恢复 Supervisor 的任务、receipt 或消息真相。本次只保证当前 Tauri command 存活期内的流式展示与最终持久化,不宣称已实现 durable reconnect、跨客户端恢复增量或重连后继续同一未完回合。 + +### 15.5 验收合同 + +- terminal command 返回前,用户消息下方必须持续渲染同回合过程卡;纯工具阶段显示安全活动,真实正文 delta 到达时在同一卡内原位更新临时回复。 +- 对同一 `projectPath + turnId` 注入重复、倒序和迟到的 `sequence`,页面必须拒绝小于或等于已接收最大值的事件,不得发生正文或状态回退。 +- command 成功后只展示并持久化一条以 final `String` 为正文的 assistant 消息;临时回复、`completed` 事件和 command result 不得形成多条最终消息。 +- command 失败、`failed`、项目切换和回合切换均必须清理临时回复、活动状态和序号门禁;组件卸载时必须清理全局监听;旧事件不得出现在新上下文。 +- 单测、AppSurface 回归与真实客户端验收均要覆盖 WorkspaceLauncher 实际工作台、长工具通知 replay、自动跟随和手动上滚保护;事件和 UI 文案不得出现 reasoning、raw arguments、tool item、stderr、内部路径、Provider、凭据或未脱敏错误。