diff --git a/apps/ai-game-creator-shell/package.json b/apps/ai-game-creator-shell/package.json index c08cb6ac9..650ebf029 100644 --- a/apps/ai-game-creator-shell/package.json +++ b/apps/ai-game-creator-shell/package.json @@ -57,6 +57,7 @@ "react-colorful": "^5.8.0", "react-dom": "^19.0.0", "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "vite": "^6.2.0", "zustand": "^5.0.14" 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 1a9a984f2..e64b321dd 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -22,7 +22,9 @@ mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; mod direct_tool_bridge; +mod direct_tool_calls; mod direct_tools_mcp; +mod direct_turn_stream; mod generation; mod interaction; mod prompt; @@ -36,8 +38,9 @@ mod runtime_tools; mod skill_pack; use codex_app_server::*; pub(crate) use codex_app_server::{ + cancel_direct_codex_turn_at, direct_codex_canonical_project_identity_for_commands as direct_codex_canonical_project_identity, - direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, + direct_game_creator_codex_chat_at, direct_game_creator_home_codex_chat, DirectTurnCancelView, }; use codex_cli::*; pub(crate) use codex_cli::{ @@ -53,7 +56,9 @@ pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_thread_manager::*; pub(crate) use direct_tool_bridge::*; +pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; +pub(crate) use direct_turn_stream::*; pub(crate) use generation::*; pub(crate) use interaction::*; pub(crate) use prompt::*; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs index 7377f445c..25d1591e4 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/codex_app_server/mod.rs @@ -222,6 +222,12 @@ impl CodexTurnStartCancellation { self.maybe_interrupt(); } + /// app-server 连接是否还活着:句柄只剩 Weak 时说明进程已被回收,此时"终止"必须 + /// 明确报错,而不是静默成功让界面以为回合已经停了。 + fn app_server_alive(&self) -> bool { + self.inner.strong_count() > 0 + } + fn cancel(&self) { self.cancelled.store(true, Ordering::Release); self.maybe_interrupt(); @@ -576,8 +582,21 @@ enum CodexTurnEvent { #[derive(Clone, Debug, Eq, PartialEq)] pub(crate) enum DirectCodexTurnObservation { AccumulatedText(String), + /// 一个 assistant 文本段的当前累计全文。 + /// + /// `item_id` 是一次 assistant 消息的稳定身份:同一个 id 的后续 delta 属于**同一段**, + /// id 变了就是新的一段。回合流的"文本段 + 工具"顺序用它来分段,而不是按 delta 分。 + AgentMessageSegment { + item_id: String, + accumulated_text: String, + completed: bool, + }, IntermediateText(String), + /// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。 + Reasoning(String), Activity(&'static str), + /// 一条结构化工具调用(`item/started` 与 `item/completed` 各采一次,按 id 幂等)。 + ToolCall(crate::DirectToolCall), } #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -862,6 +881,37 @@ fn direct_codex_mcp_tool_intermediate_text(item: &serde_json::Value) -> String { /// (with the concrete command/tool/path) while tools run; it does not push /// plan/reasoning text deltas. Showing what the agent is actually doing is /// the only reliable way to make the execution phase feel alive. +/// 从 reasoning item 里抽明文思考文本:优先 `summary[].text`,其次 `content[].text`。 +/// +/// Codex 的 reasoning item 形如 +/// `{ "type": "reasoning", "summary": [...], "content": [{ "text": "..." }], "encrypted_content": ... }`, +/// 没有 `role` 字段;明文(至少 content/summary 之一)存在时我们才展示,拿不到就返回 None。 +fn direct_codex_item_reasoning_text(item: &serde_json::Value) -> Option { + if item.get("type").and_then(serde_json::Value::as_str) != Some("reasoning") { + return None; + } + let collect = |key: &str| -> Option { + let parts = item + .get(key)? + .as_array()? + .iter() + .filter_map(|entry| { + entry + .get("text") + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|text| !text.is_empty()) + }) + .collect::>(); + if parts.is_empty() { + None + } else { + Some(parts.join("\n\n")) + } + }; + collect("summary").or_else(|| collect("content")) +} + fn direct_codex_item_intermediate_text(item: &serde_json::Value) -> Option { const MAX_ITEM_TEXT_CHARS: usize = 240; let item_type = item @@ -2734,6 +2784,12 @@ impl CodexAppServerConnection { let _turn_guard = self.inner.turn_gate.lock().await; let mut request = request; let history_root = direct_history_root.unwrap_or(&self.inner.workspace_path); + // 工具调用卡片的 turnId 用 AGC 客户端回合 id(与实时事件、落盘条目同一口径), + // 不用 Codex app-server 自己的 turnId——前端要按它把卡片挂回对应的那一轮。 + let direct_tool_call_turn_id: Option = direct_client_turn_id + .map(str::trim) + .filter(|turn_id| !turn_id.is_empty()) + .map(str::to_string); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let current_prompt = direct_codex_current_user_prompt(&request).trim(); if current_prompt.is_empty() { @@ -2821,6 +2877,17 @@ impl CodexAppServerConnection { } let turn_start_cancellation = Arc::new(CodexTurnStartCancellation::new(&self.inner, &thread_id)); + // Direct 回合登记为"可终止":终止命令只作用在这一轮上,回合结束时自动注销。 + let _active_turn_guard = direct_tool_call_turn_id + .as_deref() + .filter(|_| self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject) + .map(|turn_id| { + register_active_direct_codex_turn( + direct_codex_active_turn_key(history_root), + turn_id, + Arc::clone(&turn_start_cancellation), + ) + }); let mut turn_start_guard = CodexTurnStartGuard { cancellation: Arc::clone(&turn_start_cancellation), armed: true, @@ -2938,6 +3005,18 @@ impl CodexAppServerConnection { observer(DirectCodexTurnObservation::AccumulatedText( streamed_text.clone(), )); + // 同一个 assistant item 的当前累计全文:回合流按 item 分段, + // 段内只追加、段间才换行,不能拿"整轮累计"当一段。 + let segment_text = direct_project_history + .accumulated_text_for(&item_id) + .unwrap_or_else(|| delta.clone()); + if !segment_text.trim().is_empty() { + observer(DirectCodexTurnObservation::AgentMessageSegment { + item_id: item_id.clone(), + accumulated_text: segment_text, + completed: false, + }); + } } if let Some(callback) = on_agent_message_delta.as_deref_mut() { callback(&platform_llm::LlmStreamDelta { @@ -3027,6 +3106,10 @@ impl CodexAppServerConnection { // 让执行期间聊天窗口显示“正在做什么”,而不是只 // 有活动状态来回跳动。completed 事件不再重复。 if !completed { + if let Some(reasoning) = direct_codex_item_reasoning_text(item) + { + observer(DirectCodexTurnObservation::Reasoning(reasoning)); + } if let Some(text) = direct_codex_item_intermediate_text(item) { observer(DirectCodexTurnObservation::IntermediateText( text, @@ -3042,6 +3125,24 @@ impl CodexAppServerConnection { completed, ¶ms, ); + // 工具调用卡片:item/started 与 item/completed 各采一次, + // 由下游按 id 幂等 upsert 成同一条。采集失败(拿不到 id / + // 非工具类 item)就静默跳过,不影响这一轮的其它投影。 + if let Some(turn_id) = direct_tool_call_turn_id.as_deref() { + if let Some(tool_call) = direct_tool_call_from_item( + history_root, + item, + turn_id, + completed, + direct_tool_call_now_ms(), + ) { + if let Some(observer) = direct_observer.as_deref_mut() { + observer(DirectCodexTurnObservation::ToolCall( + tool_call, + )); + } + } + } if completed { if let Some(audit) = audit.as_mut() { audit.observe_item(¶ms); @@ -3049,6 +3150,27 @@ impl CodexAppServerConnection { } } if item_type == "agentMessage" { + // 某些 app-server 实现会在工具开始后停止发送 agentMessage delta, + // 但会在 item/completed 携带完整文本。把这份最终快照补进回合流, + // 让流中的文本段不会停在工具前的短前缀。 + if completed { + if let (Some(item_id), Some(text)) = ( + item.get("id").and_then(serde_json::Value::as_str), + item.get("text") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.trim().is_empty()), + ) { + if let Some(observer) = direct_observer.as_deref_mut() { + observer( + DirectCodexTurnObservation::AgentMessageSegment { + item_id: item_id.to_string(), + accumulated_text: text.to_string(), + completed: true, + }, + ); + } + } + } if let Some(text) = item .get("text") .and_then(serde_json::Value::as_str) @@ -3086,17 +3208,32 @@ impl CodexAppServerConnection { } Some(CodexTurnEvent::Terminal(params)) => { let turn = params.get("turn").unwrap_or(¶ms); - if final_text.is_none() { - final_text = turn - .get("items") - .and_then(serde_json::Value::as_array) - .and_then(|items| { - items.iter().rev().find_map(|item| { - (item.get("type")?.as_str()? == "agentMessage") - .then(|| item.get("text")?.as_str().map(str::to_string)) - .flatten() - }) - }); + if let Some(items) = turn.get("items").and_then(serde_json::Value::as_array) + { + for item in items { + if item.get("type").and_then(serde_json::Value::as_str) + != Some("agentMessage") + { + continue; + } + if let Some(text) = item + .get("text") + .and_then(serde_json::Value::as_str) + .filter(|text| !text.trim().is_empty()) + { + final_text = Some(text.to_string()); + if let (Some(item_id), Some(observer)) = ( + item.get("id").and_then(serde_json::Value::as_str), + direct_observer.as_deref_mut(), + ) { + observer(DirectCodexTurnObservation::AgentMessageSegment { + item_id: item_id.to_string(), + accumulated_text: text.to_string(), + completed: true, + }); + } + } + } } let status = turn .get("status") @@ -3195,6 +3332,223 @@ impl Drop for CodexTurnStartGuard { } } +/// Direct 回合中断表:与具体取消句柄解耦的最小实现,"选哪一轮 / 注销哪一轮"可单测。 +struct DirectCodexActiveTurnTable { + entries: HashMap, +} + +impl DirectCodexActiveTurnTable { + fn new() -> Self { + Self { + entries: HashMap::new(), + } + } + + fn register(&mut self, key: std::path::PathBuf, client_turn_id: &str, value: T) { + self.entries + .insert(key, (client_turn_id.to_string(), value)); + } + + /// 只有当前登记项仍是本回合的句柄时才注销,避免旧回合的收尾清掉后来注册的回合。 + fn unregister(&mut self, key: &Path, is_same: impl Fn(&T) -> bool) { + if self + .entries + .get(key) + .is_some_and(|(_, value)| is_same(value)) + { + self.entries.remove(key); + } + } + + /// 选中要终止的回合:没有活动回合、或前端给的 clientTurnId 与活动回合不一致时都返回 + /// 可读原因,绝不误伤另一个回合。 + fn select(&self, key: &Path, client_turn_id: Option<&str>) -> Result<&(String, T), String> { + let active = self + .entries + .get(key) + .ok_or_else(|| "当前项目没有正在运行的陶泥儿回合,无法终止".to_string())?; + if let Some(expected) = client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if active.0 != expected { + return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string()); + } + } + Ok(active) + } + + /// 当前登记在这一轮上的 clientTurnId;没有任何登记时返回 `None`。 + fn registered_client_turn_id(&self, key: &Path) -> Option<&str> { + self.entries + .get(key) + .map(|(client_turn_id, _)| client_turn_id.as_str()) + } +} + +/// "正在跑的是另一轮"的统一文案:`select` 与"终止"兜底路径共用,保证两处拒绝语义一致。 +const DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE: &str = "正在运行的是另一个陶泥儿回合,已拒绝终止"; + +/// 正在运行的 Direct 回合中断句柄,按项目根(canonical,去掉 Windows `\\?\` 前缀)索引。 +/// +/// `CodexTurnStartCancellation` 本身已经能在 turn/start 响应到达**前后**发出 +/// `turn/interrupt`;这里只是把它留一个 Tauri 命令取得到的引用,回合结束后由 +/// [`DirectCodexActiveTurnGuard`] 移除。只做新增:不改既有事件、命令语义。 +static GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS: OnceLock< + std::sync::Mutex>>, +> = OnceLock::new(); + +fn direct_codex_active_turns( +) -> &'static std::sync::Mutex>> { + GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS + .get_or_init(|| std::sync::Mutex::new(DirectCodexActiveTurnTable::new())) +} + +/// 注册键:与 Direct 回合用的 `codex_root` 同一形态(canonical 且去掉 `\\?\` 前缀), +/// 这样前端传进来的项目路径与注册时的路径一定落到同一个键上。 +fn direct_codex_active_turn_key(root: &Path) -> std::path::PathBuf { + let canonical = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf()); + match canonical + .to_str() + .and_then(|value| value.strip_prefix("\\\\?\\")) + { + Some(stripped) => std::path::PathBuf::from(stripped), + None => canonical, + } +} + +struct DirectCodexActiveTurnGuard { + key: std::path::PathBuf, + cancellation: Arc, +} + +impl Drop for DirectCodexActiveTurnGuard { + fn drop(&mut self) { + let Some(active_turns) = GAME_CREATOR_DIRECT_CODEX_ACTIVE_TURNS.get() else { + return; + }; + let Ok(mut entries) = active_turns.lock() else { + return; + }; + let cancellation = Arc::clone(&self.cancellation); + entries.unregister(&self.key, |current| Arc::ptr_eq(current, &cancellation)); + } +} + +/// 把一个 Direct 回合登记为"可终止",返回的 guard 在回合结束时注销它。 +fn register_active_direct_codex_turn( + key: std::path::PathBuf, + client_turn_id: &str, + cancellation: Arc, +) -> DirectCodexActiveTurnGuard { + if let Ok(mut entries) = direct_codex_active_turns().lock() { + entries.register(key.clone(), client_turn_id, Arc::clone(&cancellation)); + } + DirectCodexActiveTurnGuard { key, cancellation } +} + +/// 已向正在跑的回合发出中断:界面等这一轮自己的收尾复位。 +pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED: &str = "interrupted"; +/// 这一轮已经没有人替它收尾,本地守卫已被兜底释放:界面必须自己复位。 +pub(crate) const DIRECT_TURN_CANCEL_OUTCOME_RELEASED: &str = "released"; + +/// `cancel_direct_codex_turn` 的返回值:界面据此决定是自己复位,还是等回合自己收尾。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectTurnCancelView { + /// [`DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED`] 或 + /// [`DIRECT_TURN_CANCEL_OUTCOME_RELEASED`]。 + pub(crate) outcome: String, + /// 给用户看的可读结果。 + pub(crate) message: String, + /// 被终止 / 被释放的 clientTurnId。 + pub(crate) client_turn_id: String, +} + +/// "终止"这一步要作用在哪:发中断,还是走残留守卫兜底释放。 +enum DirectCodexTurnCancelTarget { + /// app-server 侧还有活句柄:正常发 `turn/interrupt`。 + Interrupt(Arc), + /// app-server 侧已经拿不到可中断的活句柄;带上是哪种情况。 + Stale(DirectTaonierStaleGuardReason), +} + +/// 终止当前项目正在运行的 Direct 回合。 +/// +/// 正常路径:只向正在跑的 Codex app-server 回合发 `turn/interrupt`(app-server 随后回 +/// `turn/completed status=interrupted`,正在 await 的那个回合命令会带着可读原因返回), +/// 不动任何既有事件或命令语义。 +/// +/// 兜底路径:app-server 侧已经拿不到可中断的活句柄时,说明这一轮不会再有人替它收尾。 +/// 只发中断会让本地守卫(`DirectTaonierActiveInvocationGuard`)永远留在进程内,用户此后 +/// 每条消息都会被"已有另一条回合正在运行"拒绝——这正是"重进会话被堵死"的死锁形态。 +/// 这时显式释放这条守卫并把可读原因返回给界面。释放条件见 +/// [`release_stale_direct_taonier_active_invocation`] 的注释;"正在跑的是另一轮"仍然 +/// 保持原拒绝语义,什么都不释放。 +pub(crate) fn cancel_direct_codex_turn_at( + root: &Path, + client_turn_id: Option<&str>, +) -> Result { + let key = direct_codex_active_turn_key(root); + let expected = client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()); + { + let entries = direct_codex_active_turns() + .lock() + .map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?; + if let (Some(registered), Some(expected)) = + (entries.registered_client_turn_id(&key), expected) + { + if registered != expected { + return Err(DIRECT_CODEX_ANOTHER_TURN_RUNNING_MESSAGE.to_string()); + } + } + } + let target = { + let entries = direct_codex_active_turns() + .lock() + .map_err(|_| "Direct 回合中断表已损坏,无法终止".to_string())?; + match entries.select(&key, client_turn_id) { + Ok((_, cancellation)) if cancellation.app_server_alive() => { + DirectCodexTurnCancelTarget::Interrupt(Arc::clone(cancellation)) + } + Ok(_) => { + DirectCodexTurnCancelTarget::Stale(DirectTaonierStaleGuardReason::ExecutorExited) + } + Err(_) => DirectCodexTurnCancelTarget::Stale( + DirectTaonierStaleGuardReason::NeverReachedExecutor, + ), + } + }; + match target { + DirectCodexTurnCancelTarget::Interrupt(cancellation) => { + cancellation.cancel(); + Ok(DirectTurnCancelView { + outcome: DIRECT_TURN_CANCEL_OUTCOME_INTERRUPTED.to_string(), + message: "已向正在运行的回合发出终止".to_string(), + client_turn_id: client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or_default() + .to_string(), + }) + } + DirectCodexTurnCancelTarget::Stale(reason) => { + let released = + release_stale_direct_taonier_active_invocation(root, client_turn_id, reason)?; + Ok(DirectTurnCancelView { + outcome: DIRECT_TURN_CANCEL_OUTCOME_RELEASED.to_string(), + message: format!( + "{},已释放这一轮的占用,可以直接重新发送消息", + reason.message() + ), + client_turn_id: released, + }) + } + } +} + struct CodexThreadLease { connection: CodexAppServerConnection, key: CodexNodeThreadKey, @@ -4100,6 +4454,52 @@ pub(crate) fn build_direct_codex_history_prompt( mod tests { use super::*; + /// 终止只作用在"当前项目正在跑的那一轮"上:没有活动回合 / clientTurnId 不匹配都要 + /// 返回可读原因,不能误伤别人;注销也只注销本回合自己的句柄。 + #[test] + fn direct_codex_active_turn_table_selects_only_the_running_turn() { + let mut table: DirectCodexActiveTurnTable = DirectCodexActiveTurnTable::new(); + let key = std::path::PathBuf::from("C:/projects/direct-turn-demo"); + assert_eq!( + table.select(&key, None).expect_err("no active turn"), + "当前项目没有正在运行的陶泥儿回合,无法终止" + ); + + table.register(key.clone(), "turn-a", 1); + assert_eq!(table.select(&key, None).expect("active turn").0, "turn-a"); + assert_eq!(table.select(&key, Some("turn-a")).expect("same turn").1, 1); + assert_eq!( + table + .select(&key, Some("turn-b")) + .expect_err("another running turn"), + "正在运行的是另一个陶泥儿回合,已拒绝终止" + ); + + // 句柄已被后来的回合替换:旧回合收尾不得注销新回合。 + table.register(key.clone(), "turn-b", 2); + table.unregister(&key, |value| *value == 1); + assert_eq!(table.select(&key, None).expect("newer turn").0, "turn-b"); + table.unregister(&key, |value| *value == 2); + assert!(table.select(&key, None).is_err()); + } + + /// 注册键:前端传的项目路径与回合注册时的路径必须归一化成同一个键(Windows 上 + /// `canonicalize` 会带 `\\?\` 前缀,去掉后两边才相等)。 + #[test] + fn direct_codex_active_turn_key_normalizes_windows_prefix() { + let root = tempfile::tempdir().expect("temp dir"); + let canonical = std::fs::canonicalize(root.path()).expect("canonical root"); + let expected = canonical + .to_str() + .and_then(|value| value.strip_prefix("\\\\?\\")) + .map(std::path::PathBuf::from) + .unwrap_or(canonical); + let key = direct_codex_active_turn_key(root.path()); + assert_eq!(key, expected); + // 归一化后的键不再带 Windows 扩展长度前缀:前端传进来的普通路径才能命中同一个键。 + assert!(!key.to_string_lossy().starts_with("\\\\?\\")); + } + #[test] fn direct_thread_item_projection_drops_full_app_server_payload() { let item = serde_json::json!({ diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 24b3aed74..130c01549 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -5,6 +5,7 @@ use crate::project::{ }; use crate::{LocalConversationMessageRecord, LocalConversationResult}; use serde_json::Value; +use std::collections::BTreeMap; use std::fs::{self, File}; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; @@ -91,6 +92,10 @@ fn record(item: &Value) -> Result { serde_json::to_string(&serde_json::json!({ "type": DIRECT_PROJECT_HISTORY_RECORD_TYPE, "payload": item, + "recordedAt": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_millis() as u64) + .unwrap_or(0), })) .map_err(|error| format!("序列化 DirectProject 历史失败:{error}")) } @@ -470,6 +475,13 @@ fn direct_project_message_item(role: &str, content: &str, message_id: Option<&st } pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, String> { + Ok(read_direct_project_history_entries_at(root)? + .into_iter() + .map(|(item, _)| item) + .collect()) +} + +fn read_direct_project_history_entries_at(root: &Path) -> Result, String> { let path = history_path(root); if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { return Ok(Vec::new()); @@ -507,7 +519,13 @@ pub(crate) fn read_direct_project_history_items_at(root: &Path) -> Result, limit: usize, -) -> Result<(Vec, bool), String> { - let items = read_direct_project_history_items_at(root)?; +) -> Result<(Vec, bool, BTreeMap), String> { + let items = read_direct_project_history_entries_at(root)?; let end = match before_item_id { Some(item_id) => items .iter() - .position(|item| item.get("id").and_then(Value::as_str) == Some(item_id)) + .position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id)) .ok_or_else(|| format!("DirectProject 历史中不存在 item:{item_id}"))?, None => items.len(), }; let bounded_limit = limit.clamp(1, 200); let start = end.saturating_sub(bounded_limit); - Ok((items[start..end].to_vec(), start > 0)) + let slice = &items[start..end]; + let timestamps = slice + .iter() + .filter_map(|(item, at)| { + let id = item.get("id").and_then(Value::as_str)?; + (*at > 0).then(|| (id.to_string(), *at)) + }) + .collect(); + Ok(( + slice.iter().map(|(item, _)| item.clone()).collect(), + start > 0, + timestamps, + )) } pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result, String> { @@ -546,10 +576,10 @@ pub(crate) fn read_direct_project_chat_history_at( root: &Path, ) -> Result { let path = history_path(root); - let items = read_direct_project_history_items_at(root)?; + let items = read_direct_project_history_entries_at(root)?; let messages = items .into_iter() - .filter_map(|item| { + .filter_map(|(item, recorded_at)| { let role = item.get("role").and_then(Value::as_str)?; if !matches!(role, "user" | "assistant") { return None; @@ -571,7 +601,7 @@ pub(crate) fn read_direct_project_chat_history_at( content, agent_id: None, message_id: item.get("id").and_then(Value::as_str).map(str::to_string), - updated_at: 0, + updated_at: recorded_at, }) }) .collect(); @@ -610,6 +640,40 @@ mod tests { const RESPONSE_ITEM_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"codex-item-2","content":[{"type":"input_text","text":"再加一个按钮"}]}}"#; const RESPONSE_ASSISTANT_ROW: &str = r#"{"type":"response_item","payload":{"type":"message","role":"assistant","content":[{"type":"output_text","text":"已完成"}]}}"#; + #[test] + fn history_timestamps_survive_reload_and_idempotent_append_without_changing_raw_items() { + let root = init_history_project("history-time"); + let item = json!({ + "type": "message", "role": "user", "id": "sent-message", + "content": [{"type": "input_text", "text": "修改游戏"}], + }); + append_direct_project_user_message_at(root.path(), &item).unwrap(); + let (items, _, timestamps) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert_eq!(items, vec![item.clone()]); + assert!(timestamps["sent-message"] > 0); + append_direct_project_user_message_at(root.path(), &item).unwrap(); + let (_, _, reloaded) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert_eq!(timestamps, reloaded); + } + + #[test] + fn old_history_without_envelope_time_stays_unknown() { + let root = init_history_project("history-unknown-time"); + write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); + let (_, _, timestamps) = + super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); + assert!(timestamps.is_empty()); + assert_eq!( + read_direct_project_chat_history_at(root.path()) + .unwrap() + .messages[0] + .updated_at, + 0 + ); + } + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs index c2c317692..c41b19de0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_turn_history.rs @@ -22,6 +22,14 @@ impl DirectProjectHistoryAccumulator { } } + /// 某个 assistant item 目前累计到的全文。 + /// + /// 回合流按 item 分段:同一个 item 的后续 delta 是同一段的增长,item 变了才是新的一段。 + /// 没有这条 item(非 DirectProject 工作区、或已经 complete)时返回 `None`。 + pub(crate) fn accumulated_text_for(&self, item_id: &str) -> Option { + self.text_by_item_id.get(item_id).cloned() + } + fn take_partial_items(&mut self) -> impl Iterator + '_ { std::mem::take(&mut self.text_by_item_id) .into_iter() diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs index 7f743d89b..a515954cb 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/mod.rs @@ -1,5 +1,6 @@ use super::*; use base64::Engine as _; +use std::collections::BTreeMap; use std::collections::HashMap; use std::future::Future; use std::io::Write; @@ -292,6 +293,16 @@ static DIRECT_TAONIER_ACTIVE_INVOCATIONS: OnceLock< Mutex>, > = OnceLock::new(); +/// 一条"app-server 侧完全没有登记"的守卫,只有在存在时间超过这个量级后才允许被 +/// "终止"兜底释放。一轮 Direct 回合在进入 app-server 之前只做本地准备(读配置、 +/// 读 manifest、拼系统提示、开审计),是秒级的;超过这个窗口还没登记,说明这一轮 +/// 不可能再进入执行器,守卫是残留。 +const DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS: u64 = 60_000; + +fn direct_taonier_active_now_millis() -> u64 { + unix_millis().min(u128::from(u64::MAX)) as u64 +} + #[derive(Debug)] pub(crate) struct DirectTaonierActiveInvocationGuard { root: PathBuf, @@ -314,8 +325,9 @@ impl DirectTaonierActiveInvocationGuard { "{DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX} 当前 Direct 客户端回合仍在运行,已拒绝并发复用同一 clientTurnId" ) } else { - "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份" - .to_string() + format!( + "当前项目已有另一条 Direct 客户端回合正在运行,已拒绝混用付费生成身份;可在输入盒点「终止」结束它,或等它结束后再发送" + ) }); } None => { @@ -432,6 +444,106 @@ pub(crate) fn direct_taonier_active_invocation_id_at(root: &Path) -> Result Result, String> { + let root = root + .canonicalize() + .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; + Ok(DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| "Direct 调用身份锁已损坏".to_string())? + .get(&root) + .map(|active| DirectActiveTurnView { + client_turn_id: active.invocation_id.clone(), + started_at: active.started_at, + })) +} + +/// "终止"拿不到可中断句柄时的分类,决定是否允许强制释放本地守卫。 +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum DirectTaonierStaleGuardReason { + /// app-server 侧登记着这一轮,但执行进程已经退出:这一轮不可能再有收尾。 + ExecutorExited, + /// app-server 侧完全没有这一轮的登记:只有过了正常启动窗口才允许释放。 + NeverReachedExecutor, +} + +impl DirectTaonierStaleGuardReason { + pub(crate) fn message(self) -> &'static str { + match self { + Self::ExecutorExited => "陶泥儿执行进程已退出", + Self::NeverReachedExecutor => "这一轮 Direct 回合没有进入执行器", + } + } +} + +/// 强制释放某项目登记的 Direct 活跃回合占用("终止"的兜底出口)。 +/// +/// 释放条件(四条必须同时成立,这段注释就是契约): +/// 1. 项目路径能 canonicalize,且守卫表里确实登记了这一轮; +/// 2. 传了 `expected_client_turn_id` 时必须与登记一致——绝不误伤另一条回合; +/// 3. 调用方已确认 app-server 侧没有可中断的活句柄,即 `reason` 成立; +/// 4. `reason == NeverReachedExecutor` 时,这条登记的年龄必须超过 +/// [`DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS`],排除"刚进入、还在本地准备阶段" +/// 的正常启动窗口——那种情况下这一轮马上就会去执行器,释放等于放开并发。 +/// +/// 移除后原守卫的 `Drop` 变成空操作(`invocation_id` 已不在表里),所以释放是幂等的; +/// 释放只影响"能否开始新回合",不动任何正在跑的回合事件。 +pub(crate) fn release_stale_direct_taonier_active_invocation( + root: &Path, + expected_client_turn_id: Option<&str>, + reason: DirectTaonierStaleGuardReason, +) -> Result { + let root = root + .canonicalize() + .map_err(|error| format!("无法锚定 Direct 调用项目目录:{error}"))?; + let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .map_err(|_| "Direct 调用身份锁已损坏,无法释放".to_string())?; + let Some(existing) = active.get(&root) else { + return Err("当前项目没有正在运行的陶泥儿回合,无法终止".to_string()); + }; + if let Some(expected) = expected_client_turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + { + if existing.invocation_id != expected { + return Err("正在运行的是另一条 Direct 客户端回合,已拒绝终止".to_string()); + } + } + if reason == DirectTaonierStaleGuardReason::NeverReachedExecutor { + let age_ms = direct_taonier_active_now_millis().saturating_sub(existing.started_at); + if age_ms < DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS { + return Err(format!( + "这一轮 Direct 客户端回合刚开始 {} 秒、还在准备中,暂不能强制释放;请稍后再试", + age_ms / 1000 + )); + } + } + let released = existing.invocation_id.clone(); + active.remove(&root); + Ok(released) +} + fn direct_taonier_regeneration_project_id(root: &Path) -> Result { let project_id = read_manifest(&root.join(".agent/manifest.json"))? .project_id @@ -713,7 +825,9 @@ fn prepare_direct_taonier_regeneration_workflow_at( "direct-codex.taonier-package-workflow-prepare", )?; match read_direct_taonier_regeneration_workflow_at(root).map_err(|error| { - format!("{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法读取陶泥儿整包重生成工作流:{error}") + format!( + "{DIRECT_TAONIER_RESULT_UNKNOWN_PREFIX} 无法读取陶泥儿整包重生成工作流:{error}" + ) })? { Some(existing) => match existing.state { DirectTaonierRegenerationWorkflowState::Resetting => { @@ -789,9 +903,7 @@ fn prepare_direct_taonier_regeneration_workflow_at( "{DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX} 整包重生成补偿状态缺少 durable rollback journal" ) })?; - DirectTaonierRegenerationWorkflowPreparation::Compensate { - rollback, - } + DirectTaonierRegenerationWorkflowPreparation::Compensate { rollback } } DirectTaonierRegenerationWorkflowState::Completed => { if existing.invocation_sha256 == invocation_sha256 { @@ -3403,8 +3515,8 @@ pub(crate) async fn ensure_direct_taonier_art_package_at( Some(rollback), format!( "{DIRECT_TAONIER_LOCAL_RECONCILIATION_PREFIX} 无法锚定本轮新背景图,已停止整包重生成:{error}" - ), - )); + ), + )); } if let Some(workflow) = regeneration_workflow.as_mut() { if let Err(error) = @@ -4143,7 +4255,9 @@ fn sync_direct_codex_project_outputs_at( /// Project Codex text for the user-visible DirectProject stream and reply. /// Reasoning wrappers are still removed because they are not reply text, but /// the user owns the project and the resulting reply is not redacted here. -fn project_direct_codex_visible_text(value: &str) -> Option { +/// +/// `pub(crate)`:回合流(`direct_turn_stream`)落最终回复前复用同一套可见性投影。 +pub(crate) fn project_direct_codex_visible_text(value: &str) -> Option { let stripped = strip_incomplete_direct_thinking_marker(&strip_llm_thinking_blocks(value)); if stripped.trim().is_empty() { return None; @@ -4379,7 +4493,7 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( direct_creation_type_system_context(creation_type)?; emit_direct_game_creator_progress(root, "request.accepted", "已发送消息,正在等待陶泥儿回复"); if let Some(emitter) = turn_emitter { - emitter.emit("accepted", Some("request-accepted"), None); + emitter.emit("accepted", Some("request-accepted"), None, None); } match run_direct_game_creator_turn_inner( root, @@ -4405,13 +4519,182 @@ async fn run_direct_game_creator_turn_at_with_creation_type_and_emitter( let _ = persist_direct_codex_failure_context(root, emitter.turn_id(), &error); } if let Some(emitter) = turn_emitter { - emitter.emit("failed", Some("none"), None); + // 失败说明也是这一回合的内容:按出现顺序追加到回合流末尾, + // 这样"流里已经是完整内容"这一点对失败回合同样成立。 + let failure_item = append_direct_turn_stream_text_at( + root, + emitter.turn_id(), + DIRECT_TURN_STREAM_FAILURE_ITEM_ID, + &error, + ) + .ok() + .flatten() + .into_iter() + .collect::>(); + emitter.emit_with_stream_items("failed", Some("none"), None, None, failure_item); } Err(error) } } } +/// 本回合累积的工具调用条目(观察者写、回合末读)。 +type DirectToolCallCollector = std::sync::Arc>>; + +fn lock_direct_tool_call_collector( + collector: &DirectToolCallCollector, +) -> std::sync::MutexGuard<'_, Vec> { + collector + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// 单条工具调用落盘:走阻塞线程池(写文件要拿项目锁,不能在 async 运行时上直接跑)。 +/// 失败只返回错误交给调用方忽略,不打断回合。 +fn spawn_persist_direct_tool_call(root: &Path, call: &DirectToolCall) { + let root = root.to_path_buf(); + let call = call.clone(); + tauri::async_runtime::spawn_blocking(move || persist_direct_tool_call_at(&root, &call)); +} + +/// 回合结束整批落盘;失败时退回逐条 upsert,尽量把能写的写进去。 +fn persist_collected_direct_tool_calls(root: &Path, collector: &DirectToolCallCollector) { + let calls = { + let collected = lock_direct_tool_call_collector(collector); + collected.clone() + }; + if calls.is_empty() { + return; + } + if persist_direct_tool_calls_at(root, &calls).is_ok() { + return; + } + for call in &calls { + let _ = persist_direct_tool_call_at(root, call); + } +} + +/// 回合流条目落盘:与工具调用同一口径(阻塞线程池 + 项目锁)。 +fn spawn_persist_direct_turn_stream_item( + root: &Path, + item: &DirectTurnStreamItem, +) -> tauri::async_runtime::JoinHandle> { + let root = root.to_path_buf(); + let item = item.clone(); + tauri::async_runtime::spawn_blocking(move || upsert_direct_turn_stream_item_at(&root, &item)) +} + +/// 文本段落盘/下发的节流间隔:文本段是"整段累计 + 原地替换",不需要逐 delta 落盘。 +const DIRECT_TURN_STREAM_TEXT_THROTTLE_MS: u128 = 300; + +/// 正在增长的那一段文本。 +struct DirectTurnStreamPendingText { + /// 这一段对应的 Codex assistant item id(段身份)。 + item_id: String, + item: DirectTurnStreamItem, + last_flush: std::time::Instant, +} + +/// 回合流的写入与下发状态(观察者持有)。 +/// +/// `seq_by_id` 是**顺序真相的本体**:条目 id 第一次出现时分配序号,之后所有更新都带同一个 +/// 序号,所以并发落盘的先后不会改变渲染顺序(不会出现"新工具插到旧文本前面")。 +struct DirectTurnStreamWriter { + turn_id: String, + seq_by_id: BTreeMap, + next_seq: u64, + last_updated_at: u64, + pending_text: Option, +} + +impl DirectTurnStreamWriter { + fn new(turn_id: String) -> Self { + Self { + turn_id, + seq_by_id: BTreeMap::new(), + next_seq: 0, + last_updated_at: 0, + pending_text: None, + } + } + + /// 条目 id 对应的固定序号:首次出现时分配,之后永远不变。 + fn seq_for(&mut self, id: &str) -> u64 { + if let Some(seq) = self.seq_by_id.get(id) { + return *seq; + } + self.next_seq += 1; + self.seq_by_id.insert(id.to_string(), self.next_seq); + self.next_seq + } + + /// 按 item 身份更新,段切换必须同时交出旧段尾快照与新段首快照。 + fn push_text( + &mut self, + root: &Path, + item_id: &str, + visible_text: &str, + now_ms: u64, + completed: bool, + ) -> Vec { + let now = std::time::Instant::now(); + let mut snapshots = Vec::new(); + self.last_updated_at = now_ms.max(self.last_updated_at.saturating_add(1)); + if self + .pending_text + .as_ref() + .is_some_and(|pending| pending.item_id != item_id) + { + snapshots.extend(self.take_pending_snapshot()); + } + if let Some(pending) = self.pending_text.as_mut() { + pending.item.text = Some(sanitize_stream_text(root, visible_text)); + pending.item.updated_at = self.last_updated_at; + if completed + || now.duration_since(pending.last_flush).as_millis() + >= DIRECT_TURN_STREAM_TEXT_THROTTLE_MS + { + pending.last_flush = now; + snapshots.push(pending.item.clone()); + } + } else { + let seq = self.seq_for(&direct_turn_stream_text_item_id(&self.turn_id, item_id)); + let item = direct_turn_stream_text_item( + root, + &self.turn_id, + item_id, + visible_text, + seq, + now_ms, + self.last_updated_at, + ); + snapshots.push(item.clone()); + self.pending_text = Some(DirectTurnStreamPendingText { + item_id: item_id.to_string(), + item, + last_flush: now, + }); + } + snapshots + } + + /// 取出当前段的收尾快照(段结束 / 回合结束时调用),不再持有它。 + fn take_pending_snapshot(&mut self) -> Option { + self.pending_text.take().map(|pending| pending.item) + } + + /// 工具条目:只记位置,正文仍来自 `tool-calls.jsonl`。 + fn push_tool(&mut self, call: &DirectToolCall, now_ms: u64) -> DirectTurnStreamItem { + let seq = self.seq_for(&direct_turn_stream_tool_item_id(&self.turn_id, &call.id)); + let at = if call.started_at > 0 { + call.started_at + } else { + now_ms + }; + direct_turn_stream_tool_item(&self.turn_id, call, seq, at) + } +} + async fn run_direct_game_creator_turn_inner( root: &Path, prompt: &str, @@ -4422,8 +4705,11 @@ async fn run_direct_game_creator_turn_inner( ) -> Result { emit_direct_game_creator_progress(root, "codex.turn", "陶泥儿正在处理这条消息"); if let Some(emitter) = turn_emitter { - emitter.emit("running", Some("preparing"), None); + emitter.emit("running", Some("preparing"), None, None); } + // 本回合累积的工具调用条目:观察者增量采集,回合结束时整批落盘(幂等 upsert)。 + // 实时下发与落盘共用同一份数据,避免两处各采集一次产生口径差。 + let tool_calls: DirectToolCallCollector = Arc::new(Mutex::new(Vec::new())); let stream_enabled = load_game_creator_app_config() .map(|config| config.llm.stream) .map_err(|error| { @@ -4437,8 +4723,12 @@ async fn run_direct_game_creator_turn_inner( let reply = if let Some(emitter) = turn_emitter { let client_turn_id = emitter.turn_id().to_string(); let emitter = emitter.clone(); - let observer_emitter = emitter.clone(); - let mut observer = move |observation: DirectCodexTurnObservation| { + let turn_root = root.to_path_buf(); + let turn_tool_calls = Arc::clone(&tool_calls); + // 回合流:文本段与工具按**出现顺序**各占一行,位置(seq)在首次出现时钉死。 + let mut stream_writer = DirectTurnStreamWriter::new(client_turn_id.clone()); + let mut stream_writes = Vec::new(); + let mut observer = |observation: DirectCodexTurnObservation| { let status = direct_codex_observation_status(&observation, stream_enabled); match observation { DirectCodexTurnObservation::AccumulatedText(accumulated_text) => { @@ -4447,7 +4737,31 @@ async fn run_direct_game_creator_turn_inner( if visible_text.is_none() { return; } - observer_emitter.emit(status, None, visible_text); + emitter.emit(status, None, visible_text, None); + } + DirectCodexTurnObservation::AgentMessageSegment { + item_id, + accumulated_text, + completed, + } => { + // 可见文本段:同一 item 的后续 delta 就地增长,item 变了才新起一段。 + let Some(visible_text) = project_direct_codex_visible_text(&accumulated_text) + else { + return; + }; + let items = stream_writer.push_text( + &turn_root, + &item_id, + &visible_text, + direct_tool_call_now_ms(), + completed, + ); + for item in &items { + stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, item)); + } + if !items.is_empty() { + emitter.emit_with_stream_items(status, None, None, None, items); + } } DirectCodexTurnObservation::IntermediateText(intermediate_text) => { let visible_text = if stream_enabled @@ -4458,18 +4772,73 @@ async fn run_direct_game_creator_turn_inner( None }; if let Some(visible_text) = visible_text { - observer_emitter.emit(status, None, Some(visible_text)); + emitter.emit(status, None, Some(visible_text), None); } } DirectCodexTurnObservation::Activity(activity) => { - observer_emitter.emit(status, Some(activity), None); + emitter.emit(status, Some(activity), None, None); + } + DirectCodexTurnObservation::Reasoning(reasoning) => { + // 思考过程按"当前累计全文"下发(前端整段替换),状态保持 running: + // streaming 已被"用户可见正文"占用。 + emitter.emit_with_reasoning("running", None, None, None, Some(reasoning)); + } + DirectCodexTurnObservation::ToolCall(mut tool_call) => { + // 同一工具的开始、完成和详情补全共用一份单调快照。 + { + let collected = lock_direct_tool_call_collector(&turn_tool_calls); + let existing = collected + .iter() + .find(|existing| existing.id == tool_call.id); + if existing.is_some_and(|call| { + call.status != "running" && tool_call.status == "running" + }) { + return; + } + if !super::direct_tool_calls::direct_tool_call_status_changed( + existing, &tool_call, + ) { + return; + } + if let Some(existing) = existing { + tool_call.updated_at = tool_call + .updated_at + .max(existing.updated_at.saturating_add(1)); + tool_call = super::direct_tool_calls::merge_tool_call_snapshot( + existing, &tool_call, + ); + } + } + { + let mut collected = lock_direct_tool_call_collector(&turn_tool_calls); + collected.retain(|existing| existing.id != tool_call.id); + collected.push(tool_call.clone()); + } + // 回合流:工具是**普通元素**,位置在文本段之后(或与相邻工具成块)。 + let mut items = stream_writer + .take_pending_snapshot() + .into_iter() + .collect::>(); + items.push(stream_writer.push_tool(&tool_call, direct_tool_call_now_ms())); + for item in &items { + stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, item)); + } + emitter.emit_with_stream_items( + status, + None, + None, + Some(vec![tool_call.clone()]), + items, + ); + // 落盘"最新的那一份":started 让卡片刷新后立刻出现,终态覆盖同一行。 + spawn_persist_direct_tool_call(&turn_root, &tool_call); } } }; let mut feedback_prompt = prompt.to_string(); let mut audit = audit; - let mut response = None; - for attempt in 1..=DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS { + let mut attempt = 1; + let reply_result = loop { match direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt.clone(), @@ -4481,10 +4850,7 @@ async fn run_direct_game_creator_turn_inner( ) .await { - Ok(value) => { - response = Some(value); - break; - } + Ok(value) => break Ok(value), Err(error) if attempt < DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS && direct_codex_error_should_feedback(&error) => @@ -4494,18 +4860,27 @@ async fn run_direct_game_creator_turn_inner( "running", Some("error-feedback"), Some(format!("检测到执行错误,正在反馈给陶泥儿继续修复({attempt}/{DIRECT_CODEX_ERROR_FEEDBACK_MAX_ATTEMPTS})")), + None, ); - feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt + 1); - } - Err(error) => { - return Err(DirectCodexTurnFailure::new( - DirectCodexFailureStage::CodeGeneration, - error, - )); + attempt += 1; + feedback_prompt = direct_codex_error_feedback_prompt(&detail, attempt); } + // 失败也先走统一收尾,确保已提交的回合流快照全部落盘。 + Err(error) => break Err(error), + } + }; + drop(observer); + if let Some(item) = stream_writer.take_pending_snapshot() { + stream_writes.push(spawn_persist_direct_turn_stream_item(&turn_root, &item)); + emitter.emit_with_stream_items("streaming", None, None, None, vec![item]); + } + // finalize 必须看见这一轮全部快照,不能与 fire-and-forget 写任务竞争。 + for write in stream_writes { + if !matches!(write.await, Ok(Ok(()))) { + app_log!("[turn-stream] 回合快照持久化失败"); } } - response.ok_or_else(|| "陶泥儿错误反馈回合未返回结果".to_string()) + reply_result } else { let mut feedback_prompt = prompt.to_string(); let mut audit = audit; @@ -4544,6 +4919,9 @@ async fn run_direct_game_creator_turn_inner( response.ok_or_else(|| "陶泥儿错误反馈回合未返回结果".to_string()) } .map_err(|error| DirectCodexTurnFailure::new(DirectCodexFailureStage::CodeGeneration, error))?; + // 回合结束:把本回合累积的工具调用整批落盘(一次锁、一次重写,幂等 upsert)。 + // 落盘失败只记日志,不能把已经成功的回合判成失败——工具调用卡片是展示数据。 + persist_collected_direct_tool_calls(root, &tool_calls); let visible_reply = project_direct_codex_visible_text(&reply).ok_or_else(|| { DirectCodexTurnFailure::new( DirectCodexFailureStage::CodeGeneration, @@ -4551,10 +4929,19 @@ async fn run_direct_game_creator_turn_inner( ) })?; if let Some(emitter) = turn_emitter { - emitter.emit( + // 已有 item 文本由完成事件负责;只有完全没有 item 文本才补最终回复。 + let finalized = + finalize_direct_turn_stream_reply_at(root, emitter.turn_id(), &visible_reply) + .ok() + .flatten() + .into_iter() + .collect::>(); + emitter.emit_with_stream_items( "finalizing", Some("response-finalization"), Some(visible_reply.clone()), + None, + finalized, ); } if direct_codex_output_fingerprint(root) != previous_output_fingerprint { @@ -4568,6 +4955,7 @@ async fn run_direct_game_creator_turn_inner( "finalizing", Some("file-write"), Some(visible_reply.clone()), + None, ); } sync_direct_codex_project_file_projection_at(root, Some(&previous_output_fingerprint)) @@ -4578,6 +4966,38 @@ async fn run_direct_game_creator_turn_inner( Ok(visible_reply) } +#[cfg(test)] +mod direct_turn_stream_writer_tests { + use super::*; + + #[test] + fn item_switch_returns_previous_tail_and_next_head() { + let mut writer = DirectTurnStreamWriter::new("turn".into()); + let root = Path::new("."); + writer.push_text(root, "a", "前缀", 1000, false); + writer.push_text(root, "a", "完整正文", 1000, false); + let snapshots = writer.push_text(root, "b", "第二段", 1000, false); + assert_eq!(snapshots.len(), 2); + assert_eq!(snapshots[0].text.as_deref(), Some("完整正文")); + assert_eq!(snapshots[0].seq, 1); + assert_eq!(snapshots[1].seq, 2); + assert!(snapshots[1].updated_at > snapshots[0].updated_at); + } + + #[test] + fn completed_snapshot_bypasses_throttle_and_keeps_item_position() { + let mut writer = DirectTurnStreamWriter::new("turn".into()); + let root = Path::new("."); + let first = writer.push_text(root, "a", "前缀", 1000, false); + let completed = writer.push_text(root, "a", "完整正文", 1000, true); + assert_eq!(completed.len(), 1); + assert_eq!(completed[0].id, first[0].id); + assert_eq!(completed[0].seq, first[0].seq); + assert!(completed[0].updated_at > first[0].updated_at); + assert_eq!(completed[0].text.as_deref(), Some("完整正文")); + } +} + /// Default product path: one user message becomes one turn on the same /// project-bound Codex app-server thread. The client does not classify the /// intent or perform hidden art, preview, repair, or another LLM workflow. If @@ -5018,6 +5438,132 @@ mod tests { .expect("lost-response replay after the original turn finishes"); } + #[test] + fn read_direct_active_turn_reports_the_registered_turn_and_disappears_after_drop() { + let root = tempfile::tempdir().expect("active invocation root"); + assert_eq!( + read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), + None + ); + + let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-1") + .expect("first client turn"); + let running = read_direct_taonier_active_invocation_at(root.path()) + .expect("read running project") + .expect("running turn is visible to the read-only probe"); + assert_eq!(running.client_turn_id, "client-turn-read-1"); + assert!(running.started_at > 0, "{running:?}"); + // camelCase 契约:前端按 `clientTurnId` / `startedAt` 取值。 + assert_eq!( + serde_json::to_value(&running).expect("serialize view"), + serde_json::json!({ + "clientTurnId": "client-turn-read-1", + "startedAt": running.started_at, + }) + ); + // 只读探测不占有、不释放:探测之后同项目第二次进入仍然被拒。 + let duplicate = + DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-read-2") + .expect_err("read-only probe must not take over the project"); + assert!(!duplicate.starts_with(DIRECT_CODEX_TURN_ALREADY_RUNNING_PREFIX)); + + drop(first); + assert_eq!( + read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), + None + ); + } + + #[test] + fn stale_guard_release_requires_a_matching_identity_and_only_after_the_start_window() { + let root = tempfile::tempdir().expect("active invocation root"); + let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-1") + .expect("first client turn"); + + // ① clientTurnId 不匹配:拒绝,且不误伤正在跑的那一轮。 + let mismatch = release_stale_direct_taonier_active_invocation( + root.path(), + Some("client-turn-stale-2"), + DirectTaonierStaleGuardReason::ExecutorExited, + ) + .expect_err("another turn must not be released"); + assert!(mismatch.contains("另一条"), "{mismatch}"); + assert!( + DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2").is_err() + ); + + // ② 刚登记、还没进执行器:正常启动窗口内不许释放(释放等于放开并发)。 + let young = release_stale_direct_taonier_active_invocation( + root.path(), + Some("client-turn-stale-1"), + DirectTaonierStaleGuardReason::NeverReachedExecutor, + ) + .expect_err("a freshly registered turn is still starting"); + assert!(young.contains("暂不能强制释放"), "{young}"); + + // ③ 同一条登记老过窗口:判定为残留守卫,释放后同项目可以再次进入。 + backdate_active_direct_invocation(root.path(), DIRECT_TAONIER_STALE_GUARD_MIN_AGE_MS + 1); + let released = release_stale_direct_taonier_active_invocation( + root.path(), + Some("client-turn-stale-1"), + DirectTaonierStaleGuardReason::NeverReachedExecutor, + ) + .expect("stale guard is released"); + assert_eq!(released, "client-turn-stale-1"); + let second = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-stale-2") + .expect("a new turn can start once the stale guard is released"); + // 释放是幂等的:原 guard 的 Drop 不会影响后来登记的那一轮。 + drop(first); + let still_running = read_direct_taonier_active_invocation_at(root.path()) + .expect("read running project") + .expect("the newer turn survives the stale guard drop"); + assert_eq!(still_running.client_turn_id, "client-turn-stale-2"); + drop(second); + } + + #[test] + fn stale_guard_release_after_the_executor_exited_frees_the_project() { + let root = tempfile::tempdir().expect("active invocation root"); + let first = DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-1") + .expect("client turn"); + let released = release_stale_direct_taonier_active_invocation( + root.path(), + None, + DirectTaonierStaleGuardReason::ExecutorExited, + ) + .expect("executor exited: this guard is residue"); + assert_eq!(released, "client-turn-exited-1"); + assert_eq!( + read_direct_taonier_active_invocation_at(root.path()).expect("read idle project"), + None + ); + let _second = + DirectTaonierActiveInvocationGuard::enter(root.path(), "client-turn-exited-2") + .expect("a new turn can start after the residue is released"); + drop(first); + + // 没有任何登记时给出可读原因,而不是静默成功。 + let empty = tempfile::tempdir().expect("empty invocation root"); + let nothing = release_stale_direct_taonier_active_invocation( + empty.path(), + None, + DirectTaonierStaleGuardReason::ExecutorExited, + ) + .expect_err("nothing to release"); + assert!(nothing.contains("没有正在运行"), "{nothing}"); + } + + /// 把某项目当前登记的活跃回合往前拨 `age_ms`,用于覆盖"守卫年龄"分支。 + fn backdate_active_direct_invocation(root: &Path, age_ms: u64) { + let root = root.canonicalize().expect("canonical root"); + let mut active = DIRECT_TAONIER_ACTIVE_INVOCATIONS + .get_or_init(|| Mutex::new(HashMap::new())) + .lock() + .expect("active invocation lock"); + let entry = active.get_mut(&root).expect("registered invocation"); + entry.started_at = entry.started_at.saturating_sub(age_ms); + } + #[test] fn active_turn_snapshot_tracks_progress_and_is_removed_after_drop() { let root = tempfile::tempdir().expect("active snapshot root"); @@ -5165,6 +5711,7 @@ mod tests { status: "streaming".to_string(), activity: None, accumulated_text: Some("partial".to_string()), + tool_calls: None, updated_at: 42, }) .expect("serialize direct update"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs index efdc044e7..aa9bbe8f7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_runtime/user_input.rs @@ -31,7 +31,7 @@ pub(crate) fn normalize_direct_client_turn_id( pub(crate) async fn chat_with_game_creator_direct_codex( project_path: String, prompt: String, - user_item: DirectCodexUserItem, + mut user_item: DirectCodexUserItem, creation_type: Option, client_turn_id: Option, attachments: Option>, @@ -50,6 +50,17 @@ pub(crate) async fn chat_with_game_creator_direct_codex( attachments.as_deref().unwrap_or_default(), ); let attachments = attachments.unwrap_or_default(); + if !attachments.is_empty() { + let attachment_context = + render_direct_codex_user_prompt("", &attachments).map_err(|error| { + audit.finish(false); + error + })?; + let DirectCodexUserItem::Message(message) = &mut user_item; + message.content.push(DirectCodexUserContentPart::InputText { + text: attachment_context, + }); + } validate_direct_codex_user_item(root, &user_item).map_err(|error| { audit.finish(false); error @@ -81,6 +92,6 @@ pub(crate) async fn chat_with_game_creator_direct_codex( } }; audit.finish(true); - turn_emitter.emit("completed", Some("none"), Some(reply.clone())); + turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None); Ok(reply) } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs index 9f5adf8f9..e3bc497ea 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_manager.rs @@ -58,6 +58,7 @@ pub(crate) struct DirectThreadConsumeResult { pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, + pub(crate) item_timestamps: std::collections::BTreeMap, } #[derive(Clone, Debug)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs new file mode 100644 index 000000000..82e9989e8 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_tool_calls.rs @@ -0,0 +1,1418 @@ +//! GameAgent 对话「工具调用卡片」的采集、持久化与回读。 +//! +//! 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: +//! Codex app-server 的 `item/started` / `item/completed` 里带着完整的命令 / 文件变更 +//! 信息,这里把它们投影成结构化的 `DirectToolCall`,落到**独立文件** +//! `/.agent/conversations/tool-calls.jsonl`。 +//! +//! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 +//! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 + +use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; +use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; +use crate::project::{enforce_project_permission_policy, project_append_lock_for}; +use crate::redact_absolute_path_tokens; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。 +pub(crate) const DIRECT_TOOL_CALL_RECORD_TYPE: &str = "tool_call_item"; +/// 条目 schema 版本。 +pub(crate) const DIRECT_TOOL_CALL_SCHEMA_VERSION: &str = "agc-tool-call.v1"; +/// 回读上限:只保留最近这么多条(按 `updatedAt` / `startedAt` 取最新)。 +pub(crate) const DIRECT_TOOL_CALL_LIMIT: usize = 200; +/// `detail.command` / `detail.output` 的字符上限。 +const DIRECT_TOOL_CALL_DETAIL_MAX_CHARS: usize = 4000; +/// 折叠态摘要(`summary`)的字符上限。 +const DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS: usize = 120; +/// 单条变更路径的字符上限。 +const DIRECT_TOOL_CALL_PATH_MAX_CHARS: usize = 300; + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectToolCallChange { + pub(crate) path: String, + /// `add` | `update` | `delete` + pub(crate) kind: String, +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectToolCallDetail { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) command: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) output: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) changes: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectToolCall { + pub(crate) schema_version: String, + /// Codex item 的 id。同一 item 的 started/completed 共用它,落盘时按它幂等 upsert。 + pub(crate) id: String, + pub(crate) turn_id: String, + /// `command` | `file_change` | `mcp_tool` | `web_search` | `context_compaction` | `other` + pub(crate) kind: String, + /// 折叠态标题,按 kind 固定(`command` → `执行命令`、`file_change` → `编辑 N 个文件`)。 + pub(crate) title: String, + /// 折叠态标题后面的短摘要。 + pub(crate) summary: String, + /// `running` | `completed` | `failed` + pub(crate) status: String, + pub(crate) detail: DirectToolCallDetail, + pub(crate) started_at: u64, + pub(crate) updated_at: u64, +} + +impl DirectToolCall { + fn timestamp(&self) -> u64 { + if self.updated_at > 0 { + self.updated_at + } else { + self.started_at + } + } +} + +fn tool_calls_path(root: &Path) -> PathBuf { + root.join(".agent/conversations/tool-calls.jsonl") +} + +/// 项目根目录之后的路径 token:分隔符统一成 `/`,返回 `(消费到的下标, 项目相对路径)`。 +fn project_relative_path_segment(value: &str, start: usize) -> (usize, String) { + let mut index = start; + let mut relative = String::new(); + while index < value.len() { + let character = value[index..].chars().next().unwrap_or_default(); + if matches!(character, '/' | '\\') { + if !relative.is_empty() { + relative.push('/'); + } + index += character.len_utf8(); + continue; + } + if character.is_whitespace() + || matches!( + character, + '\'' | '"' + | '`' + | ',' + | ';' + | '|' + | '&' + | '(' + | ')' + | '[' + | ']' + | '{' + | '}' + | '<' + | '>' + | ':' + ) + { + break; + } + relative.push(character); + index += character.len_utf8(); + } + while relative.ends_with('/') { + relative.pop(); + } + (index, relative) +} + +/// 把项目根目录前缀换成**项目相对路径**(`/game/src/x.ts` → `game/src/x.ts`)。 +/// +/// 必须排在 `redact_absolute_path_tokens` 之前:后者会把整个绝对路径抹成 +/// ``,之后就再也认不出哪些路径在项目内了。 +/// Windows 上同时匹配 `\` 与 `/` 两种分隔符写法,并按大小写不敏感比较(盘符大小写会变)。 +fn relativize_project_root_paths(root: &Path, value: &str) -> String { + let root_text = root.to_string_lossy(); + let root_text = root_text.trim_end_matches(['/', '\\']); + if root_text.is_empty() { + return value.to_string(); + } + let mut needles = [ + root_text.to_string(), + root_text.replace('\\', "/"), + root_text.replace('/', "\\"), + ] + .into_iter() + .map(|needle| needle.to_ascii_lowercase()) + .filter(|needle| !needle.is_empty()) + .collect::>(); + needles.sort(); + needles.dedup(); + let lower = value.to_ascii_lowercase(); + + let mut output = String::with_capacity(value.len()); + let mut cursor = 0usize; + while cursor < value.len() { + let mut hit: Option<(usize, usize)> = None; + for needle in &needles { + let mut search = cursor; + while let Some(relative) = lower[search..].find(needle.as_str()) { + let start = search + relative; + let end = start + needle.len(); + let left_is_boundary = start == 0 + || lower[..start].chars().next_back().is_some_and(|character| { + !character.is_alphanumeric() && character != '_' && character != '-' + }); + if left_is_boundary && value[end..].starts_with(['/', '\\']) { + if hit.is_none_or(|(best_start, _)| start < best_start) { + hit = Some((start, end)); + } + break; + } + search = end; + } + } + let Some((start, end)) = hit else { + break; + }; + output.push_str(&value[cursor..start]); + let (consumed, relative) = project_relative_path_segment(value, end); + if relative.is_empty() { + // 只写了项目根目录本身(没有后续路径段):按占位形状处理。 + output.push_str(""); + } else { + output.push_str(&relative); + } + cursor = consumed; + } + output.push_str(&value[cursor..]); + output +} + +/// 脱敏:项目内绝对路径先归一化成项目相对路径,再依次做绝对路径、密钥前缀与 +/// 错误上下文脱敏。 +/// +/// 顺序不能反:先抹密钥会把 `sk-…` 之类的 token 换成占位符,但绝对路径里的用户名目录 +/// 仍然会留下;这里先归一化路径 token,再处理密钥。 +/// +/// 复用既有 `agent/generation/prompt_context.rs` 的脱敏组合:`sanitize_error_context` +/// 就是 `redact_secret_tokens` + `redact_error_sensitive_assignments` + +/// `redact_error_bearer_values` + `redact_error_config_names` 的既有组合用法,覆盖 +/// `Authorization: Bearer …`、`Cookie: …`、`api_key=…`、`client_secret=…` 这类键值凭据; +/// 含 `--password` / `--token` / `--secret` 这类敏感 CLI 标志的行按既有 fail-closed +/// 约定整行替换成 `[redacted sensitive context]`(与 `sanitize_agent_runtime_text` 一致)。 +/// +/// `pub(crate)`:回合流(`direct_turn_stream`)的文本段复用同一套脱敏,避免两处口径分叉。 +pub(crate) fn sanitize_detail_text(root: &Path, value: &str) -> String { + let without_project_root = relativize_project_root_paths(root, value); + let without_absolute = redact_absolute_path_tokens(&without_project_root); + let without_secret = redact_secret_tokens(&without_absolute); + sanitize_error_context(&without_secret) +} + +/// 按字符数截断(不切坏 UTF-8),并在真正截断时补省略号。 +fn bounded_chars(value: &str, max_chars: usize) -> String { + if value.chars().count() <= max_chars { + return value.to_string(); + } + let mut truncated = value.chars().take(max_chars).collect::(); + truncated.push('…'); + truncated +} + +/// 取值的首行并按字符数截断(命令的摘要习惯)。 +fn first_line_bounded(value: &str, max_chars: usize) -> String { + let first_line = value.lines().next().unwrap_or_default().trim(); + bounded_chars(first_line, max_chars) +} + +/// 把 app-server 中可能是字符串或 JSON 对象的工具详情统一转成可读文本。 +fn direct_tool_call_value_text(value: &Value) -> Option { + match value { + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + Value::Null => None, + // 调用方先脱敏再截断,不能在这里截断掉敏感字段的语法边界。 + _ => serde_json::to_string_pretty(value).ok(), + } +} + +fn tool_call_kind(item_type: &str) -> Option<&'static str> { + match item_type { + "commandExecution" => Some("command"), + "fileChange" => Some("file_change"), + "mcpToolCall" => Some("mcp_tool"), + "webSearch" => Some("web_search"), + "contextCompaction" => Some("context_compaction"), + // todoList / reasoning / plan / agentMessage 之类不属于「工具调用」,不落卡片。 + "todoList" | "reasoning" | "plan" | "agentMessage" | "message" | "userMessage" => None, + _ => Some("other"), + } +} + +fn direct_tool_call_changes(item: &Value) -> Vec { + item.get("changes") + .and_then(Value::as_array) + .map(|changes| { + changes + .iter() + .filter_map(|change| { + let path = change + .get("path") + .and_then(Value::as_str) + .map(str::trim) + .filter(|path| !path.is_empty())?; + Some(DirectToolCallChange { + path: bounded_chars(path, DIRECT_TOOL_CALL_PATH_MAX_CHARS), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn direct_tool_call_title(kind: &str, changes: &[DirectToolCallChange]) -> String { + match kind { + "command" => "执行命令".to_string(), + "file_change" => { + let mut paths = changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + paths.sort_unstable(); + paths.dedup(); + if paths.is_empty() { + "编辑文件".to_string() + } else { + format!("编辑 {} 个文件", paths.len()) + } + } + "mcp_tool" => "调用工具".to_string(), + "web_search" => "搜索资料".to_string(), + "context_compaction" => "整理上下文".to_string(), + _ => "调用工具".to_string(), + } +} + +fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str { + // item 自带的显式终态优先:被策略拒绝(declined)、失败、取消的调用不能因为 + // `completed == true` 就被当成成功,否则卡片会把"没执行成功"显示成"已执行"。 + if let Some(status) = item.get("status").and_then(Value::as_str) { + match status { + "completed" => return "completed", + "failed" | "declined" | "cancelled" | "canceled" | "aborted" => return "failed", + _ => {} + } + } + // Codex 的退出码约定:非 0 即失败;缺席时按"已完成"处理。 + if let Some(exit_code) = item.get("exitCode").and_then(Value::as_i64) { + return if exit_code == 0 { + "completed" + } else { + "failed" + }; + } + if let Some(success) = item.get("success").and_then(Value::as_bool) { + return if success { "completed" } else { "failed" }; + } + if completed { + "completed" + } else { + "running" + } +} + +/// 状态或可见详情变化才下发;同状态的输入/输出补全也属于更新。 +pub(crate) fn direct_tool_call_status_changed( + existing: Option<&DirectToolCall>, + incoming: &DirectToolCall, +) -> bool { + !existing.is_some_and(|current| { + current.status == incoming.status + && current.detail == incoming.detail + && current.title == incoming.title + && current.summary == incoming.summary + }) +} + +/// 把一条 Codex item 投影成工具调用条目。非工具类 item 返回 `None`。 +/// +/// `started_at` / `updated_at`:item 自己带的 `startedAtMs` / `completedAtMs` 优先, +/// 两处都没有时才用调用方给的回退值(本机毫秒时间戳)。 +pub(crate) fn direct_tool_call_from_item( + root: &Path, + item: &Value, + turn_id: &str, + completed: bool, + now_ms: u64, +) -> Option { + let item_type = item.get("type").and_then(Value::as_str)?; + let kind = tool_call_kind(item_type)?; + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|id| !id.is_empty())?; + + let item_started_at = item + .get("startedAtMs") + .and_then(Value::as_u64) + .unwrap_or_default(); + let item_completed_at = item + .get("completedAtMs") + .and_then(Value::as_u64) + .unwrap_or_default(); + let started_at = if item_started_at > 0 { + item_started_at + } else if item_completed_at > 0 { + item_completed_at + } else { + now_ms + }; + let updated_at = if item_completed_at > 0 { + item_completed_at + } else { + started_at.max(now_ms) + }; + + let command = item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| sanitize_detail_text(root, command)) + .map(|command| bounded_chars(&command, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)) + .or_else(|| { + item.get("arguments") + .and_then(direct_tool_call_value_text) + .map(|arguments| sanitize_detail_text(root, &arguments)) + .map(|arguments| bounded_chars(&arguments, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)) + }); + let output = ["aggregatedOutput", "output", "result", "error"] + .iter() + .find_map(|key| item.get(key).and_then(direct_tool_call_value_text)) + .map(|output| sanitize_detail_text(root, &output)) + .map(|output| bounded_chars(&output, DIRECT_TOOL_CALL_DETAIL_MAX_CHARS)); + // `fileChange` 的路径先脱敏成"项目内相对路径":绝对路径会被抹成 ``, + // 相对路径原样保留(契约要求 detail.changes[].path 用项目相对路径)。 + let changes = direct_tool_call_changes(item) + .into_iter() + .map(|change| DirectToolCallChange { + path: sanitize_detail_text(root, &change.path), + kind: change.kind, + }) + .collect::>(); + + let tool = item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)); + // `summary` 会落到卡片与落盘文件,它的兜底来源同样必须脱敏。 + let summary_source = (if kind == "mcp_tool" { + tool.as_deref() + } else { + None + }) + .or(command.as_deref()) + .or_else(|| changes.first().map(|change| change.path.as_str())) + .or(tool.as_deref()) + .unwrap_or_default(); + let summary = first_line_bounded(summary_source, DIRECT_TOOL_CALL_SUMMARY_MAX_CHARS); + + Some(DirectToolCall { + schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), + id: id.to_string(), + turn_id: turn_id.trim().to_string(), + kind: kind.to_string(), + title: direct_tool_call_title(kind, &changes), + summary, + status: direct_tool_call_status(item, completed).to_string(), + detail: DirectToolCallDetail { + command, + output, + changes, + }, + started_at, + updated_at, + }) +} + +fn record_line(call: &DirectToolCall) -> Result { + serde_json::to_string(&serde_json::json!({ + "type": DIRECT_TOOL_CALL_RECORD_TYPE, + "payload": call, + })) + .map_err(|error| format!("序列化工具调用条目失败:{error}")) +} + +/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。 +fn tool_call_from_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let parsed: Value = serde_json::from_str(trimmed).ok()?; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TOOL_CALL_RECORD_TYPE) { + return None; + } + let payload = parsed.get("payload")?; + let mut call: DirectToolCall = serde_json::from_value(payload.clone()).ok()?; + if call.id.trim().is_empty() { + return None; + } + if call.schema_version.trim().is_empty() { + call.schema_version = DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(); + } + Some(call) +} + +fn read_tool_call_lines(path: &Path) -> Vec { + let Ok(file) = File::open(path) else { + return Vec::new(); + }; + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + let mut calls = Vec::new(); + loop { + buffer.clear(); + match reader.read_until(b'\n', &mut buffer) { + Ok(0) => break, + // 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行; + // 契约要求「单行损坏跳过该行继续」,不能把后续记录一起丢掉。 + Ok(_) => match std::str::from_utf8(&buffer) { + Ok(line) => { + if let Some(call) = tool_call_from_line(line) { + calls.push(call); + } + } + Err(_) => continue, + }, + // 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。 + Err(_) => break, + } + } + calls +} + +/// 按 id 归并(同 id 按 `updatedAt` 单调合并),再按时间正序裁剪到最近 +/// `DIRECT_TOOL_CALL_LIMIT` 条。 +fn normalize_tool_calls(calls: Vec) -> Vec { + let mut by_id: BTreeMap = BTreeMap::new(); + for call in calls { + let merged = match by_id.remove(&call.id) { + Some(previous) => merge_tool_call_snapshot(&previous, &call), + None => call, + }; + by_id.insert(merged.id.clone(), merged); + } + let mut normalized = by_id.into_values().collect::>(); + normalized.sort_by(|left, right| { + left.timestamp() + .cmp(&right.timestamp()) + .then_with(|| left.id.cmp(&right.id)) + }); + if normalized.len() > DIRECT_TOOL_CALL_LIMIT { + normalized.drain(..normalized.len() - DIRECT_TOOL_CALL_LIMIT); + } + normalized +} + +/// 回读:文件缺失返回空数组;单行损坏跳过;按时间正序,最多最近 200 条。 +pub(crate) fn read_direct_tool_calls_at(root: &Path) -> Result, String> { + let path = tool_calls_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "工具调用历史")? { + return Ok(Vec::new()); + } + Ok(normalize_tool_calls(read_tool_call_lines(&path))) +} + +/// 状态的「确定性」排序:终态(`completed` / `failed`)优先于 `running`。 +fn status_certainty(status: &str) -> u8 { + match status { + "completed" | "failed" => 1, + _ => 0, + } +} + +/// 同一 id 的两条快照按 `updatedAt` 做**单调合并**。 +/// +/// - `startedAt` 取最早的非零值:`item/completed` 事件不一定带 `startedAtMs`, +/// 不能让 completed 覆盖掉 started 记下的起点(卡片时间序依赖它)。 +/// - `updatedAt` 更旧的快照不得覆盖更新的状态与 `updatedAt`:`direct_runtime.rs` 里 +/// 「回合末整批落盘」与「逐条快照落盘(spawn_blocking)」两条路径竞争时,后到的 +/// 旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`。 +/// - `updatedAt` 相同时终态优先,避免同一毫秒内的旧快照回退状态。 +pub(crate) fn merge_tool_call_snapshot( + existing: &DirectToolCall, + incoming: &DirectToolCall, +) -> DirectToolCall { + let take_incoming = incoming.updated_at > existing.updated_at + || (incoming.updated_at == existing.updated_at + && status_certainty(&incoming.status) > status_certainty(&existing.status)); + let mut merged = if take_incoming { + incoming.clone() + } else { + existing.clone() + }; + if merged.detail.command.is_none() { + merged.detail.command = existing + .detail + .command + .clone() + .or(incoming.detail.command.clone()); + } + if merged.detail.output.is_none() { + merged.detail.output = existing + .detail + .output + .clone() + .or(incoming.detail.output.clone()); + } + merged.started_at = [merged.started_at, existing.started_at, incoming.started_at] + .into_iter() + .filter(|started_at| *started_at > 0) + .min() + .unwrap_or_default(); + merged +} + +/// 幂等 upsert:同 id 只保留一行,快照按 `updatedAt` 单调合并(旧快照不得回退状态)。 +/// +/// 单次尝试的顺序是「取项目锁 + append 锁 → 锁内读 → 整文件原子替换」。 +/// 工具调用是**追加 + 就地更新**混用的数据,没有纯追加的 JSONL 语义,所以只能整文件重写; +/// 文件规模由 200 条上限与 4000 字符截断兜住。 +fn upsert_direct_tool_call_once(root: &Path, call: &DirectToolCall) -> Result<(), String> { + let path = tool_calls_path(root); + let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("工具调用历史写入")?; + let mut calls = read_tool_call_lines(&path); + let existing = calls + .iter() + .find(|existing| existing.id == call.id) + .cloned(); + let incoming = match existing.as_ref() { + Some(existing) => merge_tool_call_snapshot(existing, call), + None => call.clone(), + }; + calls.retain(|existing| existing.id != incoming.id); + calls.push(incoming); + let normalized = normalize_tool_calls(calls); + let mut body = String::new(); + for existing in &normalized { + body.push_str(&record_line(existing)?); + body.push('\n'); + } + write_game_creator_private_file(&path, body.as_bytes(), "工具调用历史") +} + +/// 落盘入口。失败不抛给调用方以外的地方——工具调用是展示数据,不能因为它把整轮判失败。 +pub(crate) fn persist_direct_tool_call_at( + root: &Path, + call: &DirectToolCall, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + upsert_direct_tool_call_once(root, call) +} + +/// 一轮结束时把本回合累积的工具调用整批落盘(一次锁、一次重写)。 +pub(crate) fn persist_direct_tool_calls_at( + root: &Path, + calls: &[DirectToolCall], +) -> Result<(), String> { + if calls.is_empty() { + return Ok(()); + } + enforce_project_permission_policy(root, "conversation.write")?; + let path = tool_calls_path(root); + let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("工具调用历史写入")?; + let mut existing = read_tool_call_lines(&path); + let mut incoming = calls.to_vec(); + for call in incoming.iter_mut() { + let merged = existing + .iter() + .find(|row| row.id == call.id) + .map(|previous| merge_tool_call_snapshot(previous, call)); + if let Some(merged) = merged { + *call = merged; + } + } + let ids = incoming + .iter() + .map(|call| call.id.as_str()) + .collect::>(); + existing.retain(|call| !ids.contains(&call.id.as_str())); + existing.extend(incoming); + let normalized = normalize_tool_calls(existing); + let mut body = String::new(); + for call in &normalized { + body.push_str(&record_line(call)?); + body.push('\n'); + } + write_game_creator_private_file(&path, body.as_bytes(), "工具调用历史") +} + +/// 本机毫秒时间戳(item 没带时间时用)。 +pub(crate) fn direct_tool_call_now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .min(u64::MAX as u128) as u64 +} + +#[cfg(test)] +mod tests { + use super::{ + direct_tool_call_from_item, direct_tool_call_now_ms, direct_tool_call_status, + direct_tool_call_status_changed, persist_direct_tool_call_at, persist_direct_tool_calls_at, + read_direct_tool_calls_at, sanitize_detail_text, tool_calls_path, DirectToolCall, + DirectToolCallDetail, DIRECT_TOOL_CALL_LIMIT, DIRECT_TOOL_CALL_SCHEMA_VERSION, + }; + use serde_json::json; + + /// 一行合法的落盘信封(回读用例的夹具)。 + fn tool_call_row(id: &str, started_at: u64, updated_at: u64) -> String { + serde_json::to_string(&json!({ + "type": "tool_call_item", + "payload": { + "schemaVersion": "agc-tool-call.v1", + "id": id, + "turnId": "turn-1", + "kind": "command", + "title": "执行命令", + "summary": "npm run build", + "status": "completed", + "detail": {"command": "npm run build"}, + "startedAt": started_at, + "updatedAt": updated_at + } + })) + .expect("serialize tool call row") + } + + fn init_tool_call_project(name: &str) -> tempfile::TempDir { + let root = tempfile::tempdir().expect("temp project"); + crate::init_local_game_project_at(root.path(), name, "工具调用卡片测试") + .expect("init project"); + root + } + + fn command_item(id: &str, command: &str) -> serde_json::Value { + json!({ + "id": id, + "type": "commandExecution", + "command": command, + "status": "inProgress", + "startedAtMs": 1000, + }) + } + + /// 判据:同一 item 的 started 与 completed 只落一行,completed 覆盖 status。 + fn sample_tool_call(id: &str, status: &str, updated_at: u64) -> DirectToolCall { + DirectToolCall { + schema_version: DIRECT_TOOL_CALL_SCHEMA_VERSION.to_string(), + id: id.to_string(), + turn_id: "turn-1".to_string(), + kind: "command".to_string(), + title: "执行命令".to_string(), + summary: "npm run build".to_string(), + status: status.to_string(), + detail: DirectToolCallDetail::default(), + started_at: 1, + updated_at, + } + } + + #[test] + fn tool_call_status_change_is_detected_only_on_real_changes() { + let running = sample_tool_call("call-1", "running", 1); + let completed = sample_tool_call("call-1", "completed", 2); + + assert!( + direct_tool_call_status_changed(None, &running), + "首次观察必须被收集" + ); + assert!( + !direct_tool_call_status_changed(Some(&running), &running), + "状态没变时不该重复下发同一份快照" + ); + assert!( + direct_tool_call_status_changed(Some(&running), &completed), + "running -> completed 的终态观察必须被收集与下发(历史 bug:这里被丢弃,卡片永远显示执行中)" + ); + } + + #[test] + fn explicit_declined_or_failed_status_is_not_reported_as_completed() { + for status in ["declined", "failed", "cancelled", "aborted"] { + let item = json!({ + "id": "call-1", + "type": "commandExecution", + "status": status, + }); + assert_eq!( + direct_tool_call_status(&item, true), + "failed", + "item 自带 {status} 时不能因为 completed=true 就被当成 completed" + ); + } + let completed = json!({ + "id": "call-1", + "type": "commandExecution", + "status": "completed", + }); + assert_eq!(direct_tool_call_status(&completed, true), "completed"); + } + + #[test] + fn tool_call_upsert_is_idempotent_per_item_id() { + let root = init_tool_call_project("tool-call-upsert"); + let started = direct_tool_call_from_item( + root.path(), + &command_item("item-1", "npm run build"), + "turn-1", + false, + 1000, + ) + .expect("started tool call"); + persist_direct_tool_call_at(root.path(), &started).expect("persist started"); + + let completed = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-1", + "type": "commandExecution", + "command": "npm run build", + "exitCode": 0, + "completedAtMs": 2000, + }), + "turn-1", + true, + 2000, + ) + .expect("completed tool call"); + persist_direct_tool_call_at(root.path(), &completed).expect("persist completed"); + + let calls = read_direct_tool_calls_at(root.path()).expect("read tool calls"); + assert_eq!(calls.len(), 1, "同一 id 只能有一行"); + assert_eq!(calls[0].status, "completed"); + assert_eq!(calls[0].started_at, 1000, "startedAt 不被 completed 覆盖"); + assert_eq!(calls[0].updated_at, 2000); + } + + /// 判据:非 0 退出码判 failed。 + #[test] + fn tool_call_marks_failed_on_non_zero_exit_code() { + let root = init_tool_call_project("tool-call-failed"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-failed", + "type": "commandExecution", + "command": "npm test", + "exitCode": 1, + "completedAtMs": 3000, + }), + "turn-1", + true, + 3000, + ) + .expect("failed tool call"); + assert_eq!(call.status, "failed"); + } + + /// 判据:command / output 截断到 4000 字符,summary 截断到 120 字符。 + #[test] + fn tool_call_truncates_command_output_and_summary() { + let root = init_tool_call_project("tool-call-truncate"); + let long_command = "a".repeat(5000); + let long_output = "b".repeat(5000); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-long", + "type": "commandExecution", + "command": long_command, + "aggregatedOutput": long_output, + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("long tool call"); + let command = call.detail.command.expect("bounded command"); + let output = call.detail.output.expect("bounded output"); + assert_eq!(command.chars().count(), 4001, "4000 字符 + 省略号"); + assert_eq!(output.chars().count(), 4001, "4000 字符 + 省略号"); + assert_eq!(call.summary.chars().count(), 121, "120 字符 + 省略号"); + } + + /// 判据:脱敏后不出现 API Key / Token / 绝对用户目录。 + #[test] + fn tool_call_redacts_secrets_and_absolute_paths() { + let root = init_tool_call_project("tool-call-redact"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-secret", + "type": "commandExecution", + "command": "curl -H 'Authorization: Bearer sk-abcdefghijklmnop' https://example.com", + "aggregatedOutput": "OPENAI_API_KEY=tnr_sk_abcdefghijklmnop /home/someuser/private/notes.txt", + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("redacted tool call"); + let command = call.detail.command.as_deref().expect("command"); + let output = call.detail.output.as_deref().expect("output"); + assert!( + !command.contains("sk-abcdefghijklmnop"), + "命令里的 API Key 必须脱敏:{command}" + ); + assert!( + !output.contains("tnr_sk_abcdefghijklmnop"), + "输出里的 Token 必须脱敏:{output}" + ); + assert!( + !output.contains("/home/someuser"), + "输出里的绝对用户目录必须脱敏:{output}" + ); + + persist_direct_tool_call_at(root.path(), &call).expect("persist redacted call"); + let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); + assert!( + !raw.contains("sk-abcdefghijklmnop"), + "落盘文件里不得出现 API Key" + ); + assert!( + !raw.contains("/home/someuser"), + "落盘文件里不得出现绝对用户目录" + ); + } + + /// 判据:单行损坏只跳过该行,不整体失败;缺文件返回空数组。 + #[test] + fn tool_call_read_skips_corrupted_lines() { + let root = init_tool_call_project("tool-call-corrupt"); + let path = tool_calls_path(root.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + let good = serde_json::to_string(&json!({ + "type": "tool_call_item", + "payload": { + "schemaVersion": "agc-tool-call.v1", + "id": "item-good", + "turnId": "turn-1", + "kind": "command", + "title": "执行命令", + "summary": "npm run build", + "status": "completed", + "detail": {"command": "npm run build"}, + "startedAt": 1, + "updatedAt": 2 + } + })) + .expect("serialize good row"); + std::fs::write( + &path, + format!("{good}\n{{ not json\n{{\"type\":\"other\",\"payload\":{{}}}}\n{good}\n"), + ) + .expect("write fixture"); + + let missing = tempfile::tempdir().expect("missing dir"); + assert!( + read_direct_tool_calls_at(missing.path()) + .expect("missing file is empty") + .is_empty(), + "历史文件缺失必须返回空数组" + ); + + let calls = read_direct_tool_calls_at(root.path()).expect("read with corrupted lines"); + assert_eq!(calls.len(), 1, "坏行被跳过,同 id 归并成一条"); + assert_eq!(calls[0].id, "item-good"); + } + + /// 判据:回读按时间正序,且超出上限时保留最新。 + #[test] + fn tool_call_read_is_ordered_and_capped() { + let root = init_tool_call_project("tool-call-cap"); + let total = DIRECT_TOOL_CALL_LIMIT + 5; + let calls = (0..total) + .map(|index| { + direct_tool_call_from_item( + root.path(), + &json!({ + "id": format!("item-{index:04}"), + "type": "commandExecution", + "command": format!("run {index}"), + "startedAtMs": 1000 + index as u64, + }), + "turn-1", + false, + 1000 + index as u64, + ) + .expect("tool call") + }) + .collect::>(); + persist_direct_tool_calls_at(root.path(), &calls).expect("persist batch"); + + let read = read_direct_tool_calls_at(root.path()).expect("read capped"); + assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "超出上限保留最新 N 条"); + assert_eq!( + read.first().expect("first").id, + format!("item-{:04}", total - DIRECT_TOOL_CALL_LIMIT), + "最早被裁掉的是最旧的条目" + ); + assert!( + read.windows(2) + .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), + "回读必须按时间正序" + ); + } + + /// 判据:fileChange 的标题按去重后的变更数量,摘要取首个变更路径。 + #[test] + fn tool_call_file_change_title_counts_unique_paths() { + let root = init_tool_call_project("tool-call-file-change"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-files", + "type": "fileChange", + "changes": [ + {"path": "game/src/a.ts", "kind": "update"}, + {"path": "game/src/a.ts", "kind": "update"}, + {"path": "game/src/b.ts", "kind": "add"} + ], + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("file change tool call"); + assert_eq!(call.kind, "file_change"); + assert_eq!(call.title, "编辑 2 个文件"); + assert_eq!(call.summary, "game/src/a.ts"); + assert_eq!(call.detail.changes.len(), 3); + } + + /// 判据:非工具类 item 不产卡片。 + #[test] + fn tool_call_skips_non_tool_items() { + let root = init_tool_call_project("tool-call-skip"); + for item_type in ["reasoning", "agentMessage", "todoList", "plan"] { + assert!( + direct_tool_call_from_item( + root.path(), + &json!({"id": "item-x", "type": item_type}), + "turn-1", + false, + direct_tool_call_now_ms(), + ) + .is_none(), + "{item_type} 不应产出工具调用卡片" + ); + } + } + /// 五类必须脱敏的凭据形状(审查报告实测泄漏的那五类)。 + const CREDENTIAL_CANARIES: [&str; 5] = [ + "canary-bearer-value", + "canary-cookie-value", + "canary-api-key-value", + "canary-client-secret-value", + "canary-password-value", + ]; + + /// 判据:`Authorization: Bearer` / `Cookie: session=` / `api_key=` / `client_secret=` / + /// `--password <值>` 五类凭据在投影结果与落盘行里都不得出现原始值。 + #[test] + fn tool_call_redacts_extended_credential_shapes() { + let root = init_tool_call_project("tool-call-credential-shapes"); + let command = [ + "curl -H 'Authorization: Bearer canary-bearer-value' https://example.com", + "curl -b 'Cookie: session=canary-cookie-value' https://example.com", + "curl -d api_key=canary-api-key-value https://example.com", + "curl -d client_secret=canary-client-secret-value https://example.com", + "vault login --password canary-password-value --env prod", + ] + .join("\n"); + let output = [ + "Authorization: Bearer canary-output-bearer-value", + "Cookie: session=canary-output-cookie-value", + ] + .join("\n"); + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-credentials", + "type": "commandExecution", + "command": command, + "aggregatedOutput": output, + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("credential tool call"); + + let projected_command = call.detail.command.as_deref().expect("command"); + let projected_output = call.detail.output.as_deref().expect("output"); + for canary in CREDENTIAL_CANARIES { + assert!( + !projected_command.contains(canary), + "命令投影里不得出现原始凭据 {canary}:{projected_command}" + ); + } + for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] { + assert!( + !projected_output.contains(canary), + "输出投影里不得出现原始凭据 {canary}:{projected_output}" + ); + } + assert!( + !call.summary.contains("canary-bearer-value"), + "摘要取自命令首行,同样不得带原始凭据:{}", + call.summary + ); + + persist_direct_tool_call_at(root.path(), &call).expect("persist credential call"); + let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); + for canary in CREDENTIAL_CANARIES { + assert!( + !raw.contains(canary), + "落盘行里不得出现原始凭据 {canary}:{raw}" + ); + } + for canary in ["canary-output-bearer-value", "canary-output-cookie-value"] { + assert!( + !raw.contains(canary), + "落盘行里不得出现原始凭据 {canary}:{raw}" + ); + } + } + + /// 判据:脱敏不误伤正常内容、既有前缀脱敏不回退、且幂等(连跑两次结果一致)。 + #[test] + fn tool_call_redaction_keeps_normal_text_and_is_idempotent() { + let root = init_tool_call_project("tool-call-redaction-idempotent"); + let sanitize = |value: &str| sanitize_detail_text(root.path(), value); + + // 出现 `password` 单词但没有赋值 → 属于正常内容,不得脱敏。 + let plain = "grep -n password game/src/config.ts"; + let once = sanitize(plain); + assert_eq!(once, plain, "没有赋值的 password 单词不得被脱敏"); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + + // 既有前缀脱敏(sk-…)不得回退。 + let prefixed = "curl -H 'X-Api-Key: sk-canary-prefix-key' https://example.com"; + let once = sanitize(prefixed); + assert!( + !once.contains("sk-canary-prefix-key"), + "既有前缀脱敏不得回退:{once}" + ); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + + // `--password <值>`:沿用既有 fail-closed 约定(含敏感 CLI 标志的行整行替换), + // 原始值随之消失,且再次脱敏结果不变。 + let with_secret = "vault login --password canary-password-value --env prod"; + let once = sanitize(with_secret); + assert!( + !once.contains("canary-password-value"), + "`--password <值>` 不得落盘明文:{once}" + ); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + + // `--password $ENV`:占位符不是密钥,但既有 `contains_sensitive_cli_flag` 按标志 + // fail-closed 整行替换(与 sanitize_error_context 一致),本次属契约内行为。 + let placeholder = "vault login --password $DEPLOY_PASSWORD --env prod"; + let once = sanitize(placeholder); + assert_eq!( + once, "[redacted sensitive context]", + "含敏感 CLI 标志的行按既有约定整行替换" + ); + assert_eq!(sanitize(&once), once, "脱敏必须幂等"); + } + + /// 判据:同 id 的快照按 `updatedAt` 单调合并——后到的旧快照不得把终态打回 `running`, + /// 也不得回退 `updatedAt`;`startedAt` 仍取最早。 + #[test] + fn tool_call_persist_keeps_newest_snapshot_per_item() { + let root = init_tool_call_project("tool-call-monotonic"); + let running = direct_tool_call_from_item( + root.path(), + &command_item("item-1", "npm run build"), + "turn-1", + false, + 1000, + ) + .expect("running tool call"); + assert_eq!(running.status, "running"); + let completed = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-1", + "type": "commandExecution", + "command": "npm run build", + "exitCode": 0, + "completedAtMs": 2000, + }), + "turn-1", + true, + 2000, + ) + .expect("completed tool call"); + assert_eq!(completed.status, "completed"); + assert_eq!(completed.updated_at, 2000); + + persist_direct_tool_call_at(root.path(), &completed).expect("persist completed first"); + persist_direct_tool_call_at(root.path(), &running).expect("persist stale running"); + let calls = read_direct_tool_calls_at(root.path()).expect("read after stale single write"); + assert_eq!(calls.len(), 1, "同一 id 只能有一行"); + assert_eq!( + calls[0].status, "completed", + "后到的旧快照不得把 completed 打回 running" + ); + assert_eq!(calls[0].updated_at, 2000, "旧快照不得回退 updatedAt"); + assert_eq!(calls[0].started_at, 1000, "startedAt 仍取最早"); + + // 回合末整批落盘那条路径同样不得回退。 + persist_direct_tool_calls_at(root.path(), std::slice::from_ref(&running)) + .expect("persist stale running batch"); + let calls = read_direct_tool_calls_at(root.path()).expect("read after stale batch write"); + assert_eq!( + calls[0].status, "completed", + "整批落盘路径同样不得把 completed 打回 running" + ); + assert_eq!(calls[0].updated_at, 2000, "整批落盘不得回退 updatedAt"); + } + + /// 判据:读回时同 id 的重复行也按 `updatedAt` 单调合并(磁盘上留有旧快照不得回退状态)。 + #[test] + fn tool_call_read_merges_duplicate_rows_monotonically() { + let root = init_tool_call_project("tool-call-read-monotonic"); + let path = tool_calls_path(root.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + let completed = tool_call_row("item-1", 1000, 2000); + let stale_running = tool_call_row("item-1", 1000, 1000) + .replace("\"status\":\"completed\"", "\"status\":\"running\""); + assert!(stale_running.contains("\"status\":\"running\"")); + std::fs::write(&path, format!("{completed}\n{stale_running}\n")).expect("write fixture"); + + let calls = read_direct_tool_calls_at(root.path()).expect("read duplicate rows"); + assert_eq!(calls.len(), 1, "同 id 归并成一条"); + assert_eq!( + calls[0].status, "completed", + "磁盘上更旧的快照不得把状态打回 running" + ); + assert_eq!(calls[0].updated_at, 2000, "归并保留更新的 updatedAt"); + assert_eq!(calls[0].started_at, 1000); + } + + /// 判据:项目内绝对路径落成项目相对路径,项目外绝对路径保持既有占位形状。 + #[test] + fn tool_call_paths_become_project_relative() { + let root = init_tool_call_project("tool-call-path-shape"); + let root_display = root.path().to_string_lossy().to_string(); + let inside = root + .path() + .join("game/src/x.ts") + .to_string_lossy() + .to_string(); + let outside = if cfg!(windows) { + r"C:\Windows\Temp\canary-outside.ts".to_string() + } else { + "/opt/canary/outside.ts".to_string() + }; + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-paths", + "type": "fileChange", + "changes": [ + {"path": inside, "kind": "update"}, + {"path": outside, "kind": "add"}, + ], + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("path tool call"); + let paths = call + .detail + .changes + .iter() + .map(|change| change.path.as_str()) + .collect::>(); + assert_eq!( + paths[0], "game/src/x.ts", + "项目内绝对路径必须落成项目相对路径(不能是占位符)" + ); + assert_eq!(paths[1], "", "项目外绝对路径保持占位形状"); + assert_eq!(call.summary, "game/src/x.ts", "摘要取首个变更路径"); + + persist_direct_tool_call_at(root.path(), &call).expect("persist path call"); + let raw = std::fs::read_to_string(tool_calls_path(root.path())).expect("read raw file"); + assert!( + !raw.contains(&root_display), + "落盘不得残留项目根目录:{raw}" + ); + } + + /// 判据:单行损坏(含非法 UTF-8 字节)只跳过损坏行,后续合法记录必须继续读回。 + #[test] + fn tool_call_read_skips_invalid_utf8_line() { + let root = init_tool_call_project("tool-call-invalid-utf8"); + let path = tool_calls_path(root.path()); + std::fs::create_dir_all(path.parent().expect("parent")).expect("create dir"); + + // 形态一(契约原文):合法行 + 非法字节行 + 合法行 → 读回 2 条。 + let mut bytes = Vec::new(); + bytes.extend_from_slice(tool_call_row("item-a", 1000, 1000).as_bytes()); + bytes.push(b'\n'); + bytes.extend_from_slice(&[0xff, 0xfe, b'\n']); + bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); + bytes.push(b'\n'); + std::fs::write(&path, &bytes).expect("write invalid utf8 fixture"); + let calls = read_direct_tool_calls_at(root.path()).expect("read with invalid utf8"); + assert_eq!( + calls.len(), + 2, + "非法 UTF-8 行只跳过该行,后面的合法记录必须读回" + ); + assert_eq!(calls[0].id, "item-a"); + assert_eq!(calls[1].id, "item-b"); + + // 形态二:损坏行缺换行(写入被截断),与紧随其后的记录黏成一行。 + // 此时被丢掉的只有黏连的那一行,其后的合法记录必须继续读回。 + let mut bytes = Vec::new(); + bytes.extend_from_slice(tool_call_row("item-a", 1000, 1000).as_bytes()); + bytes.push(b'\n'); + bytes.push(0xff); + bytes.extend_from_slice(tool_call_row("item-b", 2000, 2000).as_bytes()); + bytes.push(b'\n'); + bytes.extend_from_slice(tool_call_row("item-c", 3000, 3000).as_bytes()); + bytes.push(b'\n'); + std::fs::write(&path, &bytes).expect("write truncated utf8 fixture"); + let calls = read_direct_tool_calls_at(root.path()).expect("read with truncated line"); + assert_eq!( + calls.len(), + 2, + "损坏行缺换行时只丢黏连的那一行,其后的合法记录必须继续读回" + ); + assert_eq!(calls[0].id, "item-a"); + assert_eq!(calls[1].id, "item-c"); + } + + /// 判据:200 条上限是「按时间保留最新 200 条」,超出时更早回合的卡片会被静默丢弃 + /// (契约内行为,不是缺陷)。本用例只钉住现状与时间正序。 + #[test] + fn tool_call_cap_drops_oldest_turn_cards() { + let root = init_tool_call_project("tool-call-cap-oldest"); + let old_turn = (0..DIRECT_TOOL_CALL_LIMIT) + .map(|index| { + direct_tool_call_from_item( + root.path(), + &json!({ + "id": format!("item-{index:04}"), + "type": "commandExecution", + "command": format!("run {index}"), + "startedAtMs": 1000 + index as u64, + }), + "turn-old", + false, + 1000 + index as u64, + ) + .expect("old turn tool call") + }) + .collect::>(); + persist_direct_tool_calls_at(root.path(), &old_turn).expect("persist old turn"); + let newest = direct_tool_call_from_item( + root.path(), + &json!({ + "id": "item-newest", + "type": "commandExecution", + "command": "run newest", + "startedAtMs": 90_000, + }), + "turn-new", + false, + 90_000, + ) + .expect("newest tool call"); + persist_direct_tool_call_at(root.path(), &newest).expect("persist newest"); + + let read = read_direct_tool_calls_at(root.path()).expect("read capped"); + assert_eq!(read.len(), DIRECT_TOOL_CALL_LIMIT, "上限仍是 200 条"); + assert_eq!( + read.last().expect("last").id, + "item-newest", + "最新回合的卡片必须在" + ); + assert_eq!( + read.first().expect("first").id, + "item-0001", + "最旧回合的卡片被静默丢弃(老回合卡片会消失)" + ); + assert!( + read.windows(2) + .all(|pair| pair[0].timestamp() <= pair[1].timestamp()), + "回读必须按时间正序" + ); + } + + /// 判据:项目内路径的 `\` / `/` 两种写法与大小写变体都要落成同一份项目相对路径 + /// (Codex 上报的路径分隔符与盘符大小写不受我们控制)。 + #[test] + fn tool_call_paths_normalize_separators_and_case() { + let root = init_tool_call_project("tool-call-path-variants"); + let native = root.path().to_string_lossy().to_string(); + let variants = [native.replace('\\', "/"), native.to_ascii_uppercase()]; + let mut paths = Vec::new(); + for (index, variant) in variants.into_iter().enumerate() { + let call = direct_tool_call_from_item( + root.path(), + &json!({ + "id": format!("item-path-variant-{index}"), + "type": "fileChange", + "changes": [{"path": format!("{variant}/game/src/y.ts"), "kind": "update"}], + "startedAtMs": 1000, + }), + "turn-1", + false, + 1000, + ) + .expect("variant path tool call"); + paths.push(call.detail.changes[0].path.clone()); + } + assert_eq!(paths[0], "game/src/y.ts", "`/` 写法同样要落成项目相对路径"); + assert_eq!( + paths[1], "game/src/y.ts", + "大小写变体同样要落成项目相对路径" + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs new file mode 100644 index 000000000..a2c8f1b30 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_turn_stream.rs @@ -0,0 +1,439 @@ +//! GameAgent 对话「回合流」的采集、持久化与回读。 +//! +//! 顺序真相放在一处:`/.agent/conversations/turn-stream.jsonl` 按**出现顺序** +//! 记录一个回合里的文本段与工具调用。工具条目只记位置标记(`callId`),工具本身的正文 +//! 仍然来自 `tool-calls.jsonl`(同一 id 幂等合并只有一处实现)。 +//! +//! 位置稳定:每条条目的 `seq` 在**首次出现**时由观察方分配并落盘,后续更新(同一 id 的 +//! 文本追加 / 工具状态变化)只改内容不改 `seq`。因此并发落盘的先后顺序不会让"新工具插到 +//! 旧文本前面"——渲染顺序只由 `seq` 决定。 +//! +//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。 + +use crate::agent::sanitize_detail_text; +use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; +use crate::project::{enforce_project_permission_policy, project_append_lock_for}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::BTreeMap; +use std::fs::File; +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; + +/// 行信封类型,与既有历史文件同构(`{"type": …, "payload": {…}}`)。 +pub(crate) const DIRECT_TURN_STREAM_RECORD_TYPE: &str = "turn_stream_item"; +/// 条目 schema 版本。 +pub(crate) const DIRECT_TURN_STREAM_SCHEMA_VERSION: &str = "agc-turn-stream.v1"; +/// 回读上限:只保留最后这么多条(按 `seq` 取最新)。 +pub(crate) const DIRECT_TURN_STREAM_LIMIT: usize = 400; +/// 单条文本段的字符上限(与工具明细同口径的截断,避免单段失控)。 +const DIRECT_TURN_STREAM_TEXT_MAX_CHARS: usize = 8000; +/// 没有流式分段时,最终回复那一段的固定 item id。 +const DIRECT_TURN_STREAM_FINAL_ITEM_ID: &str = "final"; +/// 回合失败说明那一段的固定 item id:失败说明也是这一回合的内容,排在流末尾。 +pub(crate) const DIRECT_TURN_STREAM_FAILURE_ITEM_ID: &str = "failure"; + +/// 文本段。 +pub(crate) const DIRECT_TURN_STREAM_KIND_TEXT: &str = "text"; +/// 工具调用的位置标记。 +pub(crate) const DIRECT_TURN_STREAM_KIND_TOOL: &str = "tool"; + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectTurnStreamItem { + pub(crate) schema_version: String, + /// 幂等身份:文本段 `text::`、工具 `tool::`。 + pub(crate) id: String, + pub(crate) turn_id: String, + /// `text` | `tool` + pub(crate) kind: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, + /// 首次出现的写入序号:**顺序真相**,同刻按它排序。 + pub(crate) seq: u64, + /// 条目首次出现的本机毫秒时刻。 + pub(crate) at: u64, + pub(crate) updated_at: u64, +} + +#[cfg(test)] +mod snapshot_tests { + use super::*; + + fn text( + turn: &str, + id: &str, + seq: u64, + at: u64, + updated: u64, + text: &str, + ) -> DirectTurnStreamItem { + direct_turn_stream_text_item(Path::new("."), turn, id, text, seq, at, updated) + } + + #[test] + fn late_older_snapshot_cannot_undo_completed_text_or_position() { + let complete = text("turn", "item", 1, 1000, 1002, "正文"); + let late = text("turn", "item", 9, 1001, 1001, "更长但已经过期的草稿"); + let merged = normalize_stream_items(vec![complete, late]); + assert_eq!(merged.len(), 1); + assert_eq!(merged[0].text.as_deref(), Some("正文")); + assert_eq!(merged[0].seq, 1); + assert_eq!(merged[0].at, 1000); + } + + #[test] + fn retention_does_not_treat_new_turn_seq_one_as_oldest() { + let mut snapshots = (1..=DIRECT_TURN_STREAM_LIMIT) + .map(|seq| text("old", &seq.to_string(), seq as u64, 1000, 1000, "旧")) + .collect::>(); + snapshots.push(text("new", "one", 1, 2000, 2000, "新")); + let merged = normalize_stream_items(snapshots); + assert_eq!(merged.len(), DIRECT_TURN_STREAM_LIMIT); + assert_eq!(merged.last().unwrap().turn_id, "new"); + } +} + +impl DirectTurnStreamItem { + fn order_key(&self) -> (u64, u64, &str) { + (self.seq, self.at, self.id.as_str()) + } +} + +fn turn_stream_path(root: &Path) -> PathBuf { + root.join(".agent/conversations/turn-stream.jsonl") +} + +/// 文本段条目的幂等 id:同一个 Codex assistant item 只占一行。 +pub(crate) fn direct_turn_stream_text_item_id(turn_id: &str, item_id: &str) -> String { + format!("text:{}:{}", turn_id.trim(), item_id.trim()) +} + +/// 工具条目(位置标记)的幂等 id:同一个 callId 只占一行。 +pub(crate) fn direct_turn_stream_tool_item_id(turn_id: &str, call_id: &str) -> String { + format!("tool:{}:{}", turn_id.trim(), call_id.trim()) +} + +/// 构造一条文本段条目:脱敏 + 截断与 `tool-calls.jsonl` 同口径。 +pub(crate) fn direct_turn_stream_text_item( + root: &Path, + turn_id: &str, + item_id: &str, + text: &str, + seq: u64, + at: u64, + updated_at: u64, +) -> DirectTurnStreamItem { + DirectTurnStreamItem { + schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), + id: direct_turn_stream_text_item_id(turn_id, item_id), + turn_id: turn_id.trim().to_string(), + kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(), + text: Some(sanitize_stream_text(root, text)), + call_id: None, + seq, + at, + updated_at, + } +} + +/// 构造一条工具条目:只记位置,正文仍来自 `DirectToolCall`。 +pub(crate) fn direct_turn_stream_tool_item( + turn_id: &str, + call: &crate::DirectToolCall, + seq: u64, + at: u64, +) -> DirectTurnStreamItem { + DirectTurnStreamItem { + schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), + id: direct_turn_stream_tool_item_id(turn_id, &call.id), + turn_id: turn_id.trim().to_string(), + kind: DIRECT_TURN_STREAM_KIND_TOOL.to_string(), + text: None, + call_id: Some(call.id.trim().to_string()), + seq, + at, + updated_at: call.updated_at, + } +} + +/// 文本脱敏 + 截断:与 `tool-calls.jsonl` 同一套 `sanitize_detail_text`。 +pub(crate) fn sanitize_stream_text(root: &Path, text: &str) -> String { + let sanitized = sanitize_detail_text(root, text); + if sanitized.chars().count() <= DIRECT_TURN_STREAM_TEXT_MAX_CHARS { + return sanitized; + } + let mut truncated = sanitized + .chars() + .take(DIRECT_TURN_STREAM_TEXT_MAX_CHARS) + .collect::(); + truncated.push('…'); + truncated +} + +fn record_line(item: &DirectTurnStreamItem) -> Result { + serde_json::to_string(&serde_json::json!({ + "type": DIRECT_TURN_STREAM_RECORD_TYPE, + "payload": item, + })) + .map_err(|error| format!("序列化回合流条目失败:{error}")) +} + +/// 解析一行信封;坏行 / 非本文件条目都返回 `None`(尽力而为的展示数据,不整体失败)。 +fn stream_item_from_line(line: &str) -> Option { + let trimmed = line.trim(); + if trimmed.is_empty() { + return None; + } + let parsed: Value = serde_json::from_str(trimmed).ok()?; + if parsed.get("type").and_then(Value::as_str) != Some(DIRECT_TURN_STREAM_RECORD_TYPE) { + return None; + } + let payload = parsed.get("payload")?; + let mut item: DirectTurnStreamItem = serde_json::from_value(payload.clone()).ok()?; + if item.id.trim().is_empty() || item.turn_id.trim().is_empty() { + return None; + } + if !matches!( + item.kind.as_str(), + DIRECT_TURN_STREAM_KIND_TEXT | DIRECT_TURN_STREAM_KIND_TOOL + ) { + return None; + } + if item.schema_version.trim().is_empty() { + item.schema_version = DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(); + } + Some(item) +} + +fn read_stream_lines(path: &Path) -> Vec { + let Ok(file) = File::open(path) else { + return Vec::new(); + }; + let mut reader = BufReader::new(file); + let mut buffer = Vec::new(); + let mut items = Vec::new(); + loop { + buffer.clear(); + match reader.read_until(b'\n', &mut buffer) { + Ok(0) => break, + // 单行解码失败(非法 UTF-8)只跳过这一行,继续读后面的行。 + Ok(_) => match std::str::from_utf8(&buffer) { + Ok(line) => { + if let Some(item) = stream_item_from_line(line) { + items.push(item); + } + } + Err(_) => continue, + }, + // 读 I/O 错误:无法再定位下一行边界,停止读取(已读到的照常返回)。 + Err(_) => break, + } + } + items +} + +/// 同一 id 的重复行合并:`seq` 取最早(位置钉死,后到的不得回退),`at` 取最早非零, +/// `updated_at` 取最大;文本只在更新(或同刻更长)的快照上替换。 +fn merge_stream_snapshot( + existing: &DirectTurnStreamItem, + incoming: &DirectTurnStreamItem, +) -> DirectTurnStreamItem { + let text_len = |item: &DirectTurnStreamItem| { + item.text + .as_deref() + .map(str::chars) + .map(Iterator::count) + .unwrap_or_default() + }; + // writer 保证更新时间单调;完成快照可以纠正正文,旧快照不能靠更长抢回所有权。 + let take_incoming = incoming.updated_at > existing.updated_at + || (incoming.updated_at == existing.updated_at && text_len(incoming) > text_len(existing)); + let mut merged = existing.clone(); + if take_incoming { + merged.text = incoming.text.clone(); + } + merged.updated_at = merged.updated_at.max(incoming.updated_at); + if merged.call_id.is_none() { + merged.call_id = incoming.call_id.clone(); + } + merged.seq = merged.seq.min(incoming.seq); + merged.at = [merged.at, incoming.at] + .into_iter() + .filter(|at| *at > 0) + .min() + .unwrap_or_default(); + merged +} + +/// 按身份归并;跨回合按起点,回合内按 seq,不能用局部 seq 判断全局新旧。 +fn normalize_stream_items(items: Vec) -> Vec { + let mut by_id: BTreeMap = BTreeMap::new(); + for item in items { + let merged = match by_id.remove(&item.id) { + Some(previous) => merge_stream_snapshot(&previous, &item), + None => item, + }; + by_id.insert(merged.id.clone(), merged); + } + let mut normalized = by_id.into_values().collect::>(); + let mut turn_starts = BTreeMap::::new(); + for item in &normalized { + turn_starts + .entry(item.turn_id.clone()) + .and_modify(|at| *at = (*at).min(item.at)) + .or_insert(item.at); + } + normalized.sort_by(|left, right| { + (turn_starts[&left.turn_id], &left.turn_id, left.order_key()).cmp(&( + turn_starts[&right.turn_id], + &right.turn_id, + right.order_key(), + )) + }); + if normalized.len() > DIRECT_TURN_STREAM_LIMIT { + normalized.drain(..normalized.len() - DIRECT_TURN_STREAM_LIMIT); + } + normalized +} + +/// 锁内读改写:整文件重写(追加与就地更新混用,没有纯追加的 JSONL 语义)。 +/// 文件规模由 400 条上限与 8000 字符截断兜住。 +fn with_locked_stream_items( + root: &Path, + mutate: impl FnOnce(&mut Vec) -> T, +) -> Result { + let path = turn_stream_path(root); + let _project_lock = crate::acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "conversation.write", + )?; + let lock = project_append_lock_for(&path)?; + let _append_guard = lock.lock("回合流写入")?; + let mut items = read_stream_lines(&path); + let outcome = mutate(&mut items); + let normalized = normalize_stream_items(items); + let mut body = String::new(); + for item in &normalized { + body.push_str(&record_line(item)?); + body.push('\n'); + } + write_game_creator_private_file(&path, body.as_bytes(), "回合流历史")?; + Ok(outcome) +} + +/// 幂等 upsert 一条回合流条目。 +/// +/// 位置(`seq` / `at`)只在第一次出现时确定:同一 id 的后续快照不得回退位置, +/// 也不得把已经写下的文本改短(并发落盘下"后到的旧快照"不会覆盖新快照)。 +pub(crate) fn upsert_direct_turn_stream_item_at( + root: &Path, + item: &DirectTurnStreamItem, +) -> Result<(), String> { + enforce_project_permission_policy(root, "conversation.write")?; + with_locked_stream_items(root, |items| { + // normalize_stream_items 在锁内归并全部版本;不得提前删除比较基准。 + items.push(item.clone()); + }) +} + +/// 回读:文件缺失返回空数组;单行损坏跳过;按 `seq` 正序,最多最后 400 条。 +pub(crate) fn read_direct_turn_stream_at(root: &Path) -> Result, String> { + let path = turn_stream_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "回合流历史")? { + return Ok(Vec::new()); + } + Ok(normalize_stream_items(read_stream_lines(&path))) +} + +/// 追加一段固定身份的文本段(失败说明等):位置排在当前流末尾。 +/// +/// 幂等:同一 `(turnId, itemId)` 已经存在时只更新文本与 `updatedAt`(回合重放 / 重复收尾 +/// 不会多出一段)。返回写下的那一条,调用方用它下发同一份快照。 +pub(crate) fn append_direct_turn_stream_text_at( + root: &Path, + turn_id: &str, + item_id: &str, + text: &str, +) -> Result, String> { + let turn_id = turn_id.trim(); + let text = text.trim(); + if turn_id.is_empty() || text.is_empty() { + return Ok(None); + } + let sanitized = sanitize_stream_text(root, text); + let item_id = item_id.trim(); + enforce_project_permission_policy(root, "conversation.write")?; + let now = crate::agent::direct_tool_call_now_ms(); + with_locked_stream_items(root, |items| { + let existing_id = direct_turn_stream_text_item_id(turn_id, item_id); + if let Some(existing) = items.iter_mut().find(|item| item.id == existing_id) { + // 位置不动:只替换文本与 updatedAt。 + existing.text = Some(sanitized.clone()); + existing.updated_at = now.max(existing.updated_at); + return Some(existing.clone()); + } + // 首次出现:位置钉在末尾(当前最大 seq + 1)。 + let next_seq = items.iter().map(|item| item.seq).max().unwrap_or(0) + 1; + let item = DirectTurnStreamItem { + schema_version: DIRECT_TURN_STREAM_SCHEMA_VERSION.to_string(), + id: existing_id, + turn_id: turn_id.to_string(), + kind: DIRECT_TURN_STREAM_KIND_TEXT.to_string(), + text: Some(sanitized), + call_id: None, + seq: next_seq, + at: now, + updated_at: now, + }; + items.push(item.clone()); + Some(item) + }) +} + +/// 没有任何 item 文本时补最终回复;已有 item 由完成事件负责,不能猜测覆盖某一段。 +pub(crate) fn finalize_direct_turn_stream_reply_at( + root: &Path, + turn_id: &str, + visible_reply: &str, +) -> Result, String> { + let turn_id = turn_id.trim(); + if turn_id.is_empty() || visible_reply.trim().is_empty() { + return Ok(None); + } + // 入口再做一次可见性投影:调用方给的是原始回复时,思考块不能落进对话流。 + let visible_reply = crate::agent::project_direct_codex_visible_text(visible_reply) + .unwrap_or_else(|| visible_reply.trim().to_string()); + let visible_reply = visible_reply.as_str(); + enforce_project_permission_policy(root, "conversation.write")?; + let now = crate::agent::direct_tool_call_now_ms(); + with_locked_stream_items(root, |items| { + if items + .iter() + .any(|item| item.turn_id == turn_id && item.kind == DIRECT_TURN_STREAM_KIND_TEXT) + { + None + } else { + let next_seq = items + .iter() + .filter(|item| item.turn_id == turn_id) + .map(|item| item.seq) + .max() + .unwrap_or(0) + + 1; + let item = direct_turn_stream_text_item( + root, + turn_id, + DIRECT_TURN_STREAM_FINAL_ITEM_ID, + visible_reply, + next_seq, + now, + now, + ); + items.push(item.clone()); + Some(item) + } + }) +} 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 76b85ef7b..4038f1bcb 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 @@ -49,6 +49,61 @@ impl DirectGameCreatorTurnUpdateEmitter { status: &'static str, activity: Option<&'static str>, accumulated_text: Option, + tool_calls: Option>, + ) { + self.emit_with_reasoning(status, activity, accumulated_text, tool_calls, None); + } + + /// 带思考过程的回合更新:`reasoning_text` 为"当前累计的思考全文"(前端整段替换)。 + pub(crate) fn emit_with_reasoning( + &self, + status: &'static str, + activity: Option<&'static str>, + accumulated_text: Option, + tool_calls: Option>, + reasoning_text: Option, + ) { + self.emit_full( + status, + activity, + accumulated_text, + tool_calls, + reasoning_text, + Vec::new(), + ); + } + + /// 带回合流的回合更新:`stream_items` 是"顺序真相"里本次变化的那几条。 + /// + /// 前端按这些条目的 `seq` 顺序渲染,所以它们必须来自与落盘同一份数据, + /// 不能在前端各算一套顺序。 + pub(crate) fn emit_with_stream_items( + &self, + status: &'static str, + activity: Option<&'static str>, + accumulated_text: Option, + tool_calls: Option>, + stream_items: Vec, + ) { + self.emit_full( + status, + activity, + accumulated_text, + tool_calls, + None, + stream_items, + ); + } + + #[allow(clippy::too_many_arguments)] + fn emit_full( + &self, + status: &'static str, + activity: Option<&'static str>, + accumulated_text: Option, + tool_calls: Option>, + reasoning_text: Option, + stream_items: Vec, ) { let status_is_allowed = matches!( status, @@ -100,6 +155,9 @@ impl DirectGameCreatorTurnUpdateEmitter { status: status.to_string(), activity: activity.map(str::to_string), accumulated_text, + tool_calls, + reasoning_text, + stream_items: (!stream_items.is_empty()).then_some(stream_items), updated_at, }, ); 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 9de008de2..ca97077f1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -1996,6 +1996,29 @@ pub(crate) fn write_game_creator_app_config( persist_game_creator_app_config(config, overlays, false) } +#[tauri::command] +pub(crate) fn cancel_direct_codex_turn( + project_path: String, + client_turn_id: Option, +) -> Result { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "agent.kill")?; + cancel_direct_codex_turn_at(root, client_turn_id.as_deref()) +} + +#[tauri::command] +pub(crate) fn select_game_creator_reasoning_effort( + effort: String, +) -> Result { + let _guard = GAME_CREATOR_CONFIG_WRITE_LOCK + .lock() + .map_err(|_| "配置写入锁不可用")?; + let effort = game_creator_llm_reasoning_effort_name(&effort, "llm.reasoningEffort")?; + let (mut config, overlays) = load_game_creator_app_config_for_write()?; + config.llm.reasoning_effort = effort; + persist_game_creator_app_config(config, overlays, false) +} + #[tauri::command] pub(crate) fn select_game_creator_model( model_id: String, @@ -5283,6 +5306,31 @@ pub(crate) async fn read_agent_runtime_error_detail( .await .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? } +#[tauri::command] +pub(crate) async fn read_direct_tool_calls( + project_path: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_tool_calls_at(root) + }) + .await + .map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))? +} + +#[tauri::command] +pub(crate) async fn read_direct_turn_stream( + project_path: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let root = Path::new(project_path.trim()); + enforce_project_permission_policy(root, "conversation.read")?; + read_direct_turn_stream_at(root) + }) + .await + .map_err(|error| format!("读取回合流历史后台任务失败:{error}"))? +} #[tauri::command] pub(crate) fn list_game_creator_direct_active_turns( @@ -5330,12 +5378,16 @@ pub(crate) async fn read_direct_project_history_slice( tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (items, has_more) = read_direct_project_history_items_slice_at( + let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at( root, before_item_id.as_deref(), limit.unwrap_or(20), )?; - Ok(DirectThreadHistorySlice { items, has_more }) + Ok(DirectThreadHistorySlice { + items, + has_more, + item_timestamps, + }) }) .await .map_err(|error| format!("读取 DirectProject 历史切片后台任务失败:{error}"))? 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 660ff5699..bec55a981 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -1010,6 +1010,17 @@ struct GameCreatorDirectTurnUpdateEvent { status: String, activity: Option, accumulated_text: Option, + /// 本回合内发生变化的结构化工具调用集合(只有变化时才带,老事件没有这个字段)。 + /// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。 + #[serde(skip_serializing_if = "Option::is_none")] + tool_calls: Option>, + /// 本回合当前累计的思考过程(流式整段替换);拿不到时字段缺席。 + #[serde(skip_serializing_if = "Option::is_none")] + reasoning_text: Option, + /// 本回合**顺序真相**里本次发生变化的那几条(文本段 / 工具位置标记)。 + /// `skip_serializing_if`:字段缺席时前端拿到 `undefined`,行为与改造前一致。 + #[serde(skip_serializing_if = "Option::is_none")] + stream_items: Option>, updated_at: u64, } @@ -2666,6 +2677,8 @@ fn main() { chat_with_game_creator_role_agent, chat_with_game_creator_role_agent_stream, chat_with_game_creator_direct_codex, + cancel_direct_codex_turn, + select_game_creator_reasoning_effort, start_planning_session_v2, continue_planning_session_v2, decide_planning_artifact_v2, @@ -2769,6 +2782,8 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, + read_direct_tool_calls, + read_direct_turn_stream, read_agent_runtime_error_detail, list_game_creator_direct_active_turns, subscribe_direct_project_thread, diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 5c353d32e..2438d17f8 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -55,9 +55,11 @@ import type { DesignClarificationRequest, DesignEvent, DesignView, + DirectTurnCancelView, GameCreatorAgentRuntimeUpdateEvent, GameCreatorChatAgentReply, GameCreatorDirectActiveTurn, + GameCreatorDirectToolCall, GameCreatorDirectTurnUpdateEvent, GameCreatorLlmConfigStatus, GameCreatorManifestInvalidatedEvent, @@ -97,6 +99,7 @@ import type { ProjectPermissionPolicyView, SyncCanvasProjectAssetsResult, TauriInvoke, + TurnStreamItem, UploadLocalAssetResult, } from './app/types'; import { useWindowChrome } from './components/windowChromeContext'; @@ -133,6 +136,7 @@ import { submitProjectSupervisorRuntimeTask, taskRowsFromManifest, } from './features/agent-runtime'; +import { DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS } from './features/agent-runtime/directActiveTurns'; import { type DirectCodexTurnAttachment, toDirectCodexTurnAttachments, @@ -148,6 +152,7 @@ import { type WorkspaceLauncherProps, writeRecentWorkspace, } from './features/app-shell/model'; +import { uploadLocalFilesAsAttachments } from './features/app-shell/useHomeProjectCreation'; import { WorkspaceLauncherShell } from './features/app-shell/WorkspaceLauncher'; import { agentConversationReadDraftsFromManifest, @@ -221,6 +226,15 @@ import { isMissingProjectFileError, parseAgentRunTrace, } from './features/project-workspace/agentRunTrace'; +import { + chatQueueFullNotice, + createQueuedChatTurn, + dequeueChatTurn, + enqueueChatTurn, + isChatTurnQueueFull, + type QueuedChatTurn, + removeQueuedChatTurn, +} from './features/project-workspace/chatComposerQueue'; import { DeveloperProjectPanels } from './features/project-workspace/DeveloperProjectPanels'; import { DeveloperRuntimePanels } from './features/project-workspace/DeveloperRuntimePanels'; import { @@ -228,8 +242,7 @@ import { directThreadHistoryItemsToMessages, type DirectThreadHistorySlice, type DirectThreadSubscriptionBootstrap, - emptyDirectThreadReducerState, - reduceDirectThreadEvents, + isDirectTurnInProgress, } from './features/project-workspace/directThreadEvents'; import type { DirectCodexUserContentPart } from './features/project-workspace/generated'; import { @@ -297,6 +310,16 @@ const DIRECT_CODEX_PRODUCT_RUNTIME = true; const DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX = 'direct-codex:'; const DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX = 'direct-codex-turn-already-running:'; +/** 与 Rust 侧 `DirectTaonierActiveInvocationGuard::enter` 的 else 分支文案保持一致。 */ +const DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER = + '当前项目已有另一条 Direct 客户端回合正在运行'; +/** + * 恢复出来的回合多久没有任何事件就算"没响应"。Rust 守卫是进程内的:重进会话时它还在, + * 但 app-server 侧可能早就没了。这时界面必须给出明确动作,而不是让用户一直等。 + */ +const DIRECT_CODEX_RECOVERED_TURN_STALLED_MS = 15_000; +const DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE = + '该回合已无响应,可在输入盒点「终止」结束它以继续'; // Platform access tokens are short lived. DirectProject can spend several // minutes in image generation, build and browser validation, so keep the // client-owned native session current while a turn is running. The singleflight @@ -388,9 +411,8 @@ function directCodexActivityDetail( case 'finalizing': return '正在整理结果'; case 'completed': - return '正在提交回复'; case 'failed': - return '正在记录失败原因'; + return ''; default: return '正在处理任务'; } @@ -423,11 +445,8 @@ function directCodexProcessDetail({ activity?: string | null; status: string; }) { - if (status === 'completed') { - return '正在提交回复'; - } - if (status === 'failed') { - return '正在记录失败原因'; + if (status === 'completed' || status === 'failed') { + return ''; } if (status === 'streaming') { return '正在生成回复'; @@ -473,6 +492,72 @@ function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } +export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; + +/** + * 回合流排序:`seq`(条目首次出现时钉死)优先,其次 `at`,最后按 id 兜底。 + * 与 Rust 侧同一口径——前端不自己发明顺序。 + */ +function sortTurnStreamItems(items: readonly TurnStreamItem[]) { + return [...items].sort( + (left, right) => + left.seq - right.seq || + left.at - right.at || + left.id.localeCompare(right.id), + ); +} + +/** + * 归并一批回合流条目:同 id 幂等覆盖(`updatedAt` 单调,同刻取更长文本),新 id 追加。 + * 实时增量与回读历史共用这一处,所以界面上的顺序只有一份来源。 + */ +function mergeTurnStreamItems( + existing: readonly TurnStreamItem[], + incoming: readonly TurnStreamItem[], +): TurnStreamItem[] { + if (incoming.length === 0) { + return [...existing]; + } + const byId = new Map(); + for (const item of existing) { + const id = item.id?.trim(); + if (id) { + byId.set(id, item); + } + } + for (const item of incoming) { + const id = item.id?.trim(); + if (!id) { + continue; + } + const previous = byId.get(id); + const normalized: TurnStreamItem = { ...item, id }; + if (!previous) { + byId.set(id, normalized); + continue; + } + // 内容只在更新(或同刻更长)的快照上替换;`seq` 取最早,位置不许回退。 + const textLength = (value: TurnStreamItem) => + value.kind === 'text' ? (value.text?.length ?? 0) : 0; + // writer 更新时间单调;完成快照允许纠正正文,迟到旧快照不能覆盖。 + const takeIncoming = + normalized.updatedAt > previous.updatedAt || + (normalized.updatedAt === previous.updatedAt && + textLength(normalized) > textLength(previous)); + byId.set(id, { + ...(takeIncoming ? normalized : previous), + id, + updatedAt: Math.max(previous.updatedAt, normalized.updatedAt), + seq: Math.min(previous.seq, normalized.seq), + at: + previous.at > 0 && normalized.at > 0 + ? Math.min(previous.at, normalized.at) + : Math.max(previous.at, normalized.at), + } as TurnStreamItem); + } + return sortTurnStreamItems([...byId.values()]); +} + export function isDirectCodexTurnAlreadyRunningError(error: unknown) { const message = error instanceof Error ? error.message : String(error); return message @@ -480,6 +565,26 @@ export function isDirectCodexTurnAlreadyRunningError(error: unknown) { .startsWith(DIRECT_CODEX_TURN_ALREADY_RUNNING_ERROR_PREFIX); } +/** + * 另一条 Direct 回合占着这个项目时的拒绝。它与上面那条同 clientTurnId 的拒绝分属不同 + * 错误分类(Rust 侧刻意不带前缀),但对界面是同一件事:本项目现在有一条我们没接管的 + * 回合在跑。所以这里单独判定,让它也走"接管它 + 告诉用户出口"的处理。 + */ +export function isDirectCodexAnotherTurnRunningError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return message.includes(DIRECT_CODEX_ANOTHER_TURN_RUNNING_ERROR_MARKER); +} + +/** + * 用户点了"终止"以后,正在 await 的回合命令会带着 app-server 的中断原因返回 + * (`Codex app-server turn 已中断`)。这类错误是用户主动取消,不是失败:界面要给 + * "已终止本次回合"而不是把中断当作异常写进运行错误与诊断。 + */ +export function isDirectCodexTurnInterruptedError(error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return message.includes('turn 已中断') || message.includes('已终止本次回合'); +} + function isPersistableDirectCodexConversationMessage(message: ChatMessage) { if (!message.runtimeOwned) { return false; @@ -512,6 +617,41 @@ function claimInitialSupervisorMessageForPage(projectPath: string) { return true; } +/** + * 历史回读与"尚未落盘的运行时消息"合并。 + * + * 初始需求是**乐观插入**到 messages 的(latch 命中后先插一条 user 消息,再发起回合), + * 而历史回读在 replace 分支里是无条件整体替换 —— 只要回读晚于乐观插入,那条用户消息 + * 就会被冲掉(界面上看不到初始需求,但回合其实已经跑起来了)。 + * 这里把当前 messages 里"运行时拥有、且回读结果里没有"的消息保留在末尾(它们是最新的)。 + */ +function mergeLoadedConversationWithPendingRuntimeMessages( + loaded: ChatMessage[], + current: ChatMessage[], +): ChatMessage[] { + if (current.length === 0) { + return loaded; + } + const loadedIds = new Set( + loaded + .map((message) => message.messageId) + .filter((id): id is string => Boolean(id)), + ); + const loadedTexts = new Set( + loaded.map((message) => `${message.role}\u0000${message.text}`), + ); + const pending = current.filter((message) => { + if (!message.runtimeOwned) { + return false; + } + if (message.messageId) { + return !loadedIds.has(message.messageId); + } + return !loadedTexts.has(`${message.role}\u0000${message.text}`); + }); + return pending.length > 0 ? [...loaded, ...pending] : loaded; +} + export { AuthenticatedClient } from './app/AuthenticatedClient'; export type { PendingCommand } from './app/types'; export { @@ -655,6 +795,7 @@ export function App({ useEffect(() => { if (supervisorChatOnly) return; const nextProjectPath = localProject?.projectPath ?? null; + ensureDirectTimelineProject(nextProjectPath); const previousProjectPath = localProjectPathRef.current; localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 @@ -755,10 +896,39 @@ export function App({ : '', ); const [chatReferences, setChatReferences] = useState([]); + /** + * 输入盒待发送附件(direct-codex 回合附件):上传成功后先生成 chip,随下次提交一起 + * 交给 `chat_with_game_creator_direct_codex` 的 `attachments`。附件只存在于前端状态, + * 提交后即清空——后端协议不变。 + */ + const [chatAttachments, setChatAttachments] = useState< + DirectCodexTurnAttachment[] + >([]); + const [chatAttachmentNotice, setChatAttachmentNotice] = useState(''); + /** 回合运行中再次发送的消息:FIFO 本地队列,当前回合结束后依次发出。 */ + const [chatTurnQueue, setChatTurnQueue] = useState([]); + const chatTurnQueueRef = useRef([]); + chatTurnQueueRef.current = chatTurnQueue; + const [chatComposerNotice, setChatComposerNotice] = useState(''); + const [directCodexTurnCancelling, setDirectCodexTurnCancelling] = + useState(false); + const queuedChatTurnSequenceRef = useRef(0); const [chatContent, setChatContent] = useState( [], ); const chatComposerRef = useRef(null); + /** + * 切项目即清空只属于上一个项目的输入盒状态:待发附件的 `localPath` 是**项目相对**的, + * 队列也属于刚结束的那条对话;留着会把 A 项目的附件路径带进 B 项目的下一个回合。 + */ + useEffect(() => { + setChatAttachments([]); + setChatContent([]); + setChatAttachmentNotice(''); + setChatComposerNotice(''); + setChatTurnQueue([]); + chatTurnQueueRef.current = []; + }, [localProject?.projectPath]); const [chatAgentBusy, setChatAgentBusy] = useState(false); const [directCodexProgress, setDirectCodexProgress] = useState(''); const [directCodexStatus, setDirectCodexStatus] = useState< @@ -770,6 +940,10 @@ export function App({ const [directCodexTransientReply, setDirectCodexTransientReply] = useState(''); const directCodexTransientReplyRef = useRef(''); + // 直连回合的思考过程(流式):整段替换;回合结束/开始新回合/清空对话时一并清掉。 + const [directCodexTransientReasoning, setDirectCodexTransientReasoning] = + useState(''); + /** 实时回合里"某个工具首次出现时,已生成正文的长度"——用它把正文与工具交替排列。 */ const [ directCodexTransientReplyUpdatedAt, setDirectCodexTransientReplyUpdatedAt, @@ -779,11 +953,100 @@ export function App({ turnId: string; lastSequence: number; receivedDirectUpdate: boolean; + restored?: boolean; + } | null>(null); + const directActiveSnapshotVersionRef = useRef(0); + const directTurnLifecycleRef = useRef<{ + reset: () => void; + loadHistory: (projectPath: string) => Promise; + restore: (projectPath: string) => Promise; } | null>(null); - const directThreadSubscriptionIdRef = useRef(null); - const directThreadReducerStateRef = useRef(emptyDirectThreadReducerState()); const lastDirectCodexActivityRef = useRef(null); + /** + * 重进会话后从 Rust 恢复出来的回合:只有在恢复后的第一个窗口内一直收不到事件, + * 才判定"这一轮其实已经没响应",给出终止出口。收到任何一条本回合事件就撤掉。 + */ + const recoveredDirectCodexTurnRef = useRef<{ + projectPath: string; + turnId: string; + } | null>(null); + const recoveredDirectCodexTurnTimerRef = useRef(null); const directCodexConversationTurnSequenceRef = useRef(0); + // 工具调用卡片:按 **id** 归并(实时增量 + 回读历史共用一份),同一 id 只渲染一次。 + // 用 ref 做写入基准,避免同一批事件里多条增量互相覆盖。 + const [directToolCalls, setDirectToolCalls] = useState< + GameCreatorDirectToolCall[] + >([]); + const directToolCallsRef = useRef([]); + const directTimelineProjectPathRef = useRef(null); + + function ensureDirectTimelineProject(project: string | null) { + if (directTimelineProjectPathRef.current === project) return; + directTimelineProjectPathRef.current = project; + directToolCallsRef.current = []; + directTurnStreamRef.current = []; + setDirectToolCalls([]); + setDirectTurnStream([]); + } + /** + * 归并一批工具调用:同 id 覆盖已有条目(`completed` 覆盖 `running`), + * 新 id 追加(保持首次出现顺序)。实时增量与回读历史都走这里,所以同一 id 不会重复渲染。 + */ + function applyDirectToolCalls( + incoming: readonly GameCreatorDirectToolCall[], + ) { + if (incoming.length === 0) { + return; + } + const merged = [...directToolCallsRef.current]; + for (const call of incoming) { + const id = call.id?.trim(); + if (!id) { + continue; + } + const existingIndex = merged.findIndex( + (existing) => existing.id === id && existing.turnId === call.turnId, + ); + const normalized: GameCreatorDirectToolCall = { + ...call, + id, + detail: call.detail ?? { changes: [] }, + }; + // 起点时间取更早的那个:`completed` 事件不一定带 startedAt。 + const existing = existingIndex >= 0 ? merged[existingIndex] : undefined; + if (existing) { + if ( + existing.updatedAt > normalized.updatedAt || + (existing.status !== 'running' && normalized.status === 'running') + ) + continue; + normalized.detail = { + ...normalized.detail, + command: normalized.detail.command ?? existing.detail.command, + output: normalized.detail.output ?? existing.detail.output, + }; + } + if ( + existing && + existing.startedAt > 0 && + (normalized.startedAt === 0 || + existing.startedAt < normalized.startedAt) + ) { + normalized.startedAt = existing.startedAt; + } + if (existingIndex >= 0) { + merged[existingIndex] = normalized; + } else { + merged.push(normalized); + } + } + merged.sort( + (left, right) => + left.startedAt - right.startedAt || left.id.localeCompare(right.id), + ); + directToolCallsRef.current = merged; + setDirectToolCalls(merged); + } const [projectSupervisorSessionId, setProjectSupervisorSessionId] = useState< string | null >(null); @@ -804,6 +1067,8 @@ export function App({ } function resetDirectCodexTurn() { + directActiveSnapshotVersionRef.current += 1; + clearRecoveredDirectCodexTurnWatch(); activeDirectCodexTurnRef.current = null; lastDirectCodexActivityRef.current = null; setDirectCodexProgress(''); @@ -811,10 +1076,168 @@ export function App({ setDirectCodexProcessKey(''); setDirectCodexProgressUpdatedAt(null); setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); } + /** 撤掉"恢复出来的回合没响应"的看门狗;回合正常结束、被终止、或收到事件时都要撤。 */ + function clearRecoveredDirectCodexTurnWatch() { + if (recoveredDirectCodexTurnTimerRef.current !== null) { + window.clearTimeout(recoveredDirectCodexTurnTimerRef.current); + recoveredDirectCodexTurnTimerRef.current = null; + } + recoveredDirectCodexTurnRef.current = null; + } + + /** + * 给恢复出来的回合挂一个看门狗:一个窗口内没有任何本回合事件,就说明 app-server 侧 + * 其实已经没了、Rust 守卫是残留。这时把可读动作放到过程卡与输入盒提示上,用户点 + * 「终止」会走 `cancel_direct_codex_turn` 的兜底释放(见 handleCancelDirectCodexTurn)。 + * 收到任何一条本回合事件就由调用方撤掉它,绝不会覆盖真实的进度文案。 + */ + function watchRecoveredDirectCodexTurn(projectPath: string, turnId: string) { + clearRecoveredDirectCodexTurnWatch(); + recoveredDirectCodexTurnRef.current = { projectPath, turnId }; + recoveredDirectCodexTurnTimerRef.current = window.setTimeout(() => { + recoveredDirectCodexTurnTimerRef.current = null; + const watch = recoveredDirectCodexTurnRef.current; + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !watch || + watch.projectPath !== projectPath || + watch.turnId !== turnId || + activeTurn?.projectPath !== projectPath || + activeTurn.turnId !== turnId || + activeTurn.receivedDirectUpdate + ) { + return; + } + setDirectCodexStatus('running'); + setDirectCodexProgress(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); + setDirectCodexProgressUpdatedAt(Date.now()); + setChatComposerNotice(DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE); + }, DIRECT_CODEX_RECOVERED_TURN_STALLED_MS); + } + + /** + * 重进会话时接管仍在运行的 Direct 回合。 + * + * 背景:活跃回合的守卫(`DirectTaonierActiveInvocationGuard`)是 Rust 进程内的,重开 + * 项目时前端 `activeDirectCodexTurnRef` 是空的——界面既不订阅这一轮的事件,也不显示 + * 过程卡,用户再发消息只会被守卫拒绝。这里把后端登记的回合读回来重新接管。 + * + * 只读探测,不改后端回合本身;探测失败保留当前已知状态,不视为没有活动回合。 + */ + async function restoreRunningDirectCodexTurn( + projectPath: string, + reconcile = false, + ) { + if (!directCodexProductRuntime || !projectPath) { + return; + } + const invoke = resolveTauriInvoke(); + if (!invoke) { + return; + } + const owner = activeDirectCodexTurnRef.current; + const sequence = owner?.lastSequence; + const scopeVersion = projectScopeVersionRef.current; + if (!reconcile && owner?.projectPath === projectPath) { + return; + } + const readVersion = ++directActiveSnapshotVersionRef.current; + let turns: GameCreatorDirectActiveTurn[]; + try { + turns = await invoke( + 'list_game_creator_direct_active_turns', + ); + } catch { + // 读取失败不等于没有活动回合。 + return; + } + if (!Array.isArray(turns)) return; + if ( + localProjectPathRef.current !== projectPath || + planningV2ActiveRef.current || + designAgentLaneRef.current || + projectScopeVersionRef.current !== scopeVersion || + directActiveSnapshotVersionRef.current !== readVersion || + activeDirectCodexTurnRef.current !== owner || + owner?.lastSequence !== sequence + ) { + return; + } + const activeView = turns.find( + (turn) => + projectPathsMatchForInvalidation(turn.projectPath, projectPath) && + isDirectTurnInProgress(turn.status), + ); + if (!activeView || !isDirectTurnInProgress(activeView.status)) { + // 本地刚发送但尚未进入 Rust 的请求不能被空快照取消。 + if (owner && !owner.restored && owner.lastSequence < 0) return; + resetDirectCodexTurn(); + setChatAgentBusy(false); + if (owner && reconcile) { + void loadProjectConversation(projectPath, false, 'replace'); + } + return; + } + const matchingOwner = owner?.turnId === activeView.turnId ? owner : null; + if (owner && !matchingOwner) { + if (!owner.restored && owner.lastSequence < 0) return; + resetDirectCodexTurn(); + } + if (!matchingOwner) { + activeDirectCodexTurnRef.current = { + projectPath, + turnId: activeView.turnId, + lastSequence: -1, + receivedDirectUpdate: false, + restored: true, + }; + setDirectCodexProcessKey(`${projectPath}\u0000${activeView.turnId}`); + setProjectSupervisorRuntimeError(''); + watchRecoveredDirectCodexTurn(projectPath, activeView.turnId); + } + setChatAgentBusy(true); + // 活动快照不携带正文;已有实时进度不能被同序号的通用描述覆盖。 + if ( + !matchingOwner?.receivedDirectUpdate || + activeView.sequence > matchingOwner.lastSequence + ) { + setDirectCodexStatus(activeView.status); + setDirectCodexProgress( + directCodexActivityDetail(activeView.activity, activeView.status), + ); + setDirectCodexProgressUpdatedAt(activeView.updatedAt); + } + } + + directTurnLifecycleRef.current = { + reset: resetDirectCodexTurn, + loadHistory: (projectPath) => + loadProjectConversation(projectPath, false, 'replace'), + restore: (projectPath) => restoreRunningDirectCodexTurn(projectPath, true), + }; + + // 回合流(文本段 + 工具按**出现顺序**交替):实时增量与回读历史共用一份状态, + // 渲染顺序只由条目的 `seq` 决定,界面不再按文本长度 / 标点 / 时间窗猜切点。 + const [directTurnStream, setDirectTurnStream] = useState( + [], + ); + const directTurnStreamRef = useRef([]); + + /** 归并一批回合流条目(实时事件里的 `streamItems`)。 */ + function applyTurnStreamItems(incoming: readonly TurnStreamItem[]) { + if (incoming.length === 0) { + return; + } + const merged = mergeTurnStreamItems(directTurnStreamRef.current, incoming); + directTurnStreamRef.current = merged; + setDirectTurnStream(merged); + } + function clearDirectCodexTransientReply(projectPath: string, turnId: string) { const activeTurn = activeDirectCodexTurnRef.current; if ( @@ -825,6 +1248,7 @@ export function App({ } activeDirectCodexTurnRef.current = null; setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); return true; @@ -964,9 +1388,29 @@ export function App({ const sessionError = result.session.lastError?.summary ?? resultError; setProjectSupervisorRuntimeError(sessionError); if (result.conversation) { - const conversationMessages = planningMessagesToChatMessages( + let conversationMessages = planningMessagesToChatMessages( result.conversation, ); + // 创建项目后的首条需求可能先于规划会话快照到达;不能让后到的空快照 + // 把用户刚发出的内容覆盖掉。 + const initialPrompt = initialSupervisorMessageLatchRef.current.prompt; + if ( + initialPrompt && + !conversationMessages.some( + (message) => + message.role === 'user' && message.text.trim() === initialPrompt, + ) + ) { + conversationMessages = [ + { + role: 'user', + text: initialPrompt, + runtimeOwned: true, + updatedAt: Date.now(), + }, + ...conversationMessages, + ]; + } setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); setMessages(conversationMessages); savedConversationProjectPathRef.current = localProjectPathRef.current; @@ -1050,7 +1494,7 @@ export function App({ texts.push(entry.text); reasoningByMessageId.set(entry.messageId, texts); } - return view.messages + const messages: ChatMessage[] = view.messages .filter((message) => message.text.trim()) .map((message) => ({ role: message.role === 'user' ? 'user' : 'assistant', @@ -1060,6 +1504,21 @@ export function App({ reasoningText: reasoningByMessageId.get(message.id)?.join('\n\n'), updatedAt: Date.now(), })); + const initialPrompt = initialSupervisorMessageLatchRef.current.prompt; + if ( + initialPrompt && + !messages.some( + (message) => message.role === 'user' && message.text === initialPrompt, + ) + ) { + messages.unshift({ + role: 'user', + text: initialPrompt, + runtimeOwned: true, + updatedAt: Date.now(), + }); + } + return messages; } function applyDesignView(view: DesignView, projectPath: string) { @@ -1850,19 +2309,45 @@ export function App({ } activeTurn.lastSequence = payload.sequence; activeTurn.receivedDirectUpdate = true; + // 恢复出来的回合只要回来一条真实事件,就不再是"没响应",撤掉看门狗与那句提示。 + if ( + recoveredDirectCodexTurnRef.current?.projectPath === + payload.projectPath && + recoveredDirectCodexTurnRef.current.turnId === payload.turnId + ) { + clearRecoveredDirectCodexTurnWatch(); + setChatComposerNotice((current) => + current === DIRECT_CODEX_RECOVERED_TURN_STALLED_NOTICE + ? '' + : current, + ); + } + ensureDirectTimelineProject(payload.projectPath); + // 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。 + if (payload.toolCalls?.length) { + applyDirectToolCalls( + payload.toolCalls.map((call) => ({ + ...call, + turnId: payload.turnId, + })), + ); + } + if (typeof payload.reasoningText === 'string') { + setDirectCodexTransientReasoning(payload.reasoningText); + } + // 回合流的顺序真相:字段可选,老事件(undefined)走原路径。 + if (payload.streamItems?.length) { + applyTurnStreamItems(payload.streamItems); + } const updatedAt = Number.isFinite(payload.updatedAt) && payload.updatedAt > 0 ? payload.updatedAt : Date.now(); const processDetail = directCodexProcessDetail(payload); - if (payload.status === 'failed') { - activeDirectCodexTurnRef.current = null; - lastDirectCodexActivityRef.current = null; - setDirectCodexStatus(payload.status); - setDirectCodexProgress(processDetail); - setDirectCodexProgressUpdatedAt(updatedAt); - setDirectCodexTransientReply(''); - setDirectCodexTransientReplyUpdatedAt(null); + if (payload.status === 'failed' || payload.status === 'completed') { + directTurnLifecycleRef.current?.reset(); + setChatAgentBusy(false); + void directTurnLifecycleRef.current?.loadHistory(payload.projectPath); return; } setDirectCodexStatus(payload.status); @@ -1922,122 +2407,77 @@ export function App({ useEffect(() => { const projectPath = localProject?.projectPath ?? null; const directInvoke = resolveTauriInvoke(); - if (!directCodexProductRuntime || !projectPath || !directInvoke) { - directThreadSubscriptionIdRef.current = null; - directThreadReducerStateRef.current = emptyDirectThreadReducerState(); - return; - } + if (!directCodexProductRuntime || !projectPath || !directInvoke) return; let disposed = false; let cleanup: (() => void) | null = null; + let subscriptionId: string | null = null; + let consuming = false; + let consumeAgain = false; - const applyReducerState = ( - state: ReturnType, - ) => { - if (disposed) return; - directThreadReducerStateRef.current = state; - const running = - state.status === 'accepted' || - state.status === 'running' || - state.status === 'streaming' || - state.status === 'finalizing'; - setChatAgentBusy(running); - setDirectCodexStatus(state.status); - setDirectCodexProgress(state.progress); - setDirectCodexProgressUpdatedAt(Date.now()); - if (state.accumulatedText) { - setDirectCodexTransientReply(state.accumulatedText); - directCodexTransientReplyRef.current = state.accumulatedText; - setDirectCodexTransientReplyUpdatedAt(Date.now()); - } else { - setDirectCodexTransientReply(''); - directCodexTransientReplyRef.current = ''; - setDirectCodexTransientReplyUpdatedAt(null); - } + // Provider 原始事件只用于通知;运行状态始终取 client 回合快照和 Direct 事件。 + const refreshActive = () => { + if (!disposed) void directTurnLifecycleRef.current?.restore(projectPath); }; - const bootstrap = async () => { - try { - const activeTurns = await directInvoke( - 'list_game_creator_direct_active_turns', - ); - const activeTurn = activeTurns.find((turn) => - projectPathsMatchForInvalidation(turn.projectPath, projectPath), - ); - if (activeTurn && !activeDirectCodexTurnRef.current) { - activeDirectCodexTurnRef.current = { - projectPath, - turnId: activeTurn.turnId, - lastSequence: 0, - receivedDirectUpdate: true, - }; - setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`); - setChatAgentBusy(true); - setDirectCodexStatus('running'); - setDirectCodexProgress('正在处理'); - setDirectCodexProgressUpdatedAt(Date.now()); - } - } catch { - // 订阅 bootstrap 仍是恢复事实源;快照失败不能被改写成“没有在跑”。 - } const result = await directInvoke( 'subscribe_direct_project_thread', { projectPath }, ); if (disposed) return; - directThreadSubscriptionIdRef.current = result.subscriptionId; - const state = reduceDirectThreadEvents( - result.events, - emptyDirectThreadReducerState(), - ); - applyReducerState(state); - if (state.turnId && !activeDirectCodexTurnRef.current) { - activeDirectCodexTurnRef.current = { - projectPath, - turnId: state.turnId, - lastSequence: state.lastSeq, - receivedDirectUpdate: true, - }; - setDirectCodexProcessKey(`${projectPath}\u0000${state.turnId}`); - } + subscriptionId = result.subscriptionId; + refreshActive(); }; - const consume = async () => { - const subscriptionId = directThreadSubscriptionIdRef.current; if (!subscriptionId || disposed) return; + if (consuming) { + consumeAgain = true; + return; + } + consuming = true; try { - const result = await directInvoke( - 'consume_direct_project_thread', - { subscriptionId }, - ); - if (disposed) return; - const state = reduceDirectThreadEvents( - result.events, - directThreadReducerStateRef.current, - ); - applyReducerState(state); + do { + consumeAgain = false; + const result = await directInvoke( + 'consume_direct_project_thread', + { subscriptionId }, + ); + if (disposed) return; + if ( + result.events.some( + (event) => + event.type === 'turn.started' || + event.type === 'turn.completed', + ) + ) { + refreshActive(); + } + if ( + result.events.some((event) => event.type === 'turn.completed') && + !activeDirectCodexTurnRef.current?.receivedDirectUpdate + ) { + // 重进时若未接到 Direct 结束事件,原始 item 的落盘通知仍可补齐最终回复。 + void directTurnLifecycleRef.current?.loadHistory(projectPath); + } + } while (consumeAgain && !disposed); } catch (error) { - if (String(error).includes('SUBSCRIPTION_EXPIRED')) { - directThreadSubscriptionIdRef.current = null; + if (!disposed && String(error).includes('SUBSCRIPTION_EXPIRED')) { + subscriptionId = null; try { await bootstrap(); } catch { - // A later project activation or notification will retry bootstrap. + /* 活动快照轮询仍然有效。 */ } } + } finally { + consuming = false; } }; - const setup = async () => { try { const unlisten = await subscribeTauriEvent<{ subscriptionId: string }>( 'game-creator-direct-thread-notify', (event) => { - if ( - event.payload.subscriptionId === - directThreadSubscriptionIdRef.current - ) { - void consume(); - } + if (event.payload.subscriptionId === subscriptionId) void consume(); }, ); if (disposed) { @@ -2046,15 +2486,21 @@ export function App({ } cleanup = unlisten; await bootstrap(); + await consume(); } catch { - // The history view remains usable when the runtime subscription is unavailable. + // 历史仍可使用;订阅失败不伪造忙碌态。 } }; + refreshActive(); + const timer = window.setInterval( + refreshActive, + DIRECT_ACTIVE_TURNS_POLL_INTERVAL_MS, + ); void setup(); return () => { disposed = true; cleanup?.(); - directThreadSubscriptionIdRef.current = null; + window.clearInterval(timer); }; }, [directCodexProductRuntime, localProject?.projectPath]); @@ -3509,8 +3955,12 @@ export function App({ setProjectSupervisorResponseStream(null); } setConversationVisibleCount(CONVERSATION_INITIAL_VISIBLE_COUNT); - setMessages(() => { - const nextMessages = conversationMessages; + setMessages((current) => { + // 不能整体替换:乐观插入、尚未落盘的用户消息会被冲掉(初始需求看不到就是这个原因)。 + const nextMessages = mergeLoadedConversationWithPendingRuntimeMessages( + conversationMessages, + current, + ); savedConversationProjectPathRef.current = nextProjectPath; savedConversationCountRef.current = nextMessages.length; latestMessagesRef.current = nextMessages; @@ -3661,7 +4111,10 @@ export function App({ return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages(slice.items), + messages: directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ), } satisfies LocalConversationResult; }); })() @@ -3670,6 +4123,35 @@ export function App({ agentId: null, }); const resolvedProjectConversation = await projectConversation; + // 工具调用卡片走独立历史文件(`tool-calls.jsonl`)。必须在读完项目对话之后、 + // 任何提前 return 之前回读:direct-codex 下后面那条 design-agent 分支会直接返回, + // 放在它后面等于永远不执行。文件缺失 / 读取失败都只是没有卡片,不能因此把整个 + // 项目打开流程判失败。卡片按项目维度整表替换,同一 id 只渲染一次。 + if (directCodexProductRuntime) { + const persistedToolCalls = await invoke( + 'read_direct_tool_calls', + { projectPath: nextProjectPath }, + ).catch(() => []); + // 回合流(顺序真相)走独立历史文件(`turn-stream.jsonl`)。与工具调用同一处:必须在 + // 读完项目对话之后、任何提前 return 之前回读。缺命令(老客户端)/ 缺文件 / 读取失败 + // 都只是"这个回合没有流",界面回退到原来的渲染,不能因此把整个打开流程判失败。 + const persistedTurnStream = await invoke( + 'read_direct_turn_stream', + { projectPath: nextProjectPath }, + ).catch(() => []); + if ( + projectSupervisorHistoryLoadVersionRef.current !== loadVersion || + localProjectPathRef.current !== nextProjectPath + ) + return; + ensureDirectTimelineProject(nextProjectPath); + // 回读可能与实时事件交错:按同一身份合并,不能用旧磁盘快照覆盖实时状态。 + applyDirectToolCalls(persistedToolCalls); + applyTurnStreamItems(persistedTurnStream); + // 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示 + // 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。 + await restoreRunningDirectCodexTurn(nextProjectPath); + } let supervisorConversation: LocalConversationResult | null = null; let runtime: AgentRuntimeState | null = null; let runtimeResponseStream: AgentRuntimeResponseStream | null = null; @@ -3734,7 +4216,14 @@ export function App({ ?.messageId ?? null; } setMessages((current) => { - const nextConversationMessages = conversationMessages; + // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 + const nextConversationMessages = + mergeLoadedConversationWithPendingRuntimeMessages( + conversationMessages, + current, + ); + // 空对话(没有默认问候之后的新常态)同样应当接受回读结果。 + const isEmptyConversation = current.length === 0; const hasOnlyDefaultGreeting = current.length === 1 && current[0]?.role === 'assistant' && @@ -3747,6 +4236,7 @@ export function App({ current[1]?.text === `已设置本地项目:${nextProjectPath}`; if ( mode !== 'replace' && + !isEmptyConversation && !hasOnlyDefaultGreeting && !hasOnlyOpenStatus ) { @@ -6640,17 +7130,21 @@ export function App({ const appendDirectAssistantMessage = ( current: ChatMessage[], text: string, + failed = false, ): ChatMessage[] => { + const messageId = failed + ? `direct-codex:${clientTurnId}:failure` + : directAssistantMessageId; const nextMessage: ChatMessage = { role: 'assistant', text, runtimeOwned: true, - messageId: directAssistantMessageId, + messageId, updatedAt: Date.now(), }; const withUser = appendDirectUserMessageIfMissing(current); const existingIndex = withUser.findIndex( - (message) => message.messageId === directAssistantMessageId, + (message) => message.messageId === messageId, ); if (existingIndex < 0) { return [...withUser, nextMessage]; @@ -6670,6 +7164,7 @@ export function App({ setDirectCodexProcessKey(`${directProjectPath}\u0000${clientTurnId}`); setDirectCodexProgress('正在等待陶泥儿开始'); setDirectCodexTransientReply(''); + setDirectCodexTransientReasoning(''); setDirectCodexProgressUpdatedAt(Date.now()); directCodexTransientReplyRef.current = ''; setDirectCodexTransientReplyUpdatedAt(null); @@ -6719,24 +7214,42 @@ export function App({ setMessages((current) => appendDirectAssistantMessage(current, reply), ); - setDirectCodexStatus('finalizing'); - setDirectCodexProgress('正在同步项目文件'); - setDirectCodexProgressUpdatedAt(Date.now()); await refreshDirectProjectManifest(directProjectPath); } } catch (error) { - if (isDirectCodexTurnAlreadyRunningError(error)) { + if ( + isDirectCodexTurnAlreadyRunningError(error) || + isDirectCodexAnotherTurnRunningError(error) + ) { if (localProjectPathRef.current === directProjectPath) { clearDirectCodexTransientReply(directProjectPath, clientTurnId); setProjectSupervisorRuntimeError( - '陶泥儿仍在处理这条消息,请稍候刷新对话。', + '陶泥儿仍在处理上一条消息,可在输入盒点「终止」结束它,或等它结束后再发送。', ); + // 兜底:出现这条拒绝说明本项目确实有回合在跑,而本组件此前没接管它 + // (重进会话的漏网情况)。放到当前任务之后再接管,避开本回合 finally + // 里 setChatAgentBusy(false) 的复位竞态。 + window.setTimeout(() => { + void restoreRunningDirectCodexTurn(directProjectPath); + }, 0); } return; } if (localProjectPathRef.current !== directProjectPath) { return; } + if (isDirectCodexTurnInterruptedError(error)) { + // 用户主动终止:不是失败,不写运行错误与诊断,只把回合标记成已终止。 + clearDirectCodexTransientReply(directProjectPath, clientTurnId); + setDirectCodexStatus('failed'); + setDirectCodexProgress(''); + setProjectSupervisorRuntimeError(''); + setChatComposerNotice('已终止本次回合'); + setMessages((current) => + appendDirectAssistantMessage(current, '已终止本次回合。', true), + ); + return; + } void captureAgentRuntimeError(error, PROJECT_SUPERVISOR_AGENT_ID); const message = error instanceof Error ? error.message : String(error); @@ -6769,7 +7282,7 @@ export function App({ setDirectCodexProgress('正在记录失败原因'); setProjectSupervisorRuntimeError(visibleMessage); setMessages((current) => - appendDirectAssistantMessage(current, visibleMessage), + appendDirectAssistantMessage(current, visibleMessage, true), ); } } finally { @@ -6778,15 +7291,18 @@ export function App({ await refreshManifest(directProjectPath); } } finally { - setChatAgentBusy(false); - setDirectCodexProgress(''); const activeTurn = activeDirectCodexTurnRef.current; if ( - !activeTurn || - (activeTurn.projectPath === directProjectPath && - activeTurn.turnId === clientTurnId) + localProjectPathRef.current === directProjectPath && + (!activeTurn || + (activeTurn.projectPath === directProjectPath && + activeTurn.turnId === clientTurnId)) ) { + setChatAgentBusy(false); + setDirectCodexTurnCancelling(false); resetDirectCodexTurn(); + // 只有当前回合的收尾才能释放发送队列,不能覆盖后来启动的回合。 + dispatchNextQueuedChatTurn(); } } } @@ -6975,7 +7491,9 @@ export function App({ return; } if (localProject.projectPath !== latch.projectPath) { - claimInitialSupervisorMessageForPage(latch.projectPath); + // 不能在这里先"占用"这条初始消息:项目路径可能因为分隔符/大小写/时序先落到别的 + // 路径上,一旦占用,真正匹配的项目就再也不会收到这条消息,用户的输入被静默丢掉。 + // 这里只等待,占用留给下面真正要发送的那一步。 return; } if ( @@ -11972,18 +12490,19 @@ export function App({ if (localProjectPathRef.current !== projectPath) { return; } - const older = directThreadHistoryItemsToMessages(slice.items).map( - (message) => ({ - role: - message.role === 'user' - ? ('user' as const) - : ('assistant' as const), - text: message.content, - runtimeOwned: true, - messageId: message.messageId, - updatedAt: message.updatedAt, - }), - ); + const older = directThreadHistoryItemsToMessages( + slice.items, + slice.itemTimestamps, + ).map((message) => ({ + role: + message.role === 'user' + ? ('user' as const) + : ('assistant' as const), + text: message.content, + runtimeOwned: true, + messageId: message.messageId, + updatedAt: message.updatedAt, + })); setMessages((current) => [...older, ...current]); setConversationVisibleCount((current) => current + older.length); setDirectHistoryHasMore(slice.hasMore); @@ -12059,12 +12578,212 @@ export function App({ } }, [agentStatusCards, selectedAgent]); + /** + * 发起一轮 direct-codex 对话回合:提交与队列出队共用同一条路径,避免两条入口的 + * 消息落盘/回合 id/附件参数走样。 + */ + function startDirectCodexConversationTurn(input: { + prompt: string; + attachments?: DirectCodexTurnAttachment[]; + references?: ChatReference[]; + content?: DirectCodexUserContentPart[]; + }) { + const clientTurnId = createDirectCodexConversationTurnId(); + supervisorChatShouldFollowLatestRef.current = true; + setMessages((current) => [ + ...current, + { + role: 'user', + text: input.prompt, + runtimeOwned: true, + messageId: directCodexConversationMessageId(clientTurnId, 'user'), + updatedAt: Date.now(), + }, + ]); + void executeChatAgentReply({ + prompt: input.prompt, + clientTurnId, + attachments: input.attachments?.length ? input.attachments : undefined, + references: input.references, + userItem: chatComposerDraftToDirectCodexUserItem( + { + text: input.prompt, + references: input.references ?? [], + content: input.content ?? [], + }, + directCodexConversationMessageId(clientTurnId, 'user'), + ), + }); + } + + /** + * 输入盒上传本地文件:复用首页建项目那条 `upload_local_asset` 链路把文件写进项目, + * 再以**项目相对路径**生成回合附件(绝对路径会被 Rust 侧附件规则判为失败)。 + */ + async function handleChatComposerUploadFiles(files: readonly File[]) { + const invoke = resolveTauriInvoke(); + const nextProjectPath = resolveChatProjectPath(localProject); + if (!invoke || !nextProjectPath) { + setChatAttachmentNotice('需要先打开本地项目,才能上传文件'); + return; + } + const remaining = MAX_CHAT_COMPOSER_ATTACHMENTS - chatAttachments.length; + const accepted = files.slice(0, Math.max(remaining, 0)); + if (accepted.length === 0) { + setChatAttachmentNotice( + `最多同时携带 ${MAX_CHAT_COMPOSER_ATTACHMENTS} 个附件,请先移除已有附件`, + ); + return; + } + setChatAttachmentNotice('正在上传文件'); + try { + const imported = await uploadLocalFilesAsAttachments( + invoke, + nextProjectPath, + accepted, + ); + const attachments = toDirectCodexTurnAttachments(imported); + if (localProjectPathRef.current !== nextProjectPath) { + return; + } + setChatAttachments((current) => + [...current, ...attachments].slice(0, MAX_CHAT_COMPOSER_ATTACHMENTS), + ); + const failed = attachments.filter( + (attachment) => attachment.status === 'failed', + ); + setChatAttachmentNotice( + failed.length > 0 + ? `${failed.length} 个文件未能上传:${failed[0]?.name ?? ''}` + : `已上传 ${attachments.length} 个文件,将在下次发送时作为本轮附件`, + ); + void refreshManifest(nextProjectPath); + } catch (error) { + if (localProjectPathRef.current === nextProjectPath) { + setChatAttachmentNotice( + error instanceof Error ? error.message : String(error), + ); + } + } + } + + function removeChatComposerAttachment(index: number) { + setChatAttachments((current) => + current.filter((_, currentIndex) => currentIndex !== index), + ); + } + + /** 回合运行中再次发送:进本地 FIFO 队列;队列满时拒绝并保留草稿,不静默丢消息。 */ + function enqueueChatTurnForRunningTurn(input: { + prompt: string; + attachments: DirectCodexTurnAttachment[]; + references: ChatReference[]; + content: DirectCodexUserContentPart[]; + }): boolean { + if (isChatTurnQueueFull(chatTurnQueueRef.current)) { + setChatComposerNotice(chatQueueFullNotice()); + return false; + } + queuedChatTurnSequenceRef.current += 1; + const turn = createQueuedChatTurn({ + id: `queued-chat-turn-${Date.now()}-${queuedChatTurnSequenceRef.current}`, + prompt: input.prompt, + attachments: input.attachments, + references: input.references, + content: input.content, + createdAt: Date.now(), + }); + const nextQueue = enqueueChatTurn(chatTurnQueueRef.current, turn); + chatTurnQueueRef.current = nextQueue; + setChatTurnQueue(nextQueue); + setChatComposerNotice('已加入发送队列,当前回合结束后自动发送'); + return true; + } + + function cancelQueuedChatTurn(id: string) { + const nextQueue = removeQueuedChatTurn(chatTurnQueueRef.current, id); + chatTurnQueueRef.current = nextQueue; + setChatTurnQueue(nextQueue); + if (nextQueue.length === 0) { + setChatComposerNotice(''); + } + } + + /** 队首出队并立即发出:只在当前回合确实结束(`finally`)后调用。 */ + function dispatchNextQueuedChatTurn() { + const { next, rest } = dequeueChatTurn(chatTurnQueueRef.current); + if (!next) { + return; + } + chatTurnQueueRef.current = rest; + setChatTurnQueue(rest); + if (rest.length === 0) { + setChatComposerNotice(''); + } + startDirectCodexConversationTurn({ + prompt: next.prompt, + attachments: next.attachments, + references: next.references, + content: next.content, + }); + } + + /** 终止当前 direct-codex 回合:只取消这一轮,UI 由回合的 finally 复位。 */ + async function handleCancelDirectCodexTurn() { + if (directCodexTurnCancelling) { + return; + } + const invoke = resolveTauriInvoke(); + const activeTurn = activeDirectCodexTurnRef.current; + const directProjectPath = + activeTurn?.projectPath ?? resolveChatProjectPath(localProject); + if (!invoke || !directProjectPath || !activeTurn) { + setProjectSupervisorRuntimeError('当前没有正在运行的回合,无法终止。'); + return; + } + setDirectCodexTurnCancelling(true); + setChatComposerNotice('正在终止当前回合'); + try { + const result = await invoke( + 'cancel_direct_codex_turn', + { + projectPath: directProjectPath, + clientTurnId: activeTurn.turnId, + }, + ); + const message = result?.message?.trim(); + if (result?.outcome === 'released') { + // 这一轮已经没有人替它收尾(执行进程已退出 / 从没进执行器),Rust 侧已强制释放 + // 守卫。没有会 return 的回合 promise 来复位界面,这里必须自己复位,否则过程卡 + // 与"任务执行中"会一直挂着,用户仍然发不出消息。 + resetDirectCodexTurn(); + setChatAgentBusy(false); + setProjectSupervisorRuntimeError(''); + setChatComposerNotice( + message ?? '已结束这一轮占用,可以直接重新发送消息', + ); + return; + } + setDirectCodexProgress('正在终止当前回合'); + if (message) { + setChatComposerNotice(message); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setProjectSupervisorRuntimeError(`终止失败:${message}`); + setChatComposerNotice(''); + } finally { + setDirectCodexTurnCancelling(false); + } + } + function handleProjectSupervisorOnlySubmit( event: FormEvent, ) { event.preventDefault(); const prompt = chatInput.trim(); const references = chatReferences; + const pendingAttachments = chatAttachments; if ( !directCodexProductRuntime && supervisorChatOnly && @@ -12080,7 +12799,32 @@ export function App({ setProjectSupervisorRuntimeError('请先回答项目总控 Agent 当前的澄清问题'); return; } - if ((!prompt && references.length === 0) || chatAgentBusy) { + if ( + !prompt && + references.length === 0 && + chatContent.length === 0 && + pendingAttachments.length === 0 + ) { + return; + } + if (chatAgentBusy) { + // 回合运行中再次发送:direct-codex 面板把消息放进本地 FIFO 队列,当前回合结束后 + // 依次发出;其它面板保持原有"运行中不接受新输入"的行为。 + if (directCodexProductRuntime) { + const enqueued = enqueueChatTurnForRunningTurn({ + prompt, + attachments: pendingAttachments, + references, + content: chatContent, + }); + if (enqueued) { + setChatInput(''); + setChatContent([]); + setChatReferences([]); + setChatAttachments([]); + setChatAttachmentNotice(''); + } + } return; } if (directCodexProductRuntime && prompt === '/history') { @@ -12117,15 +12861,22 @@ export function App({ if (supervisorChatOnly || directCodexProductRuntime) { supervisorChatShouldFollowLatestRef.current = true; } - const directConversationTurnId = directCodexProductRuntime - ? createDirectCodexConversationTurnId() - : undefined; - const directUserItem = directConversationTurnId - ? chatComposerDraftToDirectCodexUserItem( - { text: prompt, references, content: chatContent }, - directCodexConversationMessageId(directConversationTurnId, 'user'), - ) - : undefined; + if (directCodexProductRuntime) { + // 待发附件随本轮提交一次性交给回合;提交后清空,避免同一批附件重复挂到下一轮。 + setChatInput(''); + setChatReferences([]); + setChatAttachments([]); + setChatContent([]); + setChatAttachmentNotice(''); + setChatComposerNotice(''); + startDirectCodexConversationTurn({ + prompt, + attachments: pendingAttachments, + references, + content: chatContent, + }); + return; + } setChatInput(''); setChatReferences([]); setChatContent([]); @@ -12135,23 +12886,10 @@ export function App({ role: 'user', text: prompt, runtimeOwned: true, - ...(directConversationTurnId - ? { - messageId: directCodexConversationMessageId( - directConversationTurnId, - 'user', - ), - } - : {}), updatedAt: Date.now(), }, ]); - void executeChatAgentReply({ - prompt, - clientTurnId: directConversationTurnId, - references, - userItem: directUserItem, - }); + void executeChatAgentReply({ prompt, references }); } const visibleProfessionalAgentCards = agentStatusCards.filter( @@ -12214,9 +12952,20 @@ export function App({ if (projectSupervisorOnly) { return ( void handleCancelDirectCodexTurn()} + onRemoveAttachment={removeChatComposerAttachment} + onUploadFiles={(files) => void handleChatComposerUploadFiles(files)} + queuedTurns={chatTurnQueue} + turnCancelling={directCodexTurnCancelling} composerRef={chatComposerRef} chatProjectAssets={chatProjectAssets} directCodex={directCodexProductRuntime} @@ -12244,6 +12993,23 @@ export function App({ } pendingCommand={directCodexProductRuntime ? pendingCommand : null} projectPath={localProject?.projectPath ?? projectPath} + toolCalls={ + directTimelineProjectPathRef.current === localProject?.projectPath + ? directToolCalls + : [] + } + turnStreamItems={ + directTimelineProjectPathRef.current === localProject?.projectPath + ? directTurnStream + : [] + } + conversationMessages={messages} + hasUnloadedHistory={directHistoryHasMore} + activeTurnId={ + directCodexProductRuntime + ? (activeDirectCodexTurnRef.current?.turnId ?? null) + : null + } transientReply={ planningV2Active ? planningV2TransientReply diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 4093a0b1f..1a8e48d72 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -1096,6 +1096,57 @@ export type GameCreatorDirectTurnActivity = | 'response-finalization' | 'none'; +export type GameCreatorDirectToolCallKind = + | 'command' + | 'file_change' + | 'mcp_tool' + | 'web_search' + | 'context_compaction' + | 'other'; + +export type GameCreatorDirectToolCallStatus = + | 'running' + | 'completed' + | 'failed'; + +export interface GameCreatorDirectToolCallChange { + path: string; + kind: 'add' | 'update' | 'delete' | string; +} + +export interface GameCreatorDirectToolCallDetail { + command?: string; + output?: string; + changes?: GameCreatorDirectToolCallChange[]; +} + +/** + * 一条工具调用(Codex item 的结构化投影)。 + * + * 契约见 `docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md`: + * 字段形状与 Rust 侧 `DirectToolCall`、独立历史文件 + * `.agent/conversations/tool-calls.jsonl` 的 payload 一致(这里少 `turnId` 的变体用于 + * 事件增量,见下面 `GameCreatorDirectTurnToolCall`)。 + */ +export interface GameCreatorDirectToolCall { + schemaVersion: string; + id: string; + turnId: string; + kind: GameCreatorDirectToolCallKind; + title: string; + summary: string; + status: GameCreatorDirectToolCallStatus; + detail: GameCreatorDirectToolCallDetail; + startedAt: number; + updatedAt: number; +} + +/** 事件里下发的增量条目:与持久化同形,去掉 `turnId`(回合 id 在事件顶层)。 */ +export type GameCreatorDirectTurnToolCall = Omit< + GameCreatorDirectToolCall, + 'turnId' +>; + export interface GameCreatorDirectTurnUpdateEvent { projectPath: string; turnId: string; @@ -1103,9 +1154,69 @@ export interface GameCreatorDirectTurnUpdateEvent { status: GameCreatorDirectTurnUpdateStatus; activity?: GameCreatorDirectTurnActivity | null; accumulatedText?: string | null; + /** + * 本回合内**发生变化**的结构化工具调用(只有变化时才带,不是每个 heartbeat 都带全量)。 + * 可选:老版本事件没有这个字段,前端拿到 `undefined` 时必须与改造前行为一致。 + */ + toolCalls?: GameCreatorDirectTurnToolCall[] | null; + /** + * 本回合当前累计的思考过程(流式,整段替换);拿不到时字段缺席。 + */ + reasoningText?: string | null; + /** + * 「文本段 + 工具」的**顺序真相**里本次发生变化的那几条。 + * + * 顺序由 `seq`(条目首次出现时钉死)决定,与落盘 `turn-stream.jsonl` 完全同一份数据, + * 前端不再自己猜切点。可选:老版本事件没有这个字段。 + */ + streamItems?: TurnStreamItem[] | null; updatedAt: number; } +/** 回合流里的一个 `text` 段;`text` 是该段当前累计全文(会随 delta 增长)。 */ +export interface TurnStreamTextItem extends TurnStreamItemBase { + kind: 'text'; + text: string; +} + +/** 回合流里的一个 `tool` 位置标记;工具正文在 `tool-calls.jsonl`(按 `callId` 关联)。 */ +export interface TurnStreamToolItem extends TurnStreamItemBase { + kind: 'tool'; + callId: string; +} + +interface TurnStreamItemBase { + schemaVersion: string; + /** 幂等身份:文本段 `text::`、工具 `tool::`。 */ + id: string; + turnId: string; + /** 首次出现的写入序号:**顺序真相**,按它升序渲染。 */ + seq: number; + /** 条目首次出现的时刻(Unix 毫秒),同 `seq` 时用它排序。 */ + at: number; + updatedAt: number; +} + +/** + * 回合流条目(`read_direct_turn_stream` 的返回元素)。 + * + * 与 Rust `DirectTurnStreamItem` 同形:`text` 段 ↔ `tool` 位置标记。 + */ +export type TurnStreamItem = TurnStreamTextItem | TurnStreamToolItem; + +/** `cancel_direct_codex_turn` 的返回值。 */ +export interface DirectTurnCancelView { + /** + * `interrupted` = 已向正在跑的回合发出中断,界面等这一轮自己的收尾复位; + * `released` = app-server 侧已无句柄,本轮守卫被兜底释放,界面必须自己复位。 + */ + outcome: string; + /** 给用户看的可读结果。 */ + message: string; + /** 被终止 / 被释放的 clientTurnId。 */ + clientTurnId: string; +} + export interface AgentRunControlResult { runId: string; status: string; diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/codeHighlight.css b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/codeHighlight.css new file mode 100644 index 000000000..d212361a5 --- /dev/null +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/codeHighlight.css @@ -0,0 +1,37 @@ +/* 只作用于共享 Markdown 渲染器,不改变普通正文及用户消息的字体颜色。 */ +.agc-markdown-code .hljs-comment, +.agc-markdown-code .hljs-quote { + color: #6a737d; +} + +.agc-markdown-code .hljs-keyword, +.agc-markdown-code .hljs-name, +.agc-markdown-code .hljs-selector-tag, +.agc-markdown-code .hljs-literal, +.agc-markdown-code .hljs-deletion { + color: #a6264c; +} + +.agc-markdown-code .hljs-string, +.agc-markdown-code .hljs-regexp, +.agc-markdown-code .hljs-addition { + color: #276438; +} + +.agc-markdown-code .hljs-number, +.agc-markdown-code .hljs-attr, +.agc-markdown-code .hljs-variable, +.agc-markdown-code .hljs-built_in { + color: #075a9c; +} + +.agc-markdown-code .hljs-title, +.agc-markdown-code .hljs-type, +.agc-markdown-code .hljs-section { + color: #6f42a0; +} + +.agc-markdown-code .hljs-meta, +.agc-markdown-code .hljs-symbol { + color: #8a4c0a; +} diff --git a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx index 3d79df338..d8acce153 100644 --- a/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx +++ b/apps/ai-game-creator-shell/src/components/ChatMarkdownMessage/index.tsx @@ -1,3 +1,5 @@ +import './codeHighlight.css'; + import type { ErrorInfo, ReactNode } from 'react'; import { Children, @@ -7,14 +9,33 @@ import { useContext, } from 'react'; import ReactMarkdown, { type Components } from 'react-markdown'; +import rehypeHighlight from 'rehype-highlight'; import remarkGfm from 'remark-gfm'; export type ChatMarkdownMessageProps = { text: string; role: 'assistant' | 'user'; streaming?: boolean; + /** 文件预览不压缩正文空行,保留源码与文档的原始排版。 */ + preserveBlankLines?: boolean; }; +const MAX_HIGHLIGHT_CHARACTERS = 100_000; +const CodeBlockContext = createContext(false); + +/** 只压缩普通 Markdown 正文里多余的空行;代码块中的换行必须原样保留。 */ +function normalizeMarkdownBlankLines(text: string) { + return text + .replace(/\r\n?/g, '\n') + .split(/(```[\s\S]*?```)/g) + .map((part, index) => + index % 2 === 1 + ? part + : part.replace(/[ \t]*\n(?:[ \t]*\n){2,}/g, '\n\n'), + ) + .join(''); +} + type MarkdownErrorBoundaryProps = { fallbackText: string; children: ReactNode; @@ -66,7 +87,6 @@ export class MarkdownErrorBoundary extends Component< } const ListDepthContext = createContext(0); -const ListKindContext = createContext<'unordered' | 'ordered' | null>(null); type ListItemParagraphPosition = 'first' | 'continuation'; const ListItemContext = createContext(null); @@ -75,15 +95,11 @@ function MarkdownUnorderedList({ children }: { children?: ReactNode }) { const depth = useContext(ListDepthContext); return ( - -
    0 ? 'pl-4' : 'pl-0' - }`} - > - {children} -
-
+ {/* 用真正的列表标记(`list-disc`)而不是手写 `'- '` 文本:手写前缀既没有悬挂缩进 + (换行后的第二行会顶回最左边),也不算列表语义(读屏读成普通文本)。 */} +
    + {children} +
); } @@ -98,14 +114,12 @@ function MarkdownOrderedList({ const depth = useContext(ListDepthContext); return ( - -
    - {children} -
-
+
    + {children} +
); } @@ -145,7 +159,6 @@ function StreamingMarkdownParagraph({ children }: { children?: ReactNode }) { } function MarkdownListItem({ children }: { children?: ReactNode }) { - const listKind = useContext(ListKindContext); let paragraphIndex = 0; const childrenWithParagraphContext = Children.map( children, @@ -168,7 +181,6 @@ function MarkdownListItem({ children }: { children?: ReactNode }) { ); return (
  • - {listKind === 'unordered' ? '- ' : null} {childrenWithParagraphContext}
  • ); @@ -179,22 +191,50 @@ const markdownComponents: Components = { a: ({ children }) => children, img: ({ alt }) => (alt?.trim() ? `图片:${alt}` : '图片已省略'), h1: ({ children }) => ( -

    {children}

    +

    + {children} +

    ), h2: ({ children }) => ( -

    {children}

    +

    + {children} +

    ), h3: ({ children }) => ( -

    {children}

    +

    + {children} +

    ), h4: ({ children }) => ( -

    {children}

    +

    + {children} +

    ), h5: ({ children }) => ( -
    {children}
    +
    + {children} +
    ), h6: ({ children }) => ( -
    +
    {children}
    ), @@ -209,15 +249,18 @@ const markdownComponents: Components = { ), pre: ({ children }) => (
    -      {children}
    +      
    +        {children}
    +      
         
    ), - code: ({ className, children, node: _node, ...props }) => { - const isBlock = - Boolean(className?.includes('language-')) || - String(children).includes('\n'); + code: function MarkdownCode({ className, children, node: _node, ...props }) { + const isBlock = useContext(CodeBlockContext); return isBlock ? ( - + {children} ) : ( @@ -231,7 +274,10 @@ const markdownComponents: Components = { }, table: ({ children }) => (
    - +
    {children}
    @@ -260,6 +306,7 @@ export function ChatMarkdownMessage({ text, role, streaming = false, + preserveBlankLines = false, }: ChatMarkdownMessageProps) { if (role === 'user') { return {text}; @@ -270,11 +317,14 @@ export function ChatMarkdownMessage({ - {text} + {preserveBlankLines ? text : normalizeMarkdownBlankLines(text)} ); diff --git a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts index 4c331835c..fcaef751c 100644 --- a/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts +++ b/apps/ai-game-creator-shell/src/features/agent-runtime/model.ts @@ -1327,12 +1327,9 @@ export function isMissingAgentGoalCommandError(error: unknown) { } export function createDefaultChatMessages(): ChatMessage[] { - return [ - { - role: 'assistant', - text: '想做什么游戏?', - }, - ]; + // 默认问候「想做什么游戏?」已移除:它在对话记录里没有信息量,而且会出现在用户消息之后。 + // 空对话由空状态提示(panels.tsx 的引导文案)承担,不再往消息列表里塞占位消息。 + return []; } export function isRuntimeConfigMissingError(message: string) { diff --git a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx index 33096e243..6545cd169 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/AccountWallet.tsx @@ -73,7 +73,13 @@ export function AccountWalletDialogs({ >
    兑换码 - +
    controller.setRedeemCodeInput(event.target.value)} + onChange={(event) => + controller.setRedeemCodeInput(event.target.value) + } placeholder="输入兑换码" aria-label="兑换码" autoFocus /> - {controller.redeemCodeError ?

    {controller.redeemCodeError}

    : null} - {controller.redeemCodeSuccess ?

    {controller.redeemCodeSuccess}

    : null} -
    diff --git a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx index c51a6fa49..1df87f2bb 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx +++ b/apps/ai-game-creator-shell/src/features/app-shell/ActiveProjectRunsPanel.tsx @@ -181,7 +181,10 @@ export function ActiveProjectRunsPanel({ disabled={!onOpenProject} onClick={() => openProject(turn.projectPath)} > - + {name} diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts index 09ded67d7..4005134e0 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useAccountWallet.ts @@ -50,7 +50,9 @@ export function useAccountWallet(currentUserId: string) { const [redeemCodeInput, setRedeemCodeInput] = useState(''); const [redeemCodeLoading, setRedeemCodeLoading] = useState(false); const [redeemCodeError, setRedeemCodeError] = useState(null); - const [redeemCodeSuccess, setRedeemCodeSuccess] = useState(null); + const [redeemCodeSuccess, setRedeemCodeSuccess] = useState( + null, + ); const rechargeLifecycleRef = useRef(0); const walletLedgerLifecycleRef = useRef(0); const redeemLifecycleRef = useRef(0); @@ -251,16 +253,26 @@ export function useAccountWallet(currentUserId: string) { setRedeemCodeSuccess(null); try { const response = await redeemClientProfileRewardCode(code); - if (redeemLifecycleRef.current !== lifecycle || currentUserIdRef.current !== owner) return; + if ( + redeemLifecycleRef.current !== lifecycle || + currentUserIdRef.current !== owner + ) + return; setRedeemCodeSuccess(`兑换成功,已到账 ${response.amountGranted} 泥点`); setRedeemCodeInput(''); void onWalletBalanceMayHaveChanged(); } catch (error) { - if (redeemLifecycleRef.current === lifecycle && currentUserIdRef.current === owner) { + if ( + redeemLifecycleRef.current === lifecycle && + currentUserIdRef.current === owner + ) { setRedeemCodeError(error instanceof Error ? error.message : '兑换失败'); } } finally { - if (redeemLifecycleRef.current === lifecycle && currentUserIdRef.current === owner) { + if ( + redeemLifecycleRef.current === lifecycle && + currentUserIdRef.current === owner + ) { setRedeemCodeLoading(false); } } diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts index 61fb8681a..56201d7d3 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useHomeProjectCreation.ts @@ -36,6 +36,7 @@ import type { HomeCreationType, HomeDraft, } from '../../view/home'; +import { richTextToPrompt } from '../../view/home/components/RichInputArea/richTextToPrompt'; import { useLauncherHomeDraftStore } from '../../view/home/useHomeDraftStore'; import type { LauncherView } from '../../view/layout'; import type { @@ -49,6 +50,15 @@ import { } from '../project-summary/projectSummary'; import { resolveSessionPreviewOnProjectOpen } from './sessionPreview'; +/** 首页输入框当前的纯文本(Lexical 编辑器状态 -> 文本);没有输入就返回空串。 */ +function homeDraftPromptText() { + try { + return richTextToPrompt(useLauncherHomeDraftStore.getState().draft).trim(); + } catch { + return ''; + } +} + type UseHomeProjectCreationOptions = { setStatus: Dispatch>; setLauncherView: Dispatch>; @@ -92,6 +102,51 @@ async function suggestAutomaticProjectName( } } +/** + * 把浏览器 File 上传进项目并登记为资产,返回带项目相对路径的附件记录。 + * + * 首页建项目与右侧对话输入盒共用同一条链路:`upload_local_asset` 写进项目之后, + * 附件才能以「项目路径」形式进入回合附件(绝对路径会被 Rust 侧的附件脱敏规则拒绝)。 + */ +export async function uploadLocalFilesAsAttachments( + invoke: TauriInvoke, + nextProjectPath: string, + files: readonly File[], +): Promise { + const imported: LauncherImportedAttachment[] = []; + for (const file of files) { + const mediaType = file.type || 'application/octet-stream'; + try { + const bytes = Array.from(new Uint8Array(await file.arrayBuffer())); + const result = await invoke( + 'upload_local_asset', + { + projectPath: nextProjectPath, + fileName: file.name, + mediaType, + bytes, + }, + ); + imported.push({ + fileName: file.name, + mediaType, + localPath: result.localPath, + status: 'imported', + size: file.size, + }); + } catch (error) { + imported.push({ + fileName: file.name, + mediaType, + status: 'failed', + error: error instanceof Error ? error.message : String(error), + size: file.size, + }); + } + } + return imported; +} + /** * 自动建项的兜底期限。 * @@ -304,40 +359,11 @@ export function useHomeProjectCreation({ nextProjectPath: string, attachments: HomeAttachmentDraft[], ) { - const imported: LauncherImportedAttachment[] = []; - for (const attachment of attachments) { - const mediaType = attachment.file.type || 'application/octet-stream'; - try { - const bytes = Array.from( - new Uint8Array(await attachment.file.arrayBuffer()), - ); - const result = await invoke( - 'upload_local_asset', - { - projectPath: nextProjectPath, - fileName: attachment.file.name, - mediaType, - bytes, - }, - ); - imported.push({ - fileName: attachment.file.name, - mediaType, - localPath: result.localPath, - status: 'imported', - size: attachment.file.size, - }); - } catch (error) { - imported.push({ - fileName: attachment.file.name, - mediaType, - status: 'failed', - error: error instanceof Error ? error.message : String(error), - size: attachment.file.size, - }); - } - } - return imported; + return uploadLocalFilesAsAttachments( + invoke, + nextProjectPath, + attachments.map((attachment) => attachment.file), + ); } async function enterCreatedHomeProject( @@ -455,6 +481,9 @@ export function useHomeProjectCreation({ async function createProjectFromProjectPage( nextProjectPath: string, skipNonEmptyCheck = false, + // 首页输入框里已经写好的要求:打开已有项目时不能再丢掉(此前写死空串, + // 用户写的内容既不发首轮也不进对话历史)。 + initialPrompt = '', ) { if (projectActionRef.current) { return; @@ -507,7 +536,7 @@ export function useHomeProjectCreation({ ), creationType: null, startMode: null, - initialPrompt: '', + initialPrompt, attachments: [], recentRunStatus: null, recentRunStopReason: null, @@ -613,7 +642,7 @@ export function useHomeProjectCreation({ ), creationType: null, startMode: runtimeMode?.activeRuntime === 'design' ? 'planning' : null, - initialPrompt: '', + initialPrompt: homeDraftPromptText(), attachments: [], recentRunStatus: directoryStatus.recentRunStatus, recentRunStopReason: directoryStatus.recentRunStopReason, @@ -906,7 +935,11 @@ export function useHomeProjectCreation({ setProjectPath(selectedPath); projectActionRef.current = null; setProjectAction(null); - await createProjectFromProjectPage(selectedPath); + await createProjectFromProjectPage( + selectedPath, + false, + homeDraftPromptText(), + ); } catch (error) { setStatus(error instanceof Error ? error.message : String(error)); } finally { diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx new file mode 100644 index 000000000..0aa574dbc --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ComposerControls.tsx @@ -0,0 +1,507 @@ +/** + * 输入盒控件(Codex 观感):左 `+`(上传本地文件 / 引用项目素材)、右侧推理强度 + + * 模型 + 麦克风 + 发送/终止,以及输入盒上方的待发附件与消息队列 chip。 + * + * 这些组件只承载表现与交互;回合附件由 `App.tsx` 上传并落进 `DirectCodexTurnAttachment`, + * 队列由 `chatComposerQueue.ts` 的纯函数维护。 + */ +import { + Check, + ChevronDown, + FileUp, + Images, + Mic, + MicOff, + Plus, + Square, + X, +} from 'lucide-react'; +import type { RefObject } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { resolveTauriInvoke } from '../../app/tauri'; +import type { + GameCreatorAppConfigView, + GameCreatorLlmReasoningEffort, +} from '../../app/types'; +import type { DirectCodexTurnAttachment } from '../app-shell/directCodexTurnAttachments'; +import type { QueuedChatTurn } from './chatComposerQueue'; +import { queuedChatTurnLabel } from './chatComposerQueue'; +import { + resolveSpeechRecognitionCtor, + speechEventTranscript, + type SpeechRecognitionCtor, + speechRecognitionErrorMessage, + speechRecognitionLang, + type SpeechRecognitionLike, + VOICE_INPUT_UNSUPPORTED_MESSAGE, +} from './chatComposerVoice'; +import { + composerReasoningEffortOptions, + DEFAULT_COMPOSER_REASONING_EFFORT, + normalizeComposerReasoningEffort, +} from './composerReasoningEffort'; + +type ComposerAttachmentMenuProps = { + disabled: boolean; + onPickFiles: (files: readonly File[]) => void; + onOpenReferencePicker: () => void; +}; + +/** 左侧 `+`:独立弹层给两条路径——上传本地文件、引用项目素材。 */ +export function ComposerAttachmentMenu({ + disabled, + onPickFiles, + onOpenReferencePicker, +}: ComposerAttachmentMenuProps) { + const [open, setOpen] = useState(false); + const anchorRef = useRef(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (!open) { + return; + } + function handleOutsidePointerDown(event: MouseEvent) { + const target = event.target as Node | null; + if (anchorRef.current && !anchorRef.current.contains(target)) { + setOpen(false); + } + } + function handleEscape(event: KeyboardEvent) { + if (event.key === 'Escape') { + setOpen(false); + } + } + document.addEventListener('mousedown', handleOutsidePointerDown); + document.addEventListener('keydown', handleEscape); + return () => { + document.removeEventListener('mousedown', handleOutsidePointerDown); + document.removeEventListener('keydown', handleEscape); + }; + }, [open]); + + return ( +
    + { + const files = Array.from(event.currentTarget.files ?? []); + event.currentTarget.value = ''; + if (files.length > 0) { + onPickFiles(files); + } + }} + /> + + {open ? ( +
    + + +
    + ) : null} +
    + ); +} + +/** 待发附件 chip:随下次提交一起进入回合,可单条移除。 */ +export function ComposerPendingAttachments({ + attachments, + onRemove, +}: { + attachments: readonly DirectCodexTurnAttachment[]; + onRemove: (index: number) => void; +}) { + if (attachments.length === 0) { + return null; + } + return ( +
      + {attachments.map((attachment, index) => ( +
    • + + {attachment.name} + + +
    • + ))} +
    + ); +} + +/** 队列 chip:回合运行中入队的消息,按 FIFO 顺序展示,可单条取消。 */ +export function ComposerTurnQueue({ + turns, + onCancel, +}: { + turns: readonly QueuedChatTurn[]; + onCancel: (id: string) => void; +}) { + if (turns.length === 0) { + return null; + } + return ( +
      + {turns.map((turn, index) => ( +
    1. + + {index + 1} + + + {queuedChatTurnLabel(turn)} + + +
    2. + ))} +
    + ); +} + +type ComposerVoiceButtonProps = { + disabled: boolean; + onTranscript: (text: string) => void; + onNotice: (message: string) => void; +}; + +/** + * 麦克风:只在运行时确实提供 SpeechRecognition 时可用;否则按钮禁用并直接说明原因 + * (aria-label/title 都是那句提示,不假装能用)。录音态用 `is-recording` 做视觉反馈。 + */ +export function ComposerVoiceButton({ + disabled, + onTranscript, + onNotice, +}: ComposerVoiceButtonProps) { + const ctorRef: RefObject = useRef( + resolveSpeechRecognitionCtor( + typeof window === 'undefined' ? null : (window as unknown as object), + ), + ); + const ctor = ctorRef.current; + const supported = Boolean(ctor); + const [recording, setRecording] = useState(false); + const recognitionRef = useRef(null); + const onTranscriptRef = useRef(onTranscript); + const onNoticeRef = useRef(onNotice); + useEffect(() => { + onTranscriptRef.current = onTranscript; + onNoticeRef.current = onNotice; + }, [onNotice, onTranscript]); + useEffect( + () => () => { + recognitionRef.current?.abort?.(); + recognitionRef.current = null; + }, + [], + ); + + const unsupportedHint = VOICE_INPUT_UNSUPPORTED_MESSAGE; + const activeLabel = recording ? '停止语音输入' : '语音输入'; + + function startRecognition() { + if (!ctor) { + onNoticeRef.current(unsupportedHint); + return; + } + try { + const recognition = new ctor(); + recognition.lang = speechRecognitionLang(navigator?.language); + recognition.continuous = true; + recognition.interimResults = false; + recognition.maxAlternatives = 1; + recognition.onresult = (event) => { + const transcript = speechEventTranscript(event); + if (transcript) { + onTranscriptRef.current(transcript); + } + }; + recognition.onerror = (event) => { + const message = speechRecognitionErrorMessage(event?.error); + setRecording(false); + if (message) { + onNoticeRef.current(message); + } + }; + recognition.onend = () => { + setRecording(false); + recognitionRef.current = null; + }; + recognitionRef.current = recognition; + recognition.start(); + setRecording(true); + } catch (error) { + recognitionRef.current = null; + setRecording(false); + onNoticeRef.current( + error instanceof Error && error.message + ? error.message + : '语音输入启动失败,请稍后重试', + ); + } + } + + return ( + + ); +} + +/** + * 推理强度:原生 select,紧挨模型选择器。读取/写回都走客户端配置通道 + * (`read_game_creator_app_config` / `select_game_creator_reasoning_effort`)。 + */ +export function ComposerReasoningEffortSelect({ + disabled, +}: { + disabled: boolean; +}) { + const [open, setOpen] = useState(false); + const [effort, setEffort] = useState( + DEFAULT_COMPOSER_REASONING_EFFORT, + ); + const [saving, setSaving] = useState(false); + const [notice, setNotice] = useState(''); + const writeChainRef = useRef>(Promise.resolve()); + const mountedRef = useRef(true); + const options = composerReasoningEffortOptions(); + + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + + useEffect(() => { + let cancelled = false; + const invoke = resolveTauriInvoke(); + if (!invoke) { + return undefined; + } + void invoke('read_game_creator_app_config') + .then((view) => { + if (cancelled || !mountedRef.current) return; + setEffort( + normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort), + ); + }) + .catch(() => { + if (cancelled || !mountedRef.current) return; + setNotice('推理档读取失败'); + }); + return () => { + cancelled = true; + }; + }, []); + + function selectEffort(next: GameCreatorLlmReasoningEffort) { + const invoke = resolveTauriInvoke(); + if (!invoke) { + setNotice('需要在 Tauri App 内运行'); + return; + } + const previous = effort; + setEffort(next); + setNotice(''); + setSaving(true); + const write = () => + invoke('select_game_creator_reasoning_effort', { + effort: next, + }); + const run = writeChainRef.current.then(write, write); + writeChainRef.current = run.then( + () => undefined, + () => undefined, + ); + void run + .then((view) => { + if (!mountedRef.current) return; + // 以落盘后的回读值为准,避免界面显示一个没有真正保存的档位。 + setEffort( + normalizeComposerReasoningEffort(view?.config?.llm?.reasoningEffort), + ); + }) + .catch(() => { + if (!mountedRef.current) return; + setEffort(previous); + setNotice('推理档保存失败'); + }) + .finally(() => { + if (mountedRef.current) { + setSaving(false); + } + }); + } + + return ( + + {/* 与模型选择器同一套观感:复用 `conversation-model-*` 的触发钮与浮层样式, + 不再用原生 ` - updateRuntimeLlmConfig( - 'reasoningEffort', - event.currentTarget - .value as GameCreatorLlmReasoningEffort, - ) - } - > - {gameCreatorLlmReasoningEfforts.map((effort) => ( - - ))} - - + {/* 推理档已下移到对话输入盒的模型选择器旁(按回合生效), + 设置里不再重复一份。 */} ); } - if ((kind === 'document' || kind === 'code') && documentSummary) { + /** + * 代码文件卡:**不显示内容**,只给代码图标 + 类型标签。 + * + * 这里的判据是路径分流(`previewVariant === 'code'`),与是否读到内容无关 —— + * 代码卡压根不发起读取(见 `useProjectResourceCardPreviews` 的入队门禁)。 + */ + if (previewVariant === 'code') { return ( - - {documentSummary} + + + ); + } + if (previewVariant !== null && documentPreview) { + return ( + + {documentPreview} ); } @@ -1566,6 +1585,8 @@ export default function ProjectDevelopmentView({ createResourceCanvasHistory, ); const [resourcePanelOpen, setResourcePanelOpen] = useState(false); + const [resourceDocumentPreviewIdentity, setResourceDocumentPreviewIdentity] = + useState(null); /** * 「生成素材」浮层的本次放行类型。 * @@ -1923,6 +1944,7 @@ export default function ProjectDevelopmentView({ isClassificationPanelOpen: resourceClassificationOverlayOpen, isRenameDialogOpen: resourceRenameAssetId !== null, isRecoveryPanelOpen: resourceRecoveryPanelOpen, + isDocumentPreviewOpen: resourceDocumentPreviewIdentity !== null, }, }), boundaryRefs: [resourceBookManagerRef], @@ -1950,6 +1972,7 @@ export default function ProjectDevelopmentView({ isClassificationPanelOpen: resourceClassificationOverlayOpen, isRenameDialogOpen: resourceRenameAssetId !== null, isRecoveryPanelOpen: resourceRecoveryPanelOpen, + isDocumentPreviewOpen: resourceDocumentPreviewIdentity !== null, }, }) ) { @@ -1971,6 +1994,7 @@ export default function ProjectDevelopmentView({ resourceClassificationOverlayOpen, resourcePanelOpen, resourceRecoveryPanelOpen, + resourceDocumentPreviewIdentity, resourceRenameAssetId, selectedResourceIds, uiEditorRoute, @@ -3243,9 +3267,17 @@ export default function ProjectDevelopmentView({ const agentSummaries = showAllAgentGroups ? allAgentSummaries : allAgentSummaries.slice(0, 3); - const currentApprovalLabel = - approvalOptions.find((option) => option.id === approvalMode)?.label ?? - '严格审批'; + /** + * 会话面板 Codex 风格改造后,面板顶部不再直接挂泥点钱包,钱包收进面板自己的设置浮层。 + * `supervisor` 是外部传进来的 React 元素(`WorkspaceLauncher` 里的 `ProjectSupervisor`), + * 这里用 `cloneElement` 把 `walletEntry` 补进去;元素形态不变时原样返回,不改变既有行为。 + */ + const supervisorSurface = + walletEntry && isValidElement(supervisor) + ? cloneElement(supervisor as ReactElement<{ walletEntry?: ReactNode }>, { + walletEntry, + }) + : supervisor; const resourceSectionScrollKey = useCallback( (category: ResourceCategory, layoutMode = sortMode) => @@ -6326,6 +6358,27 @@ export default function ProjectDevelopmentView({ null) : null; + useEffect(() => { + if ( + mode !== 'resources' || + uiEditorRoute || + resourceDocumentPreviewIdentity !== selectedResourcePreviewIdentity + ) { + setResourceDocumentPreviewIdentity(null); + } + }, [ + mode, + uiEditorRoute, + resourceDocumentPreviewIdentity, + selectedResourcePreviewIdentity, + ]); + + useEffect(() => { + if (!resourceDocumentPreviewIdentity) return; + protectResourceCardPreview(resourceDocumentPreviewIdentity); + return () => protectResourceCardPreview(null); + }, [protectResourceCardPreview, resourceDocumentPreviewIdentity]); + /** * 解析一次资源派生的源身份:取项目 revision,必要时把任务产物正规化成正式素材。 * @@ -7839,13 +7892,38 @@ export default function ProjectDevelopmentView({ selectedToolbarStyle && ((selectedToolbarActions?.size ?? 0) > 0 || Boolean(selectedResource?.manifestAssetId) || + Boolean( + selectedResource && + isResourceDocumentPreviewable(selectedResource), + ) || selectedResourceOpensUiEditor) ? ( + {selectedResource && + selectedResourcePreviewIdentity && + isResourceDocumentPreviewable( + selectedResource, + ) ? ( + } + onClick={() => { + stopActiveCardMedia(); + setResourceDocumentPreviewIdentity( + selectedResourcePreviewIdentity, + ); + }} + > + 预览 + + ) : null} {selectedResourceOpensUiEditor ? ( 替换素材 ) : null} - {/* - 破坏性动作排在最后,并用共享工具条同一套分隔线( - `image-canvas-editor__floating-toolbar-divider`)把它与前面的 - 非破坏性动作隔开。只删素材登记:磁盘文件保留,确认面板里再问一次 - 「是否连带删除引用它的游戏版本」。 - */} - {selectedResource?.manifestAssetId ? ( - <> -