From 09791eb9e49baaa5089cb6ed2db55d6cbe7914f0 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Wed, 16 Sep 2026 02:30:28 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=AF=B9=E8=AF=9D=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E6=8A=95=E5=BD=B1=E5=B9=B6=E4=BC=98=E5=8C=96=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E5=91=BD=E4=BB=A4=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按回合唯一归属渲染正文与工具,修复完成快照冲刷和历史归并 补齐工具输入输出,Windows命令卡片隐藏PowerShell启动器包装 时间显示精确到秒,结束信息右对齐并调整输入框文案与引用按钮间距 同步回归用例与规范文档,保留实机验收待办 --- .../src/agent/codex_app_server/mod.rs | 60 +- .../src-tauri/src/agent/direct_runtime/mod.rs | 185 ++++-- .../src-tauri/src/agent/direct_tool_calls.rs | 72 ++- .../src-tauri/src/agent/direct_turn_stream.rs | 125 ++-- apps/ai-game-creator-shell/src/App.tsx | 151 ++--- .../ProjectSupervisorView.tsx | 548 ++++-------------- .../SupervisorChatOnlyView.tsx | 4 +- .../project-workspace/ToolCallGroup.tsx | 3 +- .../directTurnPresentation.ts | 230 ++++++++ .../toolCallGroupPresentation.ts | 80 ++- apps/ai-game-creator-shell/src/styles.css | 43 +- .../appSurface/project-development.suite.ts | 6 +- .../tests/appSurface/tool-call-group.suite.ts | 50 +- .../tests/directTurnPresentation.test.ts | 175 ++++++ .../tests/windowsCommandPresentation.test.ts | 75 +++ ...【实施计划】对话回合唯一投影-2026-09-16.md | 10 + .../【里程碑】对话回合唯一投影-2026-09-16.md | 26 + .../shared-memory/project-overview.md | 2 + ...案】AI游戏创作智能体App实施计划-2026-06-24.md | 11 + ...方案】GameAgent对话工具调用卡片-2026-09-14.md | 16 +- 20 files changed, 1143 insertions(+), 729 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directTurnPresentation.ts create mode 100644 apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts create mode 100644 apps/ai-game-creator-shell/tests/windowsCommandPresentation.test.ts create mode 100644 docs/project-memory/plans/【实施计划】对话回合唯一投影-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md 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 a6a01d24a..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 @@ -589,6 +589,7 @@ pub(crate) enum DirectCodexTurnObservation { AgentMessageSegment { item_id: String, accumulated_text: String, + completed: bool, }, IntermediateText(String), /// 模型的思考过程(reasoning item 的明文摘要):流式阶段整段替换下发。 @@ -3013,6 +3014,7 @@ impl CodexAppServerConnection { observer(DirectCodexTurnObservation::AgentMessageSegment { item_id: item_id.clone(), accumulated_text: segment_text, + completed: false, }); } } @@ -3148,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) @@ -3185,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") 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 191012b97..92a7adf14 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 @@ -4505,10 +4505,13 @@ fn persist_collected_direct_tool_calls(root: &Path, collector: &DirectToolCallCo } /// 回合流条目落盘:与工具调用同一口径(阻塞线程池 + 项目锁)。 -fn spawn_persist_direct_turn_stream_item(root: &Path, item: &DirectTurnStreamItem) { +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)); + tauri::async_runtime::spawn_blocking(move || upsert_direct_turn_stream_item_at(&root, &item)) } /// 文本段落盘/下发的节流间隔:文本段是"整段累计 + 原地替换",不需要逐 delta 落盘。 @@ -4530,6 +4533,7 @@ struct DirectTurnStreamWriter { turn_id: String, seq_by_id: BTreeMap, next_seq: u64, + last_updated_at: u64, pending_text: Option, } @@ -4539,6 +4543,7 @@ impl DirectTurnStreamWriter { turn_id, seq_by_id: BTreeMap::new(), next_seq: 0, + last_updated_at: 0, pending_text: None, } } @@ -4553,60 +4558,54 @@ impl DirectTurnStreamWriter { self.next_seq } - /// 文本段推进。返回需要落盘 + 下发的那份快照(节流窗口内返回 `None`)。 - /// - /// 段身份(`item_id`)变化时先把上一段的收尾快照交出去:上一段最后一段文字不能丢。 + /// 按 item 身份更新,段切换必须同时交出旧段尾快照与新段首快照。 fn push_text( &mut self, root: &Path, item_id: &str, visible_text: &str, now_ms: u64, - ) -> Option { + completed: bool, + ) -> Vec { let now = std::time::Instant::now(); - let mut flushed = None; + 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) { - flushed = self.take_pending_snapshot(); + snapshots.extend(self.take_pending_snapshot()); } - match self.pending_text.as_mut() { - Some(pending) if pending.item_id == item_id => { - pending.item.text = Some(sanitize_stream_text(root, visible_text)); - pending.item.updated_at = now_ms.max(pending.item.updated_at); - if now.duration_since(pending.last_flush).as_millis() + 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; - return Some(pending.item.clone()); - } - } - _ => { - 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, - now_ms, - ); - self.pending_text = Some(DirectTurnStreamPendingText { - item_id: item_id.to_string(), - item, - last_flush: now, - }); - // 新段一出现就立刻落盘 + 下发:位置由这一刻钉死。 - return self - .pending_text - .as_ref() - .map(|pending| pending.item.clone()); + { + 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, + }); } - flushed + snapshots } /// 取出当前段的收尾快照(段结束 / 回合结束时调用),不再持有它。 @@ -4658,7 +4657,8 @@ async fn run_direct_game_creator_turn_inner( let turn_tool_calls = Arc::clone(&tool_calls); // 回合流:文本段与工具按**出现顺序**各占一行,位置(seq)在首次出现时钉死。 let mut stream_writer = DirectTurnStreamWriter::new(client_turn_id.clone()); - let mut observer = move |observation: DirectCodexTurnObservation| { + 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) => { @@ -4672,23 +4672,26 @@ async fn run_direct_game_creator_turn_inner( DirectCodexTurnObservation::AgentMessageSegment { item_id, accumulated_text, + completed, } => { // 可见文本段:同一 item 的后续 delta 就地增长,item 变了才新起一段。 let Some(visible_text) = project_direct_codex_visible_text(&accumulated_text) else { return; }; - let Some(item) = stream_writer.push_text( + let items = stream_writer.push_text( &turn_root, &item_id, &visible_text, direct_tool_call_now_ms(), - ) else { - return; - }; - // 事件与落盘共用同一份快照:前端的顺序真相与文件里的顺序真相一致。 - spawn_persist_direct_turn_stream_item(&turn_root, &item); - emitter.emit_with_stream_items(status, None, None, None, vec![item]); + 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 @@ -4710,23 +4713,31 @@ async fn run_direct_game_creator_turn_inner( // streaming 已被"用户可见正文"占用。 emitter.emit_with_reasoning("running", None, None, None, Some(reasoning)); } - DirectCodexTurnObservation::ToolCall(tool_call) => { - // 同一个工具调用会被观察两次:`item/started`(running)与 `item/completed` - // (终态)。这里**只在状态真的变化时**才再收集与下发一次,既能带上终态、 - // 又不会在每个 heartbeat 重发同一份快照(前端按 id 幂等合并,不会多出卡片)。 - // - // 曾经这里用"每个 id 只发一次"去重,结果终态观察被直接丢掉:工具调用永远 - // 停在 running(实机表现为"命令都结束了还显示执行中")。 + 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); @@ -4734,22 +4745,27 @@ async fn run_direct_game_creator_turn_inner( collected.push(tool_call.clone()); } // 回合流:工具是**普通元素**,位置在文本段之后(或与相邻工具成块)。 - let stream_item = - stream_writer.push_tool(&tool_call, direct_tool_call_now_ms()); - spawn_persist_direct_turn_stream_item(&turn_root, &stream_item); + 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()]), - vec![stream_item], + items, ); // 落盘"最新的那一份":started 让卡片刷新后立刻出现,终态覆盖同一行。 spawn_persist_direct_tool_call(&turn_root, &tool_call); } } }; - direct_game_creator_codex_chat_at_with_optional_observer( + let reply_result = direct_game_creator_codex_chat_at_with_optional_observer( root, system_prompt, prompt.to_string(), @@ -4758,7 +4774,19 @@ async fn run_direct_game_creator_turn_inner( audit, direct_user_item.clone(), ) - .await + .await; + 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] 回合快照持久化失败"); + } + } + reply_result } else { direct_game_creator_codex_chat_at_with_optional_observer( root, @@ -4782,8 +4810,7 @@ async fn run_direct_game_creator_turn_inner( ) })?; if let Some(emitter) = turn_emitter { - // 最终回复落到本回合最后一条文本段上(原地更新,不新起一段),并随事件下发: - // 前端据此把最后一段替换成最终可见回复,流式尾巴与最终回复不会重复。 + // 已有 item 文本由完成事件负责;只有完全没有 item 文本才补最终回复。 let finalized = finalize_direct_turn_stream_reply_at(root, emitter.turn_id(), &visible_reply) .ok() @@ -4820,6 +4847,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 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 index b1dae74b0..82e9989e8 100644 --- 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 @@ -230,9 +230,14 @@ fn first_line_bounded(value: &str, max_chars: usize) -> String { bounded_chars(first_line, max_chars) } -fn optional_bounded_line(value: Option<&str>, max_chars: usize) -> Option { - let value = value?.trim(); - (!value.is_empty()).then(|| bounded_chars(value, 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> { @@ -325,15 +330,17 @@ fn direct_tool_call_status(item: &Value, completed: bool) -> &'static str { } } -/// 同一 id 的两次观察(`item/started` / `item/completed`)是否带来了状态变化。 -/// -/// 只有状态变化时才需要再收集、再下发一次:既避免同一份快照在每个心跳重复下发, -/// 又不会像"每个 id 只发一次"那样把终态丢掉(历史 bug:命令都结束了卡片仍显示"执行中")。 +/// 状态或可见详情变化才下发;同状态的输入/输出补全也属于更新。 pub(crate) fn direct_tool_call_status_changed( existing: Option<&DirectToolCall>, incoming: &DirectToolCall, ) -> bool { - !existing.is_some_and(|current| current.status == incoming.status) + !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`。 @@ -382,14 +389,17 @@ pub(crate) fn direct_tool_call_from_item( .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)); - let output = item - .get("aggregatedOutput") - .or_else(|| item.get("output")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|output| !output.is_empty()) - .map(|output| sanitize_detail_text(root, output)) + .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 用项目相对路径)。 @@ -408,11 +418,15 @@ pub(crate) fn direct_tool_call_from_item( .filter(|tool| !tool.is_empty()) .map(|tool| sanitize_detail_text(root, tool)); // `summary` 会落到卡片与落盘文件,它的兜底来源同样必须脱敏。 - let summary_source = command - .as_deref() - .or_else(|| changes.first().map(|change| change.path.as_str())) - .or(tool.as_deref()) - .unwrap_or_default(); + 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 { @@ -538,7 +552,7 @@ fn status_certainty(status: &str) -> u8 { /// 「回合末整批落盘」与「逐条快照落盘(spawn_blocking)」两条路径竞争时,后到的 /// 旧快照不能把已经 `completed` / `failed` 的卡片打回 `running`。 /// - `updatedAt` 相同时终态优先,避免同一毫秒内的旧快照回退状态。 -fn merge_tool_call_snapshot( +pub(crate) fn merge_tool_call_snapshot( existing: &DirectToolCall, incoming: &DirectToolCall, ) -> DirectToolCall { @@ -550,6 +564,20 @@ fn merge_tool_call_snapshot( } 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) 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 index 5bbdb7d20..a2c8f1b30 100644 --- 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 @@ -8,8 +8,7 @@ //! 文本追加 / 工具状态变化)只改内容不改 `seq`。因此并发落盘的先后顺序不会让"新工具插到 //! 旧文本前面"——渲染顺序只由 `seq` 决定。 //! -//! 为什么不复用 `project.jsonl`:那条链路一个回合只投影一条 assistant 消息(整段回复), -//! 中途的文本段没有独立记录,装不下"文本段与工具交替"的顺序。 +//! `project.jsonl` 保留原始消息;本流补充文本与工具交替的 item 顺序,不能重复展示两份正文。 use crate::agent::sanitize_detail_text; use crate::config::{prepare_game_creator_private_path_for_read, write_game_creator_private_file}; @@ -59,6 +58,44 @@ pub(crate) struct DirectTurnStreamItem { 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()) @@ -211,10 +248,8 @@ fn merge_stream_snapshot( .map(Iterator::count) .unwrap_or_default() }; - // 文本段按"更长优先":同一个 item 的文本只会增长,而快照的 updated_at 未必单调递增; - // 只认 updated_at 更新会把后到的完整文本丢掉(实机表现:正文只剩前缀"我来")。 + // writer 保证更新时间单调;完成快照可以纠正正文,旧快照不能靠更长抢回所有权。 let take_incoming = incoming.updated_at > existing.updated_at - || (incoming.kind == "text" && text_len(incoming) > text_len(existing)) || (incoming.updated_at == existing.updated_at && text_len(incoming) > text_len(existing)); let mut merged = existing.clone(); if take_incoming { @@ -233,7 +268,7 @@ fn merge_stream_snapshot( merged } -/// 按 id 归并,再按 `seq` 正序裁剪到最近 `DIRECT_TURN_STREAM_LIMIT` 条。 +/// 按身份归并;跨回合按起点,回合内按 seq,不能用局部 seq 判断全局新旧。 fn normalize_stream_items(items: Vec) -> Vec { let mut by_id: BTreeMap = BTreeMap::new(); for item in items { @@ -244,7 +279,20 @@ fn normalize_stream_items(items: Vec) -> Vec>(); - normalized.sort_by(|left, right| left.order_key().cmp(&right.order_key())); + 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); } @@ -286,7 +334,7 @@ pub(crate) fn upsert_direct_turn_stream_item_at( ) -> Result<(), String> { enforce_project_permission_policy(root, "conversation.write")?; with_locked_stream_items(root, |items| { - items.retain(|existing| existing.id != item.id); + // normalize_stream_items 在锁内归并全部版本;不得提前删除比较基准。 items.push(item.clone()); }) } @@ -345,14 +393,7 @@ pub(crate) fn append_direct_turn_stream_text_at( }) } -/// 回合结束:把**可见回复**落到本回合最后一条文本段上。 -/// -/// - 本回合已有文本段(流式逐段落的那些):原地把最后一段替换成最终可见回复, -/// 不新起一段——否则最终回复会和最后一段重复。 -/// - 本回合没有文本段(非流式、或整轮没有文本):追加一段,位置排在最后。 -/// -/// `visible_reply` 由调用方先做可见性投影(去思考块、去空)。返回被写入的那一条, -/// 调用方用它下发同一份快照。 +/// 没有任何 item 文本时补最终回复;已有 item 由完成事件负责,不能猜测覆盖某一段。 pub(crate) fn finalize_direct_turn_stream_reply_at( root: &Path, turn_id: &str, @@ -369,36 +410,30 @@ pub(crate) fn finalize_direct_turn_stream_reply_at( enforce_project_permission_policy(root, "conversation.write")?; let now = crate::agent::direct_tool_call_now_ms(); with_locked_stream_items(root, |items| { - let last_text_id = items + if items .iter() - .filter(|item| item.turn_id == turn_id && item.kind == DIRECT_TURN_STREAM_KIND_TEXT) - .max_by(|left, right| left.order_key().cmp(&right.order_key())) - .map(|item| item.id.clone()); - match last_text_id { - Some(id) => { - let item = items - .iter_mut() - .find(|item| item.id == id) - .expect("最后一条文本段必须还在集合里"); - // 位置不动:只替换文本与 updatedAt(原地更新,不重排)。 - item.text = Some(sanitize_stream_text(root, visible_reply)); - item.updated_at = now.max(item.updated_at); - Some(item.clone()) - } - None => { - let next_seq = items.iter().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) - } + .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/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ffb71fd7f..dcbfc49a3 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -499,29 +499,6 @@ function directCodexConversationMessageId( return `${DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX}${turnId}:${role}`; } -/** 从 `direct-codex::assistant` 反解回合 id;不是这个形状就返回 `null`。 */ -/** - * 从任意 direct-codex 消息 id 解出回合 id:`direct-codex::user` / `:assistant` 都认。 - * - * 历史里 assistant 消息存的是 Codex 原始 id(没有前缀),解不出来就返回 null—— - * 调用方必须容忍"全部解不出来"的情况,不能因此把数据整批过滤掉。 - */ -function directCodexTurnIdFromConversationMessageId(messageId: string) { - if (!messageId.startsWith(DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX)) { - return null; - } - const rest = messageId.slice( - DIRECT_CODEX_CONVERSATION_MESSAGE_ID_PREFIX.length, - ); - for (const suffix of [':user', ':assistant']) { - if (rest.endsWith(suffix)) { - const turnId = rest.slice(0, -suffix.length).trim(); - return turnId || null; - } - } - return rest.trim() || null; -} - export const MAX_CHAT_COMPOSER_ATTACHMENTS = 8; /** @@ -569,17 +546,15 @@ function mergeTurnStreamItems( // 内容只在更新(或同刻更长)的快照上替换;`seq` 取最早,位置不许回退。 const textLength = (value: TurnStreamItem) => value.kind === 'text' ? (value.text?.length ?? 0) : 0; - // 文本段按"更长优先":同一个 item 的文本只会增长,而快照的 updatedAt 未必单调递增; - // 只认 updatedAt 更新会把后到的完整文本丢掉(实机表现:正文只剩前缀"我来")。 + // writer 更新时间单调;完成快照允许纠正正文,迟到旧快照不能覆盖。 const takeIncoming = normalized.updatedAt > previous.updatedAt || - (normalized.kind === 'text' && - textLength(normalized) > textLength(previous)) || (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 @@ -827,6 +802,7 @@ export function App({ useEffect(() => { if (supervisorChatOnly) return; const nextProjectPath = localProject?.projectPath ?? null; + ensureDirectTimelineProject(nextProjectPath); const previousProjectPath = localProjectPathRef.current; localProjectPathRef.current = nextProjectPath; // 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。 @@ -1004,6 +980,16 @@ export function App({ 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 不会重复渲染。 @@ -1020,7 +1006,9 @@ export function App({ if (!id) { continue; } - const existingIndex = merged.findIndex((existing) => existing.id === id); + const existingIndex = merged.findIndex( + (existing) => existing.id === id && existing.turnId === call.turnId, + ); const normalized: GameCreatorDirectToolCall = { ...call, id, @@ -1028,6 +1016,18 @@ export function App({ }; // 起点时间取更早的那个:`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 && @@ -1183,23 +1183,6 @@ export function App({ watchRecoveredDirectCodexTurn(projectPath, clientTurnId); } - /** 工具调用卡片按项目维度作废:换项目 / 重开历史时整体替换,避免串项目。 */ - function replaceDirectToolCalls(next: readonly GameCreatorDirectToolCall[]) { - const normalized = next - .filter((call) => Boolean(call.id?.trim())) - .map((call) => ({ - ...call, - id: call.id.trim(), - detail: call.detail ?? { changes: [] }, - })) - .sort( - (left, right) => - left.startedAt - right.startedAt || left.id.localeCompare(right.id), - ); - directToolCallsRef.current = normalized; - setDirectToolCalls(normalized); - } - // 回合流(文本段 + 工具按**出现顺序**交替):实时增量与回读历史共用一份状态, // 渲染顺序只由条目的 `seq` 决定,界面不再按文本长度 / 标点 / 时间窗猜切点。 const [directTurnStream, setDirectTurnStream] = useState( @@ -1217,15 +1200,6 @@ export function App({ setDirectTurnStream(merged); } - /** 回合流按项目维度作废:换项目 / 重开历史时整体替换,避免串项目。 */ - function replaceTurnStreamItems(next: readonly TurnStreamItem[]) { - const normalized = sortTurnStreamItems( - next.filter((item) => Boolean(item.id?.trim())), - ); - directTurnStreamRef.current = normalized; - setDirectTurnStream(normalized); - } - function clearDirectCodexTransientReply(projectPath: string, turnId: string) { const activeTurn = activeDirectCodexTurnRef.current; if ( @@ -2310,6 +2284,7 @@ export function App({ : current, ); } + ensureDirectTimelineProject(payload.projectPath); // 工具调用增量:字段可选,老事件(undefined)走原路径,行为不变。 if (payload.toolCalls?.length) { applyDirectToolCalls( @@ -2444,7 +2419,7 @@ export function App({ activeDirectCodexTurnRef.current = { projectPath, turnId: activeTurn.turnId, - lastSequence: 0, + lastSequence: -1, receivedDirectUpdate: true, }; setDirectCodexProcessKey(`${projectPath}\\u0000${activeTurn.turnId}`); @@ -2471,7 +2446,8 @@ export function App({ activeDirectCodexTurnRef.current = { projectPath, turnId: state.turnId, - lastSequence: state.lastSeq, + // Thread Manager 的 seq 与回合展示事件的 sequence 是两个独立序列。 + lastSequence: -1, receivedDirectUpdate: true, }; setDirectCodexProcessKey(`${projectPath}\u0000${state.turnId}`); @@ -4160,7 +4136,6 @@ export function App({ 'read_direct_tool_calls', { projectPath: nextProjectPath }, ).catch(() => []); - replaceDirectToolCalls(persistedToolCalls); // 回合流(顺序真相)走独立历史文件(`turn-stream.jsonl`)。与工具调用同一处:必须在 // 读完项目对话之后、任何提前 return 之前回读。缺命令(老客户端)/ 缺文件 / 读取失败 // 都只是"这个回合没有流",界面回退到原来的渲染,不能因此把整个打开流程判失败。 @@ -4168,7 +4143,15 @@ export function App({ 'read_direct_turn_stream', { projectPath: nextProjectPath }, ).catch(() => []); - replaceTurnStreamItems(persistedTurnStream); + if ( + projectSupervisorHistoryLoadVersionRef.current !== loadVersion || + localProjectPathRef.current !== nextProjectPath + ) + return; + ensureDirectTimelineProject(nextProjectPath); + // 回读可能与实时事件交错:按同一身份合并,不能用旧磁盘快照覆盖实时状态。 + applyDirectToolCalls(persistedToolCalls); + applyTurnStreamItems(persistedTurnStream); // 重进会话时 Rust 侧可能仍登记着上一条 Direct 回合。不接管的话界面既不显示 // 过程卡也不给终止入口,用户再发消息只会被守卫拒绝("已有另一条回合正在运行")。 await restoreRunningDirectCodexTurn(nextProjectPath); @@ -7144,17 +7127,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]; @@ -7259,7 +7246,7 @@ export function App({ setProjectSupervisorRuntimeError(''); setChatComposerNotice('已终止本次回合'); setMessages((current) => - appendDirectAssistantMessage(current, '已终止本次回合。'), + appendDirectAssistantMessage(current, '已终止本次回合。', true), ); return; } @@ -7295,7 +7282,7 @@ export function App({ setDirectCodexProgress('正在记录失败原因'); setProjectSupervisorRuntimeError(visibleMessage); setMessages((current) => - appendDirectAssistantMessage(current, visibleMessage), + appendDirectAssistantMessage(current, visibleMessage, true), ); } } finally { @@ -12449,32 +12436,6 @@ export function App({ 0, messages.length - visibleMessages.length, ); - // 工具调用卡片只保留「当前消息列表里确实有这个回合」的那些:实时回合一进来就能挂上, - // 历史回合只有对应的 assistant 消息还在列表里才渲染,避免旧卡片漂在列表尾部。 - const visibleTurnIds = new Set( - messages - .map((message) => message.messageId) - .filter((messageId): messageId is string => Boolean(messageId)) - .map(directCodexTurnIdFromConversationMessageId) - .filter((turnId): turnId is string => Boolean(turnId)), - ); - // 老历史里 assistant 消息是 Codex 原始 id,一个回合都解不出来:此时**不过滤**, - // 否则工具卡片与回合流会被整批丢掉(实机表现:重进会话后工具与流全部消失)。 - const keepAllDirectCodexTurns = visibleTurnIds.size === 0; - const visibleToolCalls = directToolCalls.filter( - (call) => - keepAllDirectCodexTurns || - visibleTurnIds.has(call.turnId) || - call.turnId === activeDirectCodexTurnRef.current?.turnId, - ); - // 回合流只保留「当前消息列表里确实有这个回合」的那些,外加正在跑的回合; - // 没有流条目的回合由视图自己回退到原来的渲染。 - const visibleTurnStreamItems = directTurnStream.filter( - (item) => - keepAllDirectCodexTurns || - visibleTurnIds.has(item.turnId) || - item.turnId === activeDirectCodexTurnRef.current?.turnId, - ); const hasEarlierConversationMessages = hiddenConversationCount > 0 || (directCodexProductRuntime && directHistoryHasMore); @@ -13033,8 +12994,18 @@ export function App({ } pendingCommand={directCodexProductRuntime ? pendingCommand : null} projectPath={localProject?.projectPath ?? projectPath} - toolCalls={visibleToolCalls} - turnStreamItems={visibleTurnStreamItems} + toolCalls={ + directTimelineProjectPathRef.current === localProject?.projectPath + ? directToolCalls + : [] + } + turnStreamItems={ + directTimelineProjectPathRef.current === localProject?.projectPath + ? directTurnStream + : [] + } + conversationMessages={messages} + hasUnloadedHistory={directHistoryHasMore} activeTurnId={ directCodexProductRuntime ? (activeDirectCodexTurnRef.current?.turnId ?? null) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index ef24ccc24..ed6a0f30f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -56,6 +56,10 @@ import { DesignAgentPendingActions, DesignAgentPhaseStatus, } from './DesignAgentSurface'; +import { + buildDirectTurnPresentations, + type DirectTurnPresentation, +} from './directTurnPresentation'; import { PlanGddSurface } from './GddApprovalCard'; import { pendingCommandDetail, @@ -71,53 +75,7 @@ import { } from './ResourceReferenceInput'; import type { ChatComposerDraft, ChatReference } from './resourceReferences'; import { ToolCallGroup } from './ToolCallGroup'; -import { - formatTurnDuration, - turnToolCallEndedAt, -} from './toolCallGroupPresentation'; - -/** 与 `App.tsx` 的回合消息 id 同构:`direct-codex::`。 */ -function directCodexTurnMessageId(turnId: string, role: 'user' | 'assistant') { - return `direct-codex:${turnId}:${role}`; -} - -/** 从 `direct-codex::assistant` 反解回合 id;不是这个形状返回 `null`。 */ -function directCodexTurnIdFromAssistantMessageId(messageId: string) { - const prefix = 'direct-codex:'; - const suffix = ':assistant'; - if (!messageId.startsWith(prefix) || !messageId.endsWith(suffix)) { - return null; - } - return messageId.slice(prefix.length, -suffix.length); -} - -/** 从 `direct-codex::user` 反解回合 id;不是这个形状返回 `null`。 */ -function directCodexTurnIdFromUserMessageId(messageId: string) { - const prefix = 'direct-codex:'; - const suffix = ':user'; - if (!messageId.startsWith(prefix) || !messageId.endsWith(suffix)) { - return null; - } - return messageId.slice(prefix.length, -suffix.length); -} - -/** 按回合分组,组内按 `seq` 升序(排序已在 App 侧完成,这里保持原顺序)。 */ -function groupTurnStreamItems(items: readonly TurnStreamItem[]) { - const byTurn = new Map(); - for (const item of items) { - const turnId = item.turnId?.trim(); - if (!turnId) { - continue; - } - const bucket = byTurn.get(turnId); - if (bucket) { - bucket.push(item); - } else { - byTurn.set(turnId, [item]); - } - } - return byTurn; -} +import { formatTurnDuration } from './toolCallGroupPresentation'; /** 相邻的工具流项合并成一个块;中间夹了文本段就另起一块。 */ type TurnStreamToolRun = { @@ -168,12 +126,9 @@ function TurnStreamSequence({ active, userSentAt, className, - toolsOnly = false, }: { items: readonly TurnStreamItem[]; toolCalls: readonly GameCreatorDirectToolCall[]; - /** 只渲染工具块:当流的文本不完整(老数据被截断)时用,正文交给持久化消息渲染。 */ - toolsOnly?: boolean; /** 这个回合是否正在跑(决定工具行显示"执行中")。 */ active: boolean; userSentAt: number; @@ -191,22 +146,20 @@ function TurnStreamSequence({ <> {runs.map((run, index) => run.kind === 'text' ? ( - toolsOnly ? null : ( -
- -
- ) +
+ +
) : ( :assistant`,不回落到别的回合; - // 正在跑的回合还没有 assistant 消息落盘,先落在消息流末尾,等那条消息落盘后回到它之前。 - // **只有没有回合流的回合才走这条路**:有流的回合按流自己的顺序渲染。 - const streamTurnId = activeTurnId?.trim() ?? ''; - // 回合流(`turn-stream.jsonl`)按回合分组:有流的回合按「文本段 → 工具块 → 文本段」顺序渲染, - // 没有流的回合(老项目 / 缺文件 / 读取失败)留在老路径上,绝不白屏。 - const turnStreamByTurn = groupTurnStreamItems( - directCodex ? turnStreamItems : [], - ); - const toolCallsByAnchor = new Map(); - const liveToolCalls: GameCreatorDirectToolCall[] = []; - let liveToolCallTurnId = ''; - // 老项目里持久化的 assistant 消息 id 是 Codex 原始 id(不是 `direct-codex::assistant`), - // 因此锚不上;这些工具的回合不能丢,统一收集后渲染在列表末尾。 - const unanchoredToolCallsByTurn = new Map< - string, - GameCreatorDirectToolCall[] - >(); - // 正在跑的回合:它的流条目直接渲染在消息列表末尾,直到用户消息落盘、由那条消息接管。 - const liveStreamTurnId = - streamTurnId && turnStreamByTurn.has(streamTurnId) ? streamTurnId : ''; - const liveStreamItems = liveStreamTurnId - ? (turnStreamByTurn.get(liveStreamTurnId) ?? []) + const directTurns = directCodex + ? buildDirectTurnPresentations({ + messages: conversationMessages, + visibleMessages, + items: turnStreamItems, + calls: toolCalls, + activeTurnId, + transientReply, + hasUnloadedHistory, + }) : []; - if (directCodex) { - for (const call of toolCalls) { - if (turnStreamByTurn.has(call.turnId)) { - continue; - } - const expected = directCodexTurnMessageId(call.turnId, 'assistant'); - const hasPersistedAssistant = visibleMessages.some( - (message) => message.messageId === expected, - ); - if (hasPersistedAssistant) { - const bucket = toolCallsByAnchor.get(expected); - if (bucket) { - bucket.push(call); - } else { - toolCallsByAnchor.set(expected, [call]); - } - continue; - } - if (streamTurnId && call.turnId === streamTurnId) { - if (!liveToolCallTurnId) { - liveToolCallTurnId = call.turnId; - } - liveToolCalls.push(call); - continue; - } - const bucket = unanchoredToolCallsByTurn.get(call.turnId) ?? []; - bucket.push(call); - unanchoredToolCallsByTurn.set(call.turnId, bucket); - } - } - const unanchoredToolCalls = [...unanchoredToolCallsByTurn.entries()] - .sort((left, right) => { - const leftAt = Math.min(...left[1].map((call) => call.startedAt || 0)); - const rightAt = Math.min(...right[1].map((call) => call.startedAt || 0)); - return leftAt - rightAt; - }) - .map(([turnId, calls]) => ({ turnId, calls })); - // 历史消息的 id 是 Codex 原始 id(不是 `direct-codex::...`),无法从 id 反解回合; - // 因此按位置配对:回合按首次出现时间排序后,第 N 个回合 ↔ 第 N 个用户消息。 - const turnFirstAt = (turnId: string) => { - let first = Number.POSITIVE_INFINITY; - for (const item of turnStreamByTurn.get(turnId) ?? []) { - first = Math.min(first, Number(item.at) || Number.POSITIVE_INFINITY); - } - for (const call of toolCalls) { - if (call.turnId === turnId) { - first = Math.min( - first, - Number(call.startedAt) || Number.POSITIVE_INFINITY, - ); - } - } - return Number.isFinite(first) ? first : 0; - }; - const orderedStreamTurnIds = [ - ...new Set([ - ...turnStreamByTurn.keys(), - ...toolCalls.map((call) => call.turnId ?? ''), - ]), - ] - .filter((turnId) => Boolean(turnId.trim())) - .sort((left, right) => turnFirstAt(left) - turnFirstAt(right)); - const turnIdByMessageIndex = new Map(); - { - let userIndex = -1; - visibleMessages.forEach((message, index) => { - if (message.role === 'user') { - userIndex += 1; - } - const turnId = orderedStreamTurnIds[userIndex]; - if (turnId && userIndex >= 0) { - turnIdByMessageIndex.set(index, turnId); - } - }); - } - // 每个回合的 assistant 原文长度:用于判断流的文本是否完整(老数据可能被截断)。 - const assistantTextLengthByTurn = new Map(); - visibleMessages.forEach((message, index) => { - if (message.role !== 'assistant') { - return; - } - const turnId = turnIdByMessageIndex.get(index); - if (!turnId) { - return; - } - const length = projectSupervisorChatMessageText(message).length; - assistantTextLengthByTurn.set( - turnId, - (assistantTextLengthByTurn.get(turnId) ?? 0) + length, - ); - }); - const streamTextLengthByTurn = new Map(); - for (const [turnId, items] of turnStreamByTurn) { - let total = 0; - for (const item of items) { - if (item.kind === 'text') { - total += item.text?.length ?? 0; - } - } - streamTextLengthByTurn.set(turnId, total); - } - const mappedStreamTurnIds = new Set(turnIdByMessageIndex.values()); - const unmappedStreamTurns = orderedStreamTurnIds.filter( - (turnId) => - !mappedStreamTurnIds.has(turnId) && - (turnStreamByTurn.get(turnId)?.length ?? 0) > 0, - ); - - // 该回合用户消息的 `updatedAt`:拿得到就在块头显示「发送 → 结束」,拿不到只显示结束时间。 - const userMessageUpdatedAtForTurn = (turnId: string) => { - if (!turnId) { - return 0; - } - const userId = directCodexTurnMessageId(turnId, 'user'); - const value = Number( - visibleMessages.find((message) => message.messageId === userId) - ?.updatedAt, - ); - if (!Number.isFinite(value) || value <= 0) return 0; - // 旧快照使用 Unix 秒,新消息使用毫秒;统一到毫秒,避免出现数千万分钟。 - return value < 100_000_000_000 ? value * 1000 : value; - }; - /** 本轮会话的结束时刻:工具快照与消息里最晚的那个 updatedAt。 */ - const turnEndedAtFor = (turnId: string) => { - let endedAt = turnToolCallEndedAt( - toolCalls.filter((call) => call.turnId === turnId), - ); - for (const message of visibleMessages) { - const messageTurnId = message.messageId - ? directCodexTurnIdFromAssistantMessageId(message.messageId) - : null; - if (messageTurnId === turnId) { - endedAt = Math.max(endedAt, Number(message.updatedAt) || 0); - } - } - return endedAt < 100_000_000_000 ? endedAt * 1000 : endedAt; - }; - const turnStartedAtFor = (turnId: string) => { - const messageStarted = userMessageUpdatedAtForTurn(turnId); - if (messageStarted) return messageStarted; - const starts = toolCalls - .filter((call) => call.turnId === turnId && Number(call.startedAt) > 0) - .map((call) => Number(call.startedAt)); - if (starts.length > 0) return Math.min(...starts); - // 纯文本回合没有工具调用:用流里最早一条的出现时刻兜底,否则整轮用量行不显示。 - const streamStarts = (turnStreamByTurn.get(turnId) ?? []) - .map((item) => Number(item.at)) - .filter((at) => Number.isFinite(at) && at > 0); - return streamStarts.length > 0 ? Math.min(...streamStarts) : 0; - }; + const turnStartedAtFor = (turnId: string) => + directTurns.find((turn) => turn.turnId === turnId)?.startedAt ?? 0; const clockTimeWithSeconds = (timestamp: number) => { const date = new Date(timestamp); @@ -624,39 +419,40 @@ export function ProjectSupervisorView({ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`; }; - /** 整轮会话的结束时间与耗时(进行中时用 tick 驱的 now,所以秒数会实时跳动)。 */ - const renderTurnUsage = (turnId: string) => { - if (!turnId) { - return null; - } - const startedAt = - turnStartedAtFor(turnId) || - (activeTurnId === turnId ? activeTurnStartedAt : 0); - if (!startedAt) { - return null; - } - const running = Boolean(activeTurnId) && turnId === activeTurnId; - if (running) { - // 进行中的耗时由输入框上方的过程卡显示("已耗时 xx"),对话里再显示一遍是重复。 - return null; - } - const endedAt = running - ? turnUsageNow - : Math.max(turnEndedAtFor(turnId), startedAt); - const duration = formatTurnDuration(endedAt - startedAt); + const renderTurnUsage = (turn: DirectTurnPresentation) => { + if (!turn.turnId || turn.active || !turn.startedAt) return null; + const endedAt = Math.max(turn.endedAt, turn.startedAt); return (

- {running - ? `本轮进行中 · 用时 ${duration ?? '—'}` - : `本轮结束于 ${endedAt ? clockTimeWithSeconds(endedAt) : '—'} · 耗时 ${duration ?? '0秒'}`} + {`本轮结束于 ${clockTimeWithSeconds(endedAt)} · 耗时 ${formatTurnDuration(endedAt - turn.startedAt) ?? '0秒'}`}

); }; + const renderMessage = (message: ChatMessage, index: number) => ( +
+ + {showDesignReasoning && message.reasoningText ? ( +
+ 思考过程 +
{message.reasoningText}
+
+ ) : null} +
+ ); const submitButton = ( @@ -94,9 +94,11 @@ toolCalls?: DirectTurnToolCall[] | null; - 文案规则(按 kind,不允许自由发挥): - 块头汇总按 kind 计数、顺序固定 `command → file_change → mcp_tool → web_search → context_compaction → other`,标签 `命令`/`文件变更`/`工具调用`/`联网搜索`/`上下文整理`/`其他操作`,形如 `已执行 5 个命令、2 个文件变更`;空集合不渲染块。 - - 行文案:`command` → `已运行 {summary}`、`file_change` → `已编辑 {summary}`、`mcp_tool` → `已调用 {summary}`、`web_search` → `已搜索 {summary}`、`context_compaction` → `已整理上下文`、`other` → `已执行 {summary}`;`failed` 行加 `失败` 并用现有 `--platform-*` 错误色。 + - 行文案:展示工具摘要,不重复添加动词前缀;`context_compaction` 固定为“整理上下文”。状态单独放在行尾(执行中 / 已执行 / 失败),`failed` 使用现有 `--platform-*` 错误色;已结束回合不因残留 `running` 快照显示“执行中”。 - 耗时:单条工具 = `startedAt` → `updatedAt`,块头总用时 = 该回合所有工具的 `min(startedAt)` → `max(updatedAt)`。单条格式:`<1s` → `0.4s`、`<60s` → `12.3s`(整秒省略小数)、`≥60s` → `2m 5s`;块头格式:`42秒` / `4分钟` / `5分钟 45秒`。`startedAt` 为 0 或 `updatedAt < startedAt` 时不显示耗时(不显示 `0s` / 负数),耗时为 0 时同样不显示 `0s`。 - - 时间:块头显示该回合结束时间(`max(updatedAt)` 的本地 `HH:mm`);同一回合能拿到用户消息时间(`updatedAt > 0`)时显示 `HH:mm → HH:mm`(发送 → 结束),取不到就只显示结束时间,不编造。展开态块尾再写一行 `结束于 HH:mm`。 + - 时间:块头显示该回合结束时间(`max(updatedAt)` 的本地 `HH:mm:ss`);同一回合能拿到用户消息时间(`updatedAt > 0`)时显示 `HH:mm:ss → HH:mm:ss`(发送 → 结束),取不到就只显示结束时间,不编造。 + - 回合结束时间与耗时在正文下方右对齐;Direct 对话输入框提示统一为“描述你的想法,或 @ 引用素材”,引用按钮保留输入盒的 12px 内边距,不使用负边距贴边。 + - Windows 命令展示:仅 `command` 卡片识别 `pwsh` / `powershell`(含完整路径、`.exe`、常见启动选项)的 `-Command` / `-c` 外层包装,摘要和展开输入只展示脚本正文,并解开单个 shell 参数的引用拼接。摘要优先读取已脱敏的 `detail.command`,再按首行 120 字符截断,避免历史摘要被可执行文件路径占满。无法识别的启动方式、`-File`、`-EncodedCommand`、普通命令和 MCP 输入原样展示;执行参数、持久化原文、脱敏和输出均不改变。 - 调试属性:块与行都带 `data-duration-ms`(原始毫秒,无法计算时为空串)与稳定 `data-testid`(块 `agent-tool-call-group`、行 `agent-tool-call-row`)。 - 必须用 `