From 721e45f01b4fc9b9dac1281b275e3bbc44f411be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 17:45:28 +0800 Subject: [PATCH 01/11] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20DirectProject=20?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=80=81=E6=9D=A1=E7=9B=AE=E7=9A=84=E5=90=88?= =?UTF-8?q?=E5=B9=B6=E8=BA=AB=E4=BB=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Thread Manager 事件新增 callId,条目身份统一为 callId 优先、其次 itemId - 工具条目按 callId 归并 started/completed,不再留下永远收不到完成事件的幽灵活跃条目 - 历史锚点仍取 response item id,保证分页锚点能在 project.jsonl 中命中 - 补充跨 id 空间归并的单元测试 --- .../src/agent/codex_app_server/mod.rs | 17 ++++ .../src/agent/direct_thread_manager.rs | 88 +++++++++++++++++-- 2 files changed, 99 insertions(+), 6 deletions(-) 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 25d1591e4..fcfa250be 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 @@ -760,6 +760,17 @@ fn direct_thread_item_id(item: &serde_json::Value) -> Option { .map(str::to_string) } +/// 原始 response item 的调用 id:工具条目的 app-server `itemId` 就是这个值, +/// 所以它是两个 id 空间唯一的对齐点。 +fn direct_thread_item_call_id(item: &serde_json::Value) -> Option { + item.get("call_id") + .or_else(|| item.get("callId")) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + fn direct_codex_command_is_game_verification(command: &str) -> bool { let command = command.to_ascii_lowercase(); command.contains("game.static_smoke") @@ -2932,6 +2943,7 @@ impl CodexAppServerConnection { event_type: "turn.started".to_string(), turn_id: turn_id.clone(), item_id: None, + call_id: None, payload: serde_json::json!({ "threadId": thread_id, "turnId": turn_id, @@ -2996,6 +3008,7 @@ impl CodexAppServerConnection { event_type: "item.delta".to_string(), turn_id: turn_id.clone(), item_id: Some(item_id.clone()), + call_id: None, payload: serde_json::json!({ "delta": delta.clone() }), }, ); @@ -3060,6 +3073,7 @@ impl CodexAppServerConnection { event_type: "item.completed".to_string(), turn_id: turn_id.clone(), item_id, + call_id: direct_thread_item_call_id(&item), payload: serde_json::json!({}), }, ); @@ -3079,6 +3093,7 @@ impl CodexAppServerConnection { event_type: event_type.to_string(), turn_id: turn_id.clone(), item_id: None, + call_id: None, payload: request_id .map(|id| serde_json::json!({ "requestId": id })) .unwrap_or_else(|| serde_json::json!({})), @@ -3200,6 +3215,7 @@ impl CodexAppServerConnection { event_type: "item.started".to_string(), turn_id: turn_id.clone(), item_id, + call_id: None, payload: direct_thread_item_started_payload(item), }, ); @@ -3248,6 +3264,7 @@ impl CodexAppServerConnection { event_type: "turn.completed".to_string(), turn_id: turn_id.clone(), item_id: None, + call_id: None, payload: serde_json::json!({ "status": status }), }, ); 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 e3bc497ea..f72f37380 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 @@ -28,6 +28,10 @@ pub(crate) struct DirectThreadRawEvent { pub(crate) turn_id: String, #[serde(skip_serializing_if = "Option::is_none")] pub(crate) item_id: Option, + /// 合并身份的另一半:工具类条目的 `itemId` 是调用 id,原始 item 的 `id` 是 response item id, + /// 两者只在 `callId` 上对齐。前端按 `callId ?? itemId` 归并同一张卡片。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, pub(crate) payload: Value, } @@ -36,9 +40,17 @@ pub(crate) struct DirectThreadRawEventDraft { pub(crate) event_type: String, pub(crate) turn_id: String, pub(crate) item_id: Option, + pub(crate) call_id: Option, pub(crate) payload: Value, } +impl DirectThreadRawEvent { + /// 条目合并身份:有 `callId` 就用它,否则用 `itemId`。 + fn item_identity(&self) -> Option<&str> { + self.call_id.as_deref().or(self.item_id.as_deref()) + } +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DirectThreadSubscriptionBootstrap { @@ -81,6 +93,13 @@ struct ThreadState { total_bytes: usize, active_items: HashSet, unresolved_requests: HashSet, + /// 最新一条 `turn.started` / `turn.completed` 的独立拷贝。 + /// + /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, + /// 队列本身不是完美事件日志——被回收的 `turn.started` / `turn.completed` 不会回放, + /// 只有这份拷贝保证新订阅仍能判定"最新回合是否还在跑"。若将来需要回放多个回合的 + /// 生命周期(回合账本、跨进程恢复、按回合统计),必须另建持久 ledger, + /// 不能靠扩大这份拷贝或放宽回收规则来模拟。 lifecycle_anchor: Option, last_completed_item_id: Option, subscribers: HashMap, @@ -140,6 +159,7 @@ impl DirectThreadManager { event_type: draft.event_type, turn_id: draft.turn_id, item_id: draft.item_id, + call_id: draft.call_id, payload: draft.payload, }; let cleanable = Self::observe_event(thread, &event); @@ -152,7 +172,7 @@ impl DirectThreadManager { bytes, cleanable, }); - Self::mark_item_events_cleanable(thread, event.item_id.as_deref()); + Self::mark_item_events_cleanable(thread, event.item_identity()); if matches!( event.event_type.as_str(), "approval.resolved" | "request.resolved" | "ask.resolved" @@ -256,19 +276,24 @@ impl DirectThreadManager { fn observe_event(thread: &mut ThreadState, event: &DirectThreadRawEvent) -> bool { match event.event_type.as_str() { "item.started" => { - if let Some(item_id) = event.item_id.as_deref() { - thread.active_items.insert(item_id.to_string()); + if let Some(identity) = event.item_identity() { + thread.active_items.insert(identity.to_string()); } false } "item.completed" => { + if let Some(identity) = event.item_identity() { + thread.active_items.remove(identity); + } + // 历史锚点必须是 project.jsonl 里的 response item id,不能用 call id。 if let Some(item_id) = event.item_id.as_deref() { - thread.active_items.remove(item_id); thread.last_completed_item_id = Some(item_id.to_string()); } true } "turn.started" | "turn.completed" => { + // 队列只保留最新一条生命周期事件,更早的可能已被回收;见 + // `ThreadState::lifecycle_anchor` 的 TODO:这不是完整事件日志。 thread.lifecycle_anchor = Some(event.clone()); true } @@ -301,8 +326,8 @@ impl DirectThreadManager { { return true; } - if let Some(item_id) = event.item_id.as_deref() { - return thread.active_items.contains(item_id); + if let Some(identity) = event.item_identity() { + return thread.active_items.contains(identity); } if let Some(request_id) = request_id(event) { return thread.unresolved_requests.contains(&request_id); @@ -476,10 +501,57 @@ mod tests { event_type: event_type.to_string(), turn_id: turn_id.to_string(), item_id: item_id.map(str::to_string), + call_id: None, payload: serde_json::json!({}), } } + /// 工具条目的两个 id 空间必须靠 `callId` 对齐:app-server `item.started` 的 itemId 是调用 id, + /// 原始 item 的 `item.completed` 的 itemId 是 response item id。 + fn draft_with_call_id( + event_type: &str, + turn_id: &str, + item_id: Option<&str>, + call_id: Option<&str>, + ) -> DirectThreadRawEventDraft { + DirectThreadRawEventDraft { + event_type: event_type.to_string(), + turn_id: turn_id.to_string(), + item_id: item_id.map(str::to_string), + call_id: call_id.map(str::to_string), + payload: serde_json::json!({}), + } + } + + #[test] + fn call_id_joins_started_and_completed_across_id_spaces() { + let mut manager = DirectThreadManager::with_limits(100, 100_000); + manager.append( + "thread-1", + draft_with_call_id("item.started", "turn-1", Some("call-1"), None), + ); + manager.append( + "thread-1", + draft_with_call_id("item.completed", "turn-1", Some("item-9"), Some("call-1")), + ); + // 活跃条目按合并身份清理,不留下永远收不到完成事件的幽灵条目。 + let bootstrap = manager.subscribe("thread-1"); + assert!( + bootstrap + .events + .iter() + .all(|event| event.event_type != "item.started"), + "已完成的条目不得再作为运行态事件回到 bootstrap" + ); + // 没有订阅者时,可回收事件全部被回收;历史锚点靠独立字段保留,不占队列。 + assert_eq!( + manager.thread_debug("thread-1").map(|debug| debug.0), + Some(0) + ); + // 历史锚点仍然是 response item id,前端才能拿它当分页锚点。 + assert_eq!(bootstrap.last_completed_item_id.as_deref(), Some("item-9")); + } + #[test] fn subscribers_have_independent_cursors_on_one_global_queue() { let mut manager = DirectThreadManager::with_limits(100, 100_000); @@ -582,6 +654,7 @@ mod tests { event_type: "approval.requested".to_string(), turn_id: "turn-1".to_string(), item_id: None, + call_id: None, payload: serde_json::json!({"requestId": "request-1"}), }, ); @@ -593,6 +666,7 @@ mod tests { event_type: "approval.resolved".to_string(), turn_id: "turn-1".to_string(), item_id: None, + call_id: None, payload: serde_json::json!({"requestId": "request-1"}), }, ); @@ -615,6 +689,7 @@ mod tests { event_type: "approval.requested".to_string(), turn_id: "turn-1".to_string(), item_id: None, + call_id: None, payload: serde_json::json!({"requestId": "request-1"}), }, ); @@ -625,6 +700,7 @@ mod tests { event_type: "approval.resolved".to_string(), turn_id: "turn-1".to_string(), item_id: None, + call_id: None, payload: serde_json::json!({"requestId": "request-1"}), }, ); -- 2.52.0 From 29d0cbb4dfa87ffd14d1d0884840bb9eba7ea5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:01:50 +0800 Subject: [PATCH 02/11] =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=80=81=E4=BA=8B?= =?UTF-8?q?=E4=BB=B6=E4=B8=8E=E5=8E=86=E5=8F=B2=E5=88=87=E7=89=87=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E8=81=8A=E5=A4=A9=E6=9D=A1=E7=9B=AE=E6=8A=95=E5=BD=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 direct_chat_entry 深模块,把原始 response item 与 app-server item 投影成同一聊天条目 - item.started/item.completed 载荷改为完整条目,前端不再需要按 itemId 取快照 - 原始 function_call 与 function_call_output 按 callId 并成一张卡片,文本与明细统一脱敏截断 - 历史切片新增 entries 与 first_item_id,分页锚点不再依赖是否出现可显示条目 --- .../src-tauri/src/agent.rs | 2 + .../src/agent/codex_app_server/mod.rs | 58 +- .../src-tauri/src/agent/direct_chat_entry.rs | 523 ++++++++++++++++++ .../src/agent/direct_thread_manager.rs | 6 + .../src-tauri/src/commands.rs | 8 + 5 files changed, 585 insertions(+), 12 deletions(-) create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs 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 e64b321dd..bf380a888 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -14,6 +14,7 @@ mod codex_cli; mod codex_provider_proxy; mod design_runtime; mod design_tools; +mod direct_chat_entry; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_user_item; @@ -48,6 +49,7 @@ pub(crate) use codex_cli::{ }; pub(crate) use codex_provider_proxy::*; pub(crate) use design_runtime::*; +pub(crate) use direct_chat_entry::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_codex_user_item::*; 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 fcfa250be..27f81fc6b 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 @@ -744,13 +744,17 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat /// Project an app-server item into the small public payload carried by the /// DirectProject event queue. Full item contents are persisted in JSONL and /// must not be forwarded through the runtime event stream. -fn direct_thread_item_started_payload(item: &serde_json::Value) -> serde_json::Value { - serde_json::json!({ - "itemType": item - .get("type") - .and_then(serde_json::Value::as_str) - .unwrap_or("unknown"), - }) +/// 运行态事件载荷:与历史切片同形的聊天条目;拿不到条目时给空对象。 +fn direct_chat_entry_payload( + root: &std::path::Path, + item: &serde_json::Value, + turn_id: Option<&str>, + completed: bool, + now_ms: u64, +) -> serde_json::Value { + direct_chat_entry_from_item(root, item, turn_id, completed, now_ms) + .and_then(|entry| serde_json::to_value(entry).ok()) + .unwrap_or_else(|| serde_json::json!({})) } fn direct_thread_item_id(item: &serde_json::Value) -> Option { @@ -3053,6 +3057,13 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } + let entry_payload = direct_chat_entry_payload( + history_root, + &item, + Some(turn_id.as_str()), + true, + direct_tool_call_now_ms(), + ); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3074,7 +3085,7 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id, call_id: direct_thread_item_call_id(&item), - payload: serde_json::json!({}), + payload: entry_payload, }, ); } @@ -3216,7 +3227,13 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id, call_id: None, - payload: direct_thread_item_started_payload(item), + payload: direct_chat_entry_payload( + history_root, + item, + Some(turn_id.as_str()), + completed, + direct_tool_call_now_ms(), + ), }, ); } @@ -4527,10 +4544,27 @@ mod tests { "result": { "content": "large output" } }); assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); - assert_eq!( - direct_thread_item_started_payload(&item), - serde_json::json!({ "itemType": "mcpToolCall" }) + // 运行态事件必须自足:载荷是投影后的聊天条目,前端不需要再按 itemId 取快照。 + let payload = direct_chat_entry_payload( + std::path::Path::new("."), + &item, + Some("turn-1"), + false, + 1000, ); + assert_eq!( + payload.get("kind").and_then(serde_json::Value::as_str), + Some("tool") + ); + assert_eq!( + payload.get("itemId").and_then(serde_json::Value::as_str), + Some("item-1") + ); + assert_eq!( + payload.get("turnId").and_then(serde_json::Value::as_str), + Some("turn-1") + ); + assert!(payload.get("toolCall").is_some(), "{payload}"); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs new file mode 100644 index 000000000..a348cddbd --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs @@ -0,0 +1,523 @@ +//! DirectProject 聊天条目投影。 +//! +//! 前端只消费一种形状:`DirectChatEntry`。运行态事件与历史切片都由这里把 +//! Codex 原始 response item / app-server thread item 投影成同一形状,并使用 +//! 同一套脱敏、截断与项目路径归一,避免实时与回读两套口径分叉。 +//! +//! 这里只做机械投影:不判断"哪些条目要显示",也不决定顺序策略——可见性与 +//! 排列属于前端聊天投影。 + +use super::direct_tool_calls::{direct_tool_call_from_item, sanitize_detail_text}; +use crate::DirectToolCall; +use serde::Serialize; +use serde_json::Value; +use std::path::Path; + +pub(crate) const DIRECT_CHAT_ENTRY_KIND_MESSAGE: &str = "message"; +pub(crate) const DIRECT_CHAT_ENTRY_KIND_REASONING: &str = "reasoning"; +pub(crate) const DIRECT_CHAT_ENTRY_KIND_TOOL: &str = "tool"; + +/// 单条条目文本上限:与回合流同一口径,避免单条正文无界。 +const DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS: usize = 8000; +/// 工具输入/输出上限:与卡片明细同口径。 +const DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS: usize = 4000; + +/// 前端唯一消费的聊天条目。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectChatEntry { + /// 稳定身份:`project.jsonl` 里的 response item id(分页锚点也用它)。 + pub(crate) item_id: String, + /// 工具条目的调用 id:app-server `item.started` 的 `itemId` 与原始 item 的 `call_id`。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_id: Option, + /// `message` | `reasoning` | `tool` + pub(crate) kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool_call: Option, + /// 条目自带的毫秒时间;拿不到就是 0,由调用方用文件记录时间补齐。 + pub(crate) at: u64, +} + +impl DirectChatEntry { + /// 合并身份:前端按 `callId ?? itemId` 归并实时与历史里的同一条目。 + pub(crate) fn identity(&self) -> &str { + self.call_id.as_deref().unwrap_or(&self.item_id) + } +} + +fn bounded(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 item_text(item: &Value) -> Option { + if let Some(text) = item.get("text").and_then(Value::as_str) { + if !text.trim().is_empty() { + return Some(text.to_string()); + } + } + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None +} + +fn item_turn_id(item: &Value) -> Option { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if from_metadata.is_some() { + return from_metadata; + } + // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 + let rest = item + .get("id") + .and_then(Value::as_str)? + .trim() + .strip_prefix("direct-codex:")?; + let turn = rest.rsplit_once(':')?.0.trim(); + (!turn.is_empty()).then(|| turn.to_string()) +} + +fn item_at_ms(item: &Value) -> u64 { + item.get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default() +} + +fn item_identity(item: &Value) -> Option<(String, Option)> { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match (id, call_id) { + (Some(id), call_id) => Some((id, call_id)), + (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), + (None, None) => None, + } +} + +fn raw_tool_kind(name: &str) -> &'static str { + match name { + "exec_command" | "shell" | "exec" => "command", + "apply_patch" | "write_file" | "edit_file" | "create_file" => "file_change", + "web_search" | "web_search_preview" => "web_search", + _ => "mcp_tool", + } +} + +fn raw_tool_title(kind: &str) -> &'static str { + match kind { + "command" => "执行命令", + "file_change" => "编辑文件", + "web_search" => "联网检索", + "mcp_tool" => "调用工具", + _ => "工具调用", + } +} + +fn stringified(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.clone()), + other => serde_json::to_string(other).ok(), + } +} + +fn first_line(value: &str, max_chars: usize) -> String { + bounded(value.lines().next().unwrap_or_default().trim(), max_chars) +} + +/// 原始 response item 词汇里的工具条目:`function_call` 带输入,`function_call_output` 带输出。 +/// 两者共用 `call_id`,前端与历史投影都按它归并成同一张卡片。 +fn raw_tool_entry( + root: &Path, + item: &Value, + item_id: String, + call_id: Option, + turn_id: Option, + at: u64, + completed: bool, + now_ms: u64, +) -> Option { + let item_type = item.get("type").and_then(Value::as_str)?; + let call_id = call_id?; + let (kind, title, summary, detail) = match item_type { + "function_call" => { + let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); + let arguments = item + .get("arguments") + .and_then(Value::as_str) + .unwrap_or_default(); + let kind = raw_tool_kind(name); + let command = bounded( + &sanitize_detail_text(root, arguments), + DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, + ); + let summary = first_line(&command, 120); + ( + kind, + raw_tool_title(kind).to_string(), + summary, + crate::DirectToolCallDetail { + command: Some(command), + output: None, + changes: Vec::new(), + }, + ) + } + "function_call_output" => { + let output = item.get("output").and_then(stringified).map(|output| { + bounded( + &sanitize_detail_text(root, &output), + DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, + ) + }); + ( + "other", + String::new(), + String::new(), + crate::DirectToolCallDetail { + command: None, + output, + changes: Vec::new(), + }, + ) + } + _ => return None, + }; + let status = if completed { "completed" } else { "running" }; + let started_at = if at > 0 { at } else { now_ms }; + Some(DirectChatEntry { + item_id, + call_id: Some(call_id.clone()), + turn_id: turn_id.clone(), + kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), + role: None, + text: None, + tool_call: Some(DirectToolCall { + schema_version: "agc-tool-call.v1".to_string(), + id: call_id, + turn_id: turn_id.unwrap_or_default(), + kind: kind.to_string(), + title, + summary, + status: status.to_string(), + detail, + started_at, + updated_at: if at > 0 { at } else { now_ms }, + }), + at, + }) +} + +/// 把一条 Codex 条目投影成聊天条目;不属于聊天内容的条目(如 `function_call_output`)返回 `None`。 +pub(crate) fn direct_chat_entry_from_item( + root: &Path, + item: &Value, + turn_id: Option<&str>, + completed: bool, + now_ms: u64, +) -> Option { + let (item_id, call_id) = item_identity(item)?; + let turn_id = turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| item_turn_id(item)); + let at = item_at_ms(item); + + if let Some(entry) = raw_tool_entry( + root, + item, + item_id.clone(), + call_id.clone(), + turn_id.clone(), + at, + completed, + now_ms, + ) { + return Some(entry); + } + + if let Some(tool_call) = direct_tool_call_from_item( + root, + item, + turn_id.as_deref().unwrap_or_default(), + completed, + now_ms, + ) { + let call_id = call_id.or_else(|| Some(tool_call.id.clone())); + return Some(DirectChatEntry { + item_id, + call_id, + turn_id, + kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), + role: None, + text: None, + tool_call: Some(tool_call), + at, + }); + } + + let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default(); + let (kind, role) = if item_type == "reasoning" { + (DIRECT_CHAT_ENTRY_KIND_REASONING, None) + } else { + let role = item.get("role").and_then(Value::as_str)?; + if !matches!(role, "user" | "assistant") { + return None; + } + (DIRECT_CHAT_ENTRY_KIND_MESSAGE, Some(role.to_string())) + }; + let text = item_text(item)?; + let text = bounded( + &sanitize_detail_text(root, &text), + DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS, + ); + Some(DirectChatEntry { + item_id, + call_id, + turn_id, + kind: kind.to_string(), + role, + text: Some(text), + tool_call: None, + at, + }) +} + +/// 历史切片投影:文件顺序就是条目顺序。 +/// 同一调用的输入(`function_call`)与输出(`function_call_output`)在这里并成一张卡片。 +pub(crate) fn direct_chat_entries_from_history( + root: &Path, + items: &[Value], +) -> Vec { + let now_ms = super::direct_tool_calls::direct_tool_call_now_ms(); + let mut entries: Vec = Vec::new(); + for item in items { + let Some(entry) = direct_chat_entry_from_item(root, item, None, true, now_ms) else { + continue; + }; + match entries + .iter_mut() + .find(|existing| existing.identity() == entry.identity()) + { + Some(existing) => merge_chat_entry(existing, entry), + None => entries.push(entry), + } + } + entries +} + +/// 同一条目的后续快照只补空字段:后到的输出不能抹掉先到的命令与标题。 +fn merge_chat_entry(existing: &mut DirectChatEntry, incoming: DirectChatEntry) { + if let (Some(existing_call), Some(incoming_call)) = + (existing.tool_call.as_mut(), incoming.tool_call.as_ref()) + { + existing_call.detail.command = existing_call + .detail + .command + .take() + .or_else(|| incoming_call.detail.command.clone()); + existing_call.detail.output = existing_call + .detail + .output + .take() + .or_else(|| incoming_call.detail.output.clone()); + if existing_call.detail.changes.is_empty() { + existing_call.detail.changes = incoming_call.detail.changes.clone(); + } + if existing_call.title.trim().is_empty() { + existing_call.title = incoming_call.title.clone(); + } + if existing_call.summary.trim().is_empty() { + existing_call.summary = incoming_call.summary.clone(); + } + if existing_call.kind == "other" && incoming_call.kind != "other" { + existing_call.kind = incoming_call.kind.clone(); + } + existing_call.updated_at = existing_call.updated_at.max(incoming_call.updated_at); + existing_call.status = incoming_call.status.clone(); + } + if existing.text.is_none() { + existing.text = incoming.text; + } + if existing.role.is_none() { + existing.role = incoming.role; + } + if existing.turn_id.is_none() { + existing.turn_id = incoming.turn_id; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn root() -> &'static Path { + Path::new(".") + } + + #[test] + fn message_item_projects_role_and_text() { + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + None, + true, + 0, + ) + .expect("user entry"); + assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_MESSAGE); + assert_eq!(entry.role.as_deref(), Some("user")); + assert_eq!(entry.text.as_deref(), Some("做一个拼图游戏")); + assert_eq!(entry.turn_id.as_deref(), Some("turn-1")); + } + + #[test] + fn tool_item_carries_call_id_and_card() { + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + None, + true, + 1000, + ) + .expect("tool entry"); + assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_TOOL); + assert_eq!( + entry.call_id.as_deref(), + Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") + ); + assert_eq!(entry.identity(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + assert_eq!(entry.item_id, "05dc0af1-8023-47fd-ad22-d54df2837b1b"); + assert!(entry.tool_call.is_some()); + } + + #[test] + fn reasoning_item_projects_text_without_role() { + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "reason-1", + "type": "reasoning", + "content": [{"type": "output_text", "text": "先看目录"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("reasoning entry"); + assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_REASONING); + assert_eq!(entry.role, None); + assert_eq!(entry.text.as_deref(), Some("先看目录")); + } + + #[test] + fn non_chat_items_and_secrets_are_not_leaked() { + assert!(direct_chat_entry_from_item( + root(), + &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), + Some("turn-1"), + true, + 0, + ) + .is_none()); + + let entry = direct_chat_entry_from_item( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("assistant entry"); + let text = entry.text.unwrap_or_default(); + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + } + + #[test] + fn history_merges_call_input_and_output_into_one_card() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let entries = direct_chat_entries_from_history(root(), &items); + assert_eq!(entries.len(), 1, "同一调用只能有一张卡片"); + let card = entries[0].tool_call.as_ref().expect("card"); + assert_eq!(card.kind, "command"); + assert_eq!(card.title, "执行命令"); + assert_eq!(card.detail.command.as_deref(), Some("{\"cmd\": \"ls\"}")); + assert_eq!(card.detail.output.as_deref(), Some("assets\ngame")); + } +} 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 f72f37380..44a2b67ce 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 @@ -10,6 +10,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use uuid::Uuid; +use crate::agent::DirectChatEntry; + const DEFAULT_MAX_EVENTS: usize = 8_192; const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -71,6 +73,10 @@ pub(crate) struct DirectThreadHistorySlice { pub(crate) items: Vec, pub(crate) has_more: bool, pub(crate) item_timestamps: std::collections::BTreeMap, + /// 与运行态事件同形的聊天条目(顺序即文件顺序)。 + pub(crate) entries: Vec, + /// 本次切片的原始条目锚点:无论切片里有没有可显示条目,分页都要靠它继续向前。 + pub(crate) first_item_id: Option, } #[derive(Clone, Debug)] 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 ca97077f1..d941f1c14 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5383,10 +5383,18 @@ pub(crate) async fn read_direct_project_history_slice( before_item_id.as_deref(), limit.unwrap_or(20), )?; + let entries = direct_chat_entries_from_history(root, &items); + let first_item_id = items + .first() + .and_then(|item| item.get("id")) + .and_then(serde_json::Value::as_str) + .map(str::to_string); Ok(DirectThreadHistorySlice { items, has_more, item_timestamps, + entries, + first_item_id, }) }) .await -- 2.52.0 From ae0f9376c94fd5ca6862a5a28214012763f156c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:05:10 +0800 Subject: [PATCH 03/11] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20DirectProject=20?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=20reducer=20=E6=B7=B1=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - directThreadChat 把运行态事件与历史切片归并成同一份聊天条目,活动回合只由 turn.started/turn.completed 判定 - item.delta 载荷补 kind,reducer 不再靠 itemId 猜条目类型 - bootstrap 原子替换运行态并保留历史窗口,历史与运行态按 callId ?? itemId 去重合并 - 新增 5 条 reducer 单测 --- .../src/agent/codex_app_server/mod.rs | 7 +- .../project-workspace/directThreadChat.ts | 253 ++++++++++++++++++ .../tests/directThreadChat.test.ts | 170 ++++++++++++ 3 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts create mode 100644 apps/ai-game-creator-shell/tests/directThreadChat.test.ts 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 27f81fc6b..a5db418f6 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 @@ -3013,7 +3013,12 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id: Some(item_id.clone()), call_id: None, - payload: serde_json::json!({ "delta": delta.clone() }), + // 事件自足:增量也要说明它是哪类条目的正文, + // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 + payload: serde_json::json!({ + "delta": delta.clone(), + "kind": "message", + }), }, ); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts new file mode 100644 index 000000000..e2764e51e --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -0,0 +1,253 @@ +/** + * DirectProject 聊天 reducer:把运行态 raw event 与历史切片归并成同一份聊天条目。 + * + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。这里不判断哪些条目 + * 要显示,也不决定顺序策略之外的任何东西:顺序 = 历史文件顺序 + 运行态独有条目。 + */ + +import type { GameCreatorDirectToolCall } from '../../app/types'; +import type { + DirectThreadRawEvent, + DirectThreadSubscriptionBootstrap, +} from './directThreadEvents'; + +export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; + +/** 与 Rust `DirectChatEntry` 同形:运行态事件与历史切片共用的唯一形状。 */ +export type DirectChatEntry = { + itemId: string; + callId?: string | null; + turnId?: string | null; + kind: DirectChatEntryKind; + role?: 'user' | 'assistant' | null; + text?: string | null; + toolCall?: GameCreatorDirectToolCall | null; + at?: number; +}; + +export type DirectThreadChatState = { + subscriptionId: string | null; + /** 首屏历史锚点:`subscribe` 给出的最后一个完成条目 id。 */ + lastCompletedItemId: string | null; + /** 活动回合:只有出现 `turn.started` 且没有对应 `turn.completed` 才有值。 */ + activeTurnId: string | null; + /** 历史切片条目,保持文件顺序。 */ + history: DirectChatEntry[]; + /** 运行态条目,保持到达顺序。 */ + live: DirectChatEntry[]; + /** 流式正文累计(itemId → text)。 */ + deltaText: Record; +}; + +export function emptyDirectThreadChatState(): DirectThreadChatState { + return { + subscriptionId: null, + lastCompletedItemId: null, + activeTurnId: null, + history: [], + live: [], + deltaText: {}, + }; +} + +/** 合并身份:工具条目的 app-server itemId 与原始 item 的 callId 是同一个值。 */ +export function directChatEntryIdentity(entry: DirectChatEntry): string { + return (entry.callId ?? '').trim() || entry.itemId; +} + +function mergeToolCall( + left: GameCreatorDirectToolCall, + right: GameCreatorDirectToolCall, +): GameCreatorDirectToolCall { + return { + ...left, + ...right, + title: right.title?.trim() ? right.title : left.title, + summary: right.summary?.trim() ? right.summary : left.summary, + kind: right.kind && right.kind !== 'other' ? right.kind : left.kind, + detail: { + command: right.detail?.command ?? left.detail?.command, + output: right.detail?.output ?? left.detail?.output, + changes: right.detail?.changes?.length + ? right.detail.changes + : left.detail?.changes, + }, + startedAt: Math.min( + left.startedAt > 0 ? left.startedAt : right.startedAt, + right.startedAt > 0 ? right.startedAt : left.startedAt, + ), + }; +} + +/** 后到的快照只补空字段:结果快照不能抹掉先到的命令与标题。 */ +export function mergeDirectChatEntry( + existing: DirectChatEntry, + incoming: DirectChatEntry, +): DirectChatEntry { + const merged: DirectChatEntry = { + ...existing, + ...incoming, + itemId: existing.itemId || incoming.itemId, + kind: + incoming.kind === 'tool' || existing.kind === 'tool' + ? 'tool' + : incoming.kind, + text: incoming.text ?? existing.text, + role: incoming.role ?? existing.role, + turnId: incoming.turnId ?? existing.turnId, + }; + const left = existing.toolCall ?? null; + const right = incoming.toolCall ?? null; + merged.toolCall = + left && right ? mergeToolCall(left, right) : (right ?? left); + return merged; +} + +function upsertLiveEntry( + state: DirectThreadChatState, + entry: DirectChatEntry, +): DirectThreadChatState { + const identity = directChatEntryIdentity(entry); + const index = state.live.findIndex( + (existing) => directChatEntryIdentity(existing) === identity, + ); + if (index < 0) { + return { ...state, live: [...state.live, entry] }; + } + const existing = state.live[index]; + if (!existing) { + return { ...state, live: [...state.live, entry] }; + } + const live = [...state.live]; + live[index] = mergeDirectChatEntry(existing, entry); + return { ...state, live }; +} + +/** 事件载荷就是聊天条目;不是聊天条目(例如纯工具输出之外的内部 item)返回 null。 */ +export function directChatEntryFromEventPayload( + payload: unknown, +): DirectChatEntry | null { + if (!payload || typeof payload !== 'object') return null; + const entry = payload as DirectChatEntry; + if (typeof entry.itemId !== 'string' || !entry.itemId.trim()) return null; + if (!entry.kind) return null; + return entry; +} + +function reduceDirectThreadEvent( + state: DirectThreadChatState, + event: DirectThreadRawEvent, +): DirectThreadChatState { + switch (event.type) { + case 'turn.started': + return { ...state, activeTurnId: event.turnId }; + case 'turn.completed': + return { + ...state, + activeTurnId: + state.activeTurnId === event.turnId ? null : state.activeTurnId, + }; + case 'item.delta': { + const itemId = event.itemId?.trim(); + if (!itemId) return state; + const payload = event.payload as { delta?: unknown; kind?: unknown }; + const delta = typeof payload?.delta === 'string' ? payload.delta : ''; + if (!delta) return state; + const text = `${state.deltaText[itemId] ?? ''}${delta}`; + const reasoning = payload?.kind === 'reasoning'; + return upsertLiveEntry( + { ...state, deltaText: { ...state.deltaText, [itemId]: text } }, + { + itemId, + kind: reasoning ? 'reasoning' : 'message', + role: reasoning ? null : 'assistant', + turnId: event.turnId, + text, + }, + ); + } + case 'item.started': + case 'item.completed': { + const entry = directChatEntryFromEventPayload(event.payload); + if (!entry) return state; + return upsertLiveEntry(state, { + ...entry, + turnId: entry.turnId ?? event.turnId, + }); + } + default: + return state; + } +} + +export function reduceDirectThreadEvents( + state: DirectThreadChatState, + events: readonly DirectThreadRawEvent[], +): DirectThreadChatState { + return events.reduce(reduceDirectThreadEvent, state); +} + +/** bootstrap 是运行态的唯一权威:原子替换运行态,历史窗口保留。 */ +export function resolveDirectThreadBootstrap( + state: DirectThreadChatState, + bootstrap: DirectThreadSubscriptionBootstrap, +): DirectThreadChatState { + return reduceDirectThreadEvents( + { + ...emptyDirectThreadChatState(), + history: state.history, + subscriptionId: bootstrap.subscriptionId, + lastCompletedItemId: bootstrap.lastCompletedItemId ?? null, + }, + bootstrap.events, + ); +} + +/** 历史切片并入:重叠条目按身份去重,新切片在前、已有历史在后。 */ +export function mergeDirectHistoryEntries( + state: DirectThreadChatState, + entries: readonly DirectChatEntry[], +): DirectThreadChatState { + const byIdentity = new Map(); + const order: string[] = []; + for (const entry of [...entries, ...state.history]) { + const identity = directChatEntryIdentity(entry); + const existing = byIdentity.get(identity); + if (existing) { + byIdentity.set(identity, mergeDirectChatEntry(existing, entry)); + continue; + } + byIdentity.set(identity, entry); + order.push(identity); + } + return { + ...state, + history: order + .map((identity) => byIdentity.get(identity)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)), + }; +} + +/** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ +export function selectDirectChatEntries( + state: DirectThreadChatState, +): DirectChatEntry[] { + const liveByIdentity = new Map( + state.live.map((entry) => [directChatEntryIdentity(entry), entry]), + ); + const seen = new Set(); + const entries: DirectChatEntry[] = []; + for (const entry of state.history) { + const identity = directChatEntryIdentity(entry); + seen.add(identity); + const live = liveByIdentity.get(identity); + entries.push(live ? mergeDirectChatEntry(entry, live) : entry); + } + for (const entry of state.live) { + const identity = directChatEntryIdentity(entry); + if (seen.has(identity)) continue; + seen.add(identity); + entries.push(entry); + } + return entries; +} diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts new file mode 100644 index 000000000..912e645c7 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; + +import { + type DirectChatEntry, + directChatEntryIdentity, + emptyDirectThreadChatState, + mergeDirectHistoryEntries, + reduceDirectThreadEvents, + resolveDirectThreadBootstrap, + selectDirectChatEntries, +} from '../src/features/project-workspace/directThreadChat'; +import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents'; + +function event( + partial: Partial & + Pick, +): DirectThreadRawEvent { + return { seq: 1, payload: {}, ...partial }; +} + +function toolEntry( + overrides: Partial = {}, +): DirectChatEntry { + return { + itemId: 'call-1', + callId: 'call-1', + turnId: 'turn-1', + kind: 'tool', + toolCall: { + schemaVersion: 'agc-tool-call.v1', + id: 'call-1', + turnId: 'turn-1', + kind: 'command', + title: '执行命令', + summary: 'ls', + status: 'running', + detail: { command: 'ls', output: undefined, changes: [] }, + startedAt: 1000, + updatedAt: 1000, + }, + ...overrides, + }; +} + +describe('DirectProject 聊天 reducer', () => { + it('只有 turn.started 且未完成的回合才是活动回合', () => { + const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', turnId: 'turn-1' }), + ]); + expect(started.activeTurnId).toBe('turn-1'); + + const completed = reduceDirectThreadEvents(started, [ + event({ type: 'turn.completed', turnId: 'turn-1' }), + ]); + expect(completed.activeTurnId).toBeNull(); + }); + + it('bootstrap 原子替换运行态,没有生命周期事件就没有活动回合', () => { + const stale = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started', turnId: 'turn-old' }), + ]); + const bootstrapped = resolveDirectThreadBootstrap(stale, { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-9', + events: [], + }); + expect(bootstrapped.activeTurnId).toBeNull(); + expect(bootstrapped.subscriptionId).toBe('sub-1'); + expect(bootstrapped.lastCompletedItemId).toBe('msg-9'); + }); + + it('增量正文按条目累计,完成快照覆盖同一段', () => { + const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.delta', + turnId: 'turn-1', + itemId: 'msg-1', + payload: { delta: '你', kind: 'message' }, + }), + event({ + type: 'item.delta', + turnId: 'turn-1', + itemId: 'msg-1', + payload: { delta: '好', kind: 'message' }, + }), + ]); + expect(selectDirectChatEntries(streamed)).toHaveLength(1); + expect(selectDirectChatEntries(streamed)[0]?.text).toBe('你好'); + + const done = reduceDirectThreadEvents(streamed, [ + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'msg-1', + payload: { + itemId: 'msg-1', + turnId: 'turn-1', + kind: 'message', + role: 'assistant', + text: '你好,我是陶泥儿。', + }, + }), + ]); + const entries = selectDirectChatEntries(done); + expect(entries).toHaveLength(1); + expect(entries[0]?.text).toBe('你好,我是陶泥儿。'); + }); + + it('工具条目 started/completed 归并成一张卡片', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.started', + turnId: 'turn-1', + itemId: 'call-1', + payload: toolEntry(), + }), + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'call-1', + payload: { + ...toolEntry(), + toolCall: { + ...toolEntry().toolCall!, + status: 'completed', + detail: { command: 'ls', output: 'assets\ngame', changes: [] }, + updatedAt: 2000, + }, + }, + }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(entries[0]?.toolCall?.status).toBe('completed'); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[0]?.toolCall?.detail.command).toBe('ls'); + }); + + it('历史条目与运行态按 callId 合并,重复条目只出现一次', () => { + const historical = mergeDirectHistoryEntries(emptyDirectThreadChatState(), [ + toolEntry(), + { + itemId: 'msg-user', + kind: 'message', + role: 'user', + text: '做一个拼图游戏', + }, + ]); + const live = reduceDirectThreadEvents(historical, [ + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'call-1', + payload: { + ...toolEntry(), + itemId: 'call-1', + toolCall: { + ...toolEntry().toolCall!, + status: 'completed', + detail: { command: 'ls', output: 'assets', changes: [] }, + }, + }, + }), + ]); + const entries = selectDirectChatEntries(live); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.status).toBe('completed'); + expect(directChatEntryIdentity(entries[0]!)).toBe('call-1'); + }); +}); -- 2.52.0 From 2938a49cacf8df71e6b9f080504af81f7178dfe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:05:30 +0800 Subject: [PATCH 04/11] =?UTF-8?q?=E5=9B=BA=E5=8C=96=20DirectProject=20?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E7=9C=9F=E7=9B=B8=E6=BA=90=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主规范新增首屏锚点、生命周期锚点必进 bootstrap、事件自足与不提供单点快照四条契约 - 新增 ADR 记录单一事实源取舍、删除清单与待验证的 id 空间风险 - 新增里程碑与实施计划,CONTEXT.md 补项目对话历史/运行态事件/聊天投影词条 - 目标文档合同与文档索引同步 --- CONTEXT.md | 16 +++++++ docs/README.md | 1 + ...irectProject对话历史单一事实源-2026-09-16.md | 42 +++++++++++++++++ ...划】DirectProject聊天真相源收敛-2026-09-16.md | 38 +++++++++++++++ ...碑】DirectProject聊天真相源收敛-2026-09-16.md | 47 +++++++++++++++++++ ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 10 ++++ 6 files changed, 154 insertions(+) create mode 100644 docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md create mode 100644 docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md create mode 100644 docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md diff --git a/CONTEXT.md b/CONTEXT.md index 1351b1033..da2aca812 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -172,6 +172,20 @@ _Avoid_: 多步骤向导、完整规则编辑器、拖拽编辑器 Bark Battle 平台作品闭环按契约与领域规则、后端存储/API、最小前端纵切、投影体验、收口验证的顺序推进。 _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI +## 项目开发对话(DirectProject) + +**项目对话历史**: +AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。 +_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本 + +**运行态事件**: +Thread Manager 向订阅者推送的当前回合原始事件流,只服务运行期间与短期断线恢复,不替代项目对话历史。 +_Avoid_: 进度通知、快照轮询、第二套历史 + +**聊天投影**: +把项目对话历史条目与运行态事件转换成消息气泡和工具卡片的读取期转换;不持久化,也不构成事实源。 +_Avoid_: 投影缓存文件、已脱敏卡片库、第二套 reducer + ## Relationships - 一个 **汪汪声浪大作战** 单局包含多个 **有效声浪触发**。 @@ -206,3 +220,5 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI - “入口闭环”曾可能只指内部 demo 或单个详情 CTA;已解析为 **正式作品入口闭环**,不新增独立专区或活动页。 - “创作编辑”曾可能指多步骤向导或完整编辑器;已解析为 **轻配置编辑流程**,使用单页表单 + 预览卡片完成保存草稿、发布和发布后跳转作品详情。 - “实施顺序”曾可能按 UI 或功能并行发散;已解析为契约/领域规则先行,再做后端存储/API,随后打通最小前端纵切,最后补投影体验与收口验证。 +- “回合进度事件”曾同时指 Direct turn update 与 Thread Manager 运行态事件;已解析为 AGC 项目开发对话只保留 **运行态事件**。 +- “哪些消息可显示”曾可能由后端历史分页判断;已解析为可见性判断属于 **聊天投影**,后端只按原始条目分页,前端负责跳过不可显示条目并推进分页锚点。 diff --git a/docs/README.md b/docs/README.md index 6a6bc67eb..e1c925fda 100644 --- a/docs/README.md +++ b/docs/README.md @@ -34,6 +34,7 @@ - [AGC 客户端稳定版生命周期大切换](./【技术方案】AGC客户端稳定版生命周期大切换-2026-09-14.md):统一 operation、认证/Runner、项目入口、本地恢复和 dev-stack 身份边界。 - [策划会话 Runtime V2 接入与旧链路退役方案](./technical/【技术方案】策划会话RuntimeV2接入与旧链路退役-2026-09-03.md):新单 Agent 策划会话、GDD 策略、未来 MCP/Skill 兼容插槽、阶段任务与退役验收合同。 - [DirectProject Codex 原始历史与异常恢复](<./technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md>):原始 Responses item 持久化、线程注入与异常回合收尾。 +- [DirectProject 对话历史单一事实源](./adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md):AGC 项目开发对话只以项目对话历史与运行态事件为真相源,聊天投影不落盘。 - [GameAgent 对话工具调用卡片](./technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md):把右侧对话里的执行命令 / 写文件投影成 Codex 风格可折叠卡片,含采集、独立历史文件、事件字段与回读契约。 - [DirectProject 客户端 Skill 与 MCP 扩展导入方案](./technical/【技术方案】DirectProject客户端Skill与MCP扩展导入方案-2026-08-31.md):客户端扩展导入、按独立 Skill/MCP 拆分、命名、启用和启动时注入边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md new file mode 100644 index 000000000..3a65a47c1 --- /dev/null +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -0,0 +1,42 @@ +# 【ADR】DirectProject对话历史单一事实源-2026-09-16 + +状态:已接受 + +## 背景 + +AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件(实时)、`turn-stream.jsonl`(文本段与工具交替顺序)、`tool-calls.jsonl`(已脱敏工具卡片),重进页面时还要额外接管活动回合快照。同一段文本和同一张工具卡片因此存在多个来源,实时与回读会互相覆盖,恢复路径也只能靠"哪个源先到"决定。 + +`.agent/conversations/project.jsonl` 里的 Codex 原始条目本身已经带着顺序(`function_call` 与 `function_call_output` 按写入顺序落行),顺序信息并不是协议缺陷,而是在投影层被丢弃。 + +## 决策 + +- AGC 项目开发对话的持久事实源只有 **项目对话历史**(`.agent/conversations/project.jsonl` 的原始条目);消息文本、工具卡片和它们的先后顺序都从它派生。 +- 运行期间的回合状态只来自 **运行态事件**(Thread Manager 的 subscribe / consume / notify);`notify` 只做唤醒,不携带状态。 +- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;`turn-stream.jsonl` 与 `tool-calls.jsonl` 停止写入、停止读取,也不再提供读取命令。 +- 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。 +- 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 +- 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「条目 → 视图」投影函数;Rust 做的是机械投影,不是可见性判断。 +- 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 +- 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 +- `notify` 仍是唯一唤醒意图,但前端在已判定存在活动回合期间叠加低频 `consume` tick(1–2 秒)兜底,因为唤醒本身允许丢失。 +- 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 +- 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 +- 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整条目(同一套脱敏、截断、路径归一),前端按 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 +- 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。 +- 生命周期锚点独立于 replay 队列保存,`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 +- 删除范围包含 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)以及 `game-creator-direct-turn-update` 事件与两个投影文件模块;保留分页用的历史切片读取。 +- 审批与提问事件本次只作为同一条事件流 pass-through,不并入聊天 reducer 驱动的状态机,迁移面收敛在历史与运行态一致性上。 + +## 备选方案与取舍 + +1. **保留 `tool-calls.jsonl` 作为"读侧已脱敏"缓存**:省一次脱敏与截断,但它成为与项目对话历史并行的第二事实源,卡片状态与顺序会和实时事件分叉。选择按读取期投影,必要时在进程内缓存。 +2. **保留 Direct 回合事件作为实时传输**:迁移量小,但同一段文本仍有两条实时链路,reducer 必须处理互相覆盖,正是本次要消除的问题。 +3. **让后端分页按"可显示消息数"切片**:界面能少写循环,代价是 Rust 需要理解 UI 可见性,界面规则一变就要同步改后端。 + +## 影响 + +- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再读。 +- 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 +- 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 +- 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 +- 待实现前验证的风险:`item.started` 的条目 id 来自 app-server item,`item.completed` 的来自 raw response item,两者是否同一 id 空间必须先拿真实 app-server 会话核对;若不是同一 id 空间,活跃条目将永远收不到完成事件。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md new file mode 100644 index 000000000..035ff0cc6 --- /dev/null +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -0,0 +1,38 @@ +# 【实施计划】DirectProject 聊天真相源收敛 + +| 字段 | 值 | +| --- | --- | +| Milestone | `docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` | +| Status | ready | +| Owner | Codex | + +## 修改边界 + +- 允许修改:`agent/direct_thread_manager.rs`、`agent/codex_app_server/`、`agent/runtime_driver/entrypoints.rs`、`agent/direct_project_history.rs`、`main.rs` 命令注册、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 +- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、非 DirectProject Runtime、Codex durable thread 行为、审批弹层现有状态来源。 +- 保持 `.env` 未提交修改,不触碰个人配置。 + +## 实现顺序 + +1. 先用真实 app-server 会话核对 `item.started` 与 `item.completed` 的条目 id 是否同一 id 空间;不一致则先在适配层统一 id,再进入后续步骤。 +2. Thread Manager 事件自足化:`item.started` / `item.completed` 携带经同一套脱敏、截断、路径归一的完整条目,且与历史切片同形。 +3. 删除 Direct 回合事件发点、`direct_turn_stream.rs` / `direct_tool_calls.rs` 两个投影模块及其命令注册,保留历史切片读取与 `subscribe` / `consume` / notify。 +4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,notify 唤醒为主、活动回合期间低频 `consume` tick 兜底,过期后重新订阅并原子替换。 +5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,锚点按原始条目 id 推进,切片无可见条目时自动连拉(上限 5 页)。 +6. 删除前端 `directTurnStream` / `directToolCalls` 状态、活动回合快照接管与流排序回退分支,把聊天投影收敛成"条目 → 视图"一条路径。 +7. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 + +## 验证命令 + +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread_manager -- --nocapture` +2. `npx vitest run apps/ai-game-creator-shell/tests/directThreadEvents.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts` +3. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` +4. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 +5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` + +## 风险与回滚点 + +- 条目 id 空间不一致会让活跃条目永远收不到完成事件:这是第 1 步的显式前置验证,不通过不进入实现。 +- 事件 payload 变大(命令输出、文件变更明细):继续沿用既有截断上限,并观察 Thread Manager 单 thread 字节上限是否被提前触发。 +- 订阅过期与 notify 丢失:以重新 `subscribe` + 原子替换处理过期,以低频 tick 兜底丢失,两者都需要单测覆盖。 +- 回滚点:每一步都保持"新源可用即不依赖旧源"的中间态可回退;不允许出现新源未启用而旧源已删除的提交。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md new file mode 100644 index 000000000..f0b124e5a --- /dev/null +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -0,0 +1,47 @@ +# 【里程碑】DirectProject 聊天真相源收敛 + +| 字段 | 值 | +| --- | --- | +| Version | 1.0 | +| Status | proposed | +| Date | 2026-09-16 | +| Parent Spec | `docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md` | + +## 目标 + +AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。删除 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照接管这些并行真相源。 + +## 范围 + +- 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的完整脱敏条目,前端按条目身份合并运行中与完成态。 +- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,分页锚点取原始条目 id;不可显示条目由前端跳过并继续取页。 +- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定。 +- 删除不再被使用的运行时事件、持久投影文件读写、命令与前端状态。 + +## 不在范围内 + +- 审批、提问与用户输入请求的状态机迁移;本次事件只作同一条流的 pass-through。 +- 跨进程回合账本、按回合统计与持久 turn ledger。 +- 旧项目磁盘上既有投影文件的清理、迁移或回填。 +- 非 DirectProject 运行时、Codex durable thread 语义、SpacetimeDB 与 HTTP 契约。 + +## 依赖与前置条件 + +- Thread Manager 深模块、app-server 事件适配与 Tauri 桥接已存在。 +- 主规范中的生命周期锚点、事件自足与首屏锚点条款已生效。 +- 实现前需用真实 app-server 会话核对 `item.started` 与 `item.completed` 是否同一 id 空间。 + +## 验收标准 + +- [ ] 一次回合内,实时渲染的文本段与工具卡片顺序,与回合结束后重进项目看到的顺序一致。 +- [ ] 回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,且界面不显示忙碌态。 +- [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。 +- [ ] 历史分页在切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。 +- [ ] 文件系统不再新增 `turn-stream.jsonl` / `tool-calls.jsonl`;聊天内容只由项目对话历史与运行态事件重建。 +- [ ] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。 + +## 证据要求 + +- 自动化:Thread Manager 事件契约测试、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 +- 运行时:真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 +- 边界:订阅过期、事件重复与乱序、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index a2b266ecb..0c6a66d8b 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -98,6 +98,10 @@ readHistory(threadId, { beforeItemId?, limit }) -> { `notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。 +因为唤醒本身允许丢失,前端在「已判定存在活动回合」期间以低频 `consume` tick(1–2 秒)兜底;`consume` 自带 cursor,重复 tick 不会重复返回事件。 + +首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动或按钮继续向前分页。系统不提供返回整份对话历史的命令。 + ### 事件和顺序 Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号,前端不要求连续。事件 envelope 至少包含: @@ -116,6 +120,12 @@ Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号, 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 +前端 reducer 的活动回合判定只有一条:state 中存在「出现过 `turn.started` 且未出现对应 `turn.completed`」的 `turnId` 时,该 turn 才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 中没有这样的 `turnId`,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 + +生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 + +`item.started` 与 `item.completed` 必须携带与历史切片同形的完整条目(经同一套脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 + ### 队列、subscriber 和回收 每个 thread 一个 Vec-based append-only replay queue,使用逻辑 head 偏移清理前缀,不做中间删除。完成 item 的事件在持久化成功后才可进入普通 replay 回收流程;unfinished item 的事件必须保留到 item 完成,不能被普通上限截断。 -- 2.52.0 From 0ec1179bf1197fe2f3d3d277862560649d6dccdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:17:38 +0800 Subject: [PATCH 05/11] =?UTF-8?q?=E7=A7=BB=E9=99=A4=E5=89=8D=E7=AB=AF=20co?= =?UTF-8?q?nsume=20=E8=BD=AE=E8=AF=A2=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 唤醒只由 notify 驱动,bootstrap 后立刻 consume 一次补齐竞态窗口 - 定时 tick 无法自举:判断活动回合本身依赖事件,唤醒丢失时定时器不会启动 - 主规范、ADR 与实施计划同步删除该条款 --- docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md | 2 +- .../【实施计划】DirectProject聊天真相源收敛-2026-09-16.md | 4 ++-- ...术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 3a65a47c1..76e36bb03 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -18,7 +18,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「条目 → 视图」投影函数;Rust 做的是机械投影,不是可见性判断。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 -- `notify` 仍是唯一唤醒意图,但前端在已判定存在活动回合期间叠加低频 `consume` tick(1–2 秒)兜底,因为唤醒本身允许丢失。 +- `notify` 是唯一唤醒来源:前端在 bootstrap 之后立刻 `consume` 一次补齐竞态窗口,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 - 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 - 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整条目(同一套脱敏、截断、路径归一),前端按 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index 035ff0cc6..9815096d9 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -17,7 +17,7 @@ 1. 先用真实 app-server 会话核对 `item.started` 与 `item.completed` 的条目 id 是否同一 id 空间;不一致则先在适配层统一 id,再进入后续步骤。 2. Thread Manager 事件自足化:`item.started` / `item.completed` 携带经同一套脱敏、截断、路径归一的完整条目,且与历史切片同形。 3. 删除 Direct 回合事件发点、`direct_turn_stream.rs` / `direct_tool_calls.rs` 两个投影模块及其命令注册,保留历史切片读取与 `subscribe` / `consume` / notify。 -4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,notify 唤醒为主、活动回合期间低频 `consume` tick 兜底,过期后重新订阅并原子替换。 +4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,bootstrap 后立刻 consume 一次补齐竞态窗口,此后只由 notify 唤醒,过期后重新订阅并原子替换。 5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,锚点按原始条目 id 推进,切片无可见条目时自动连拉(上限 5 页)。 6. 删除前端 `directTurnStream` / `directToolCalls` 状态、活动回合快照接管与流排序回退分支,把聊天投影收敛成"条目 → 视图"一条路径。 7. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 @@ -34,5 +34,5 @@ - 条目 id 空间不一致会让活跃条目永远收不到完成事件:这是第 1 步的显式前置验证,不通过不进入实现。 - 事件 payload 变大(命令输出、文件变更明细):继续沿用既有截断上限,并观察 Thread Manager 单 thread 字节上限是否被提前触发。 -- 订阅过期与 notify 丢失:以重新 `subscribe` + 原子替换处理过期,以低频 tick 兜底丢失,两者都需要单测覆盖。 +- 订阅过期:以重新 `subscribe` + bootstrap 后立即 `consume` + 原子替换处理,需要单测覆盖;不引入定时轮询。 - 回滚点:每一步都保持"新源可用即不依赖旧源"的中间态可回退;不允许出现新源未启用而旧源已删除的提交。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 0c6a66d8b..1e66add9c 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -98,7 +98,7 @@ readHistory(threadId, { beforeItemId?, limit }) -> { `notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。 -因为唤醒本身允许丢失,前端在「已判定存在活动回合」期间以低频 `consume` tick(1–2 秒)兜底;`consume` 自带 cursor,重复 tick 不会重复返回事件。 +`subscribe` 把新 subscriber 的游标钉在当时的队尾,因此前端拿到 bootstrap 后必须**立刻 `consume` 一次**,补齐"订阅落定到首条事件"之间的竞态窗口;此后不再轮询,也不设任何定时 `consume`。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。 首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动或按钮继续向前分页。系统不提供返回整份对话历史的命令。 -- 2.52.0 From 656c89e4b2450b87826887b52d613672c3e8a9b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 18:45:13 +0800 Subject: [PATCH 06/11] =?UTF-8?q?=E6=8A=8A=20DirectProject=20=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=9D=A1=E7=9B=AE=E7=9A=84=E6=8A=95=E5=BD=B1=E4=BB=8E?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=E6=94=B6=E5=8F=A3=EF=BC=8CRust=20=E5=8F=AA?= =?UTF-8?q?=E6=90=AC=E8=BF=90=E8=84=B1=E6=95=8F=E5=8E=9F=E5=A7=8B=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 agent/direct_thread_raw_item.rs:把 Codex 原始 response item 与 app-server item 收敛成同一形状的脱敏原始条目,只做挑字段、脱敏、截断,不再生成卡片的 kind / 标题 / 折叠摘要 删除 agent/direct_chat_entry.rs:工具卡片形状不再由 Rust 生产 历史切片改为返回脱敏原始条目列表,不再在 Rust 侧合并 function_call 与 function_call_output 新增前端 directThreadItemProjection.ts:工具卡片 kind / 标题 / 摘要 / 状态与可见性判定全部在前端完成 directThreadChat reducer 改用同一投影函数处理运行态事件,新增 mergeDirectHistoryItems 走同一份投影与合并规则 DirectThreadHistorySlice 去掉 entries 与 itemTimestamps,改为 items + firstItemId --- .../src-tauri/src/agent.rs | 4 +- .../src/agent/codex_app_server/mod.rs | 34 +- .../src-tauri/src/agent/direct_chat_entry.rs | 523 ---------------- .../src/agent/direct_thread_manager.rs | 8 +- .../src/agent/direct_thread_raw_item.rs | 581 ++++++++++++++++++ .../src-tauri/src/agent/direct_tool_calls.rs | 132 +--- .../src-tauri/src/commands.rs | 9 +- apps/ai-game-creator-shell/src/App.tsx | 39 +- .../project-workspace/directThreadChat.ts | 38 +- .../project-workspace/directThreadEvents.ts | 42 +- .../directThreadItemProjection.ts | 281 +++++++++ .../tests/directThreadChat.test.ts | 185 ++++-- .../tests/directThreadEvents.test.ts | 27 +- 13 files changed, 1108 insertions(+), 795 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts 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 bf380a888..f3ca892e7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -14,7 +14,6 @@ mod codex_cli; mod codex_provider_proxy; mod design_runtime; mod design_tools; -mod direct_chat_entry; mod direct_codex_attachments; mod direct_codex_audit; mod direct_codex_user_item; @@ -22,6 +21,7 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; +mod direct_thread_raw_item; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; @@ -49,7 +49,6 @@ pub(crate) use codex_cli::{ }; pub(crate) use codex_provider_proxy::*; pub(crate) use design_runtime::*; -pub(crate) use direct_chat_entry::*; pub(crate) use direct_codex_attachments::*; pub(crate) use direct_codex_audit::*; pub(crate) use direct_codex_user_item::*; @@ -57,6 +56,7 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_thread_manager::*; +pub(crate) use direct_thread_raw_item::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; 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 a5db418f6..8b789f2e8 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 @@ -744,16 +744,18 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat /// Project an app-server item into the small public payload carried by the /// DirectProject event queue. Full item contents are persisted in JSONL and /// must not be forwarded through the runtime event stream. -/// 运行态事件载荷:与历史切片同形的聊天条目;拿不到条目时给空对象。 -fn direct_chat_entry_payload( +/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到条目时给空对象。 +/// +/// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。 +fn direct_thread_raw_item_payload( root: &std::path::Path, item: &serde_json::Value, turn_id: Option<&str>, completed: bool, now_ms: u64, ) -> serde_json::Value { - direct_chat_entry_from_item(root, item, turn_id, completed, now_ms) - .and_then(|entry| serde_json::to_value(entry).ok()) + direct_thread_raw_item_from_value(root, item, turn_id, completed, now_ms) + .and_then(|raw| serde_json::to_value(raw).ok()) .unwrap_or_else(|| serde_json::json!({})) } @@ -3062,7 +3064,7 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } - let entry_payload = direct_chat_entry_payload( + let entry_payload = direct_thread_raw_item_payload( history_root, &item, Some(turn_id.as_str()), @@ -3232,7 +3234,7 @@ impl CodexAppServerConnection { turn_id: turn_id.clone(), item_id, call_id: None, - payload: direct_chat_entry_payload( + payload: direct_thread_raw_item_payload( history_root, item, Some(turn_id.as_str()), @@ -4549,8 +4551,8 @@ mod tests { "result": { "content": "large output" } }); assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); - // 运行态事件必须自足:载荷是投影后的聊天条目,前端不需要再按 itemId 取快照。 - let payload = direct_chat_entry_payload( + // 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。 + let payload = direct_thread_raw_item_payload( std::path::Path::new("."), &item, Some("turn-1"), @@ -4558,8 +4560,8 @@ mod tests { 1000, ); assert_eq!( - payload.get("kind").and_then(serde_json::Value::as_str), - Some("tool") + payload.get("itemType").and_then(serde_json::Value::as_str), + Some("mcpToolCall") ); assert_eq!( payload.get("itemId").and_then(serde_json::Value::as_str), @@ -4569,7 +4571,17 @@ mod tests { payload.get("turnId").and_then(serde_json::Value::as_str), Some("turn-1") ); - assert!(payload.get("toolCall").is_some(), "{payload}"); + // 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。 + assert!(payload.get("toolCall").is_none(), "{payload}"); + assert!(payload.get("title").is_none(), "{payload}"); + assert!(payload.get("summary").is_none(), "{payload}"); + assert!(payload.get("kind").is_none(), "{payload}"); + // 参数里的密钥不得随载荷下发(脱敏占位符可以保留,明文不行)。 + let arguments = payload + .get("arguments") + .and_then(serde_json::Value::as_str) + .unwrap_or_default(); + assert!(!arguments.contains("\"secret\""), "{payload}"); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs deleted file mode 100644 index a348cddbd..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_chat_entry.rs +++ /dev/null @@ -1,523 +0,0 @@ -//! DirectProject 聊天条目投影。 -//! -//! 前端只消费一种形状:`DirectChatEntry`。运行态事件与历史切片都由这里把 -//! Codex 原始 response item / app-server thread item 投影成同一形状,并使用 -//! 同一套脱敏、截断与项目路径归一,避免实时与回读两套口径分叉。 -//! -//! 这里只做机械投影:不判断"哪些条目要显示",也不决定顺序策略——可见性与 -//! 排列属于前端聊天投影。 - -use super::direct_tool_calls::{direct_tool_call_from_item, sanitize_detail_text}; -use crate::DirectToolCall; -use serde::Serialize; -use serde_json::Value; -use std::path::Path; - -pub(crate) const DIRECT_CHAT_ENTRY_KIND_MESSAGE: &str = "message"; -pub(crate) const DIRECT_CHAT_ENTRY_KIND_REASONING: &str = "reasoning"; -pub(crate) const DIRECT_CHAT_ENTRY_KIND_TOOL: &str = "tool"; - -/// 单条条目文本上限:与回合流同一口径,避免单条正文无界。 -const DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS: usize = 8000; -/// 工具输入/输出上限:与卡片明细同口径。 -const DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS: usize = 4000; - -/// 前端唯一消费的聊天条目。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectChatEntry { - /// 稳定身份:`project.jsonl` 里的 response item id(分页锚点也用它)。 - pub(crate) item_id: String, - /// 工具条目的调用 id:app-server `item.started` 的 `itemId` 与原始 item 的 `call_id`。 - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) turn_id: Option, - /// `message` | `reasoning` | `tool` - pub(crate) kind: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) role: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) tool_call: Option, - /// 条目自带的毫秒时间;拿不到就是 0,由调用方用文件记录时间补齐。 - pub(crate) at: u64, -} - -impl DirectChatEntry { - /// 合并身份:前端按 `callId ?? itemId` 归并实时与历史里的同一条目。 - pub(crate) fn identity(&self) -> &str { - self.call_id.as_deref().unwrap_or(&self.item_id) - } -} - -fn bounded(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 item_text(item: &Value) -> Option { - if let Some(text) = item.get("text").and_then(Value::as_str) { - if !text.trim().is_empty() { - return Some(text.to_string()); - } - } - for key in ["content", "summary"] { - let Some(parts) = item.get(key).and_then(Value::as_array) else { - continue; - }; - let joined = parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::>() - .join(""); - if !joined.trim().is_empty() { - return Some(joined); - } - } - None -} - -fn item_turn_id(item: &Value) -> Option { - let from_metadata = item - .get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if from_metadata.is_some() { - return from_metadata; - } - // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 - let rest = item - .get("id") - .and_then(Value::as_str)? - .trim() - .strip_prefix("direct-codex:")?; - let turn = rest.rsplit_once(':')?.0.trim(); - (!turn.is_empty()).then(|| turn.to_string()) -} - -fn item_at_ms(item: &Value) -> u64 { - item.get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("create_time")) - .and_then(Value::as_f64) - .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) - .unwrap_or_default() -} - -fn item_identity(item: &Value) -> Option<(String, Option)> { - let call_id = item - .get("call_id") - .or_else(|| item.get("callId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let id = item - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - match (id, call_id) { - (Some(id), call_id) => Some((id, call_id)), - (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), - (None, None) => None, - } -} - -fn raw_tool_kind(name: &str) -> &'static str { - match name { - "exec_command" | "shell" | "exec" => "command", - "apply_patch" | "write_file" | "edit_file" | "create_file" => "file_change", - "web_search" | "web_search_preview" => "web_search", - _ => "mcp_tool", - } -} - -fn raw_tool_title(kind: &str) -> &'static str { - match kind { - "command" => "执行命令", - "file_change" => "编辑文件", - "web_search" => "联网检索", - "mcp_tool" => "调用工具", - _ => "工具调用", - } -} - -fn stringified(value: &Value) -> Option { - match value { - Value::Null => None, - Value::String(text) => (!text.trim().is_empty()).then(|| text.clone()), - other => serde_json::to_string(other).ok(), - } -} - -fn first_line(value: &str, max_chars: usize) -> String { - bounded(value.lines().next().unwrap_or_default().trim(), max_chars) -} - -/// 原始 response item 词汇里的工具条目:`function_call` 带输入,`function_call_output` 带输出。 -/// 两者共用 `call_id`,前端与历史投影都按它归并成同一张卡片。 -fn raw_tool_entry( - root: &Path, - item: &Value, - item_id: String, - call_id: Option, - turn_id: Option, - at: u64, - completed: bool, - now_ms: u64, -) -> Option { - let item_type = item.get("type").and_then(Value::as_str)?; - let call_id = call_id?; - let (kind, title, summary, detail) = match item_type { - "function_call" => { - let name = item.get("name").and_then(Value::as_str).unwrap_or_default(); - let arguments = item - .get("arguments") - .and_then(Value::as_str) - .unwrap_or_default(); - let kind = raw_tool_kind(name); - let command = bounded( - &sanitize_detail_text(root, arguments), - DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, - ); - let summary = first_line(&command, 120); - ( - kind, - raw_tool_title(kind).to_string(), - summary, - crate::DirectToolCallDetail { - command: Some(command), - output: None, - changes: Vec::new(), - }, - ) - } - "function_call_output" => { - let output = item.get("output").and_then(stringified).map(|output| { - bounded( - &sanitize_detail_text(root, &output), - DIRECT_CHAT_ENTRY_TOOL_TEXT_MAX_CHARS, - ) - }); - ( - "other", - String::new(), - String::new(), - crate::DirectToolCallDetail { - command: None, - output, - changes: Vec::new(), - }, - ) - } - _ => return None, - }; - let status = if completed { "completed" } else { "running" }; - let started_at = if at > 0 { at } else { now_ms }; - Some(DirectChatEntry { - item_id, - call_id: Some(call_id.clone()), - turn_id: turn_id.clone(), - kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), - role: None, - text: None, - tool_call: Some(DirectToolCall { - schema_version: "agc-tool-call.v1".to_string(), - id: call_id, - turn_id: turn_id.unwrap_or_default(), - kind: kind.to_string(), - title, - summary, - status: status.to_string(), - detail, - started_at, - updated_at: if at > 0 { at } else { now_ms }, - }), - at, - }) -} - -/// 把一条 Codex 条目投影成聊天条目;不属于聊天内容的条目(如 `function_call_output`)返回 `None`。 -pub(crate) fn direct_chat_entry_from_item( - root: &Path, - item: &Value, - turn_id: Option<&str>, - completed: bool, - now_ms: u64, -) -> Option { - let (item_id, call_id) = item_identity(item)?; - let turn_id = turn_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(|| item_turn_id(item)); - let at = item_at_ms(item); - - if let Some(entry) = raw_tool_entry( - root, - item, - item_id.clone(), - call_id.clone(), - turn_id.clone(), - at, - completed, - now_ms, - ) { - return Some(entry); - } - - if let Some(tool_call) = direct_tool_call_from_item( - root, - item, - turn_id.as_deref().unwrap_or_default(), - completed, - now_ms, - ) { - let call_id = call_id.or_else(|| Some(tool_call.id.clone())); - return Some(DirectChatEntry { - item_id, - call_id, - turn_id, - kind: DIRECT_CHAT_ENTRY_KIND_TOOL.to_string(), - role: None, - text: None, - tool_call: Some(tool_call), - at, - }); - } - - let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default(); - let (kind, role) = if item_type == "reasoning" { - (DIRECT_CHAT_ENTRY_KIND_REASONING, None) - } else { - let role = item.get("role").and_then(Value::as_str)?; - if !matches!(role, "user" | "assistant") { - return None; - } - (DIRECT_CHAT_ENTRY_KIND_MESSAGE, Some(role.to_string())) - }; - let text = item_text(item)?; - let text = bounded( - &sanitize_detail_text(root, &text), - DIRECT_CHAT_ENTRY_TEXT_MAX_CHARS, - ); - Some(DirectChatEntry { - item_id, - call_id, - turn_id, - kind: kind.to_string(), - role, - text: Some(text), - tool_call: None, - at, - }) -} - -/// 历史切片投影:文件顺序就是条目顺序。 -/// 同一调用的输入(`function_call`)与输出(`function_call_output`)在这里并成一张卡片。 -pub(crate) fn direct_chat_entries_from_history( - root: &Path, - items: &[Value], -) -> Vec { - let now_ms = super::direct_tool_calls::direct_tool_call_now_ms(); - let mut entries: Vec = Vec::new(); - for item in items { - let Some(entry) = direct_chat_entry_from_item(root, item, None, true, now_ms) else { - continue; - }; - match entries - .iter_mut() - .find(|existing| existing.identity() == entry.identity()) - { - Some(existing) => merge_chat_entry(existing, entry), - None => entries.push(entry), - } - } - entries -} - -/// 同一条目的后续快照只补空字段:后到的输出不能抹掉先到的命令与标题。 -fn merge_chat_entry(existing: &mut DirectChatEntry, incoming: DirectChatEntry) { - if let (Some(existing_call), Some(incoming_call)) = - (existing.tool_call.as_mut(), incoming.tool_call.as_ref()) - { - existing_call.detail.command = existing_call - .detail - .command - .take() - .or_else(|| incoming_call.detail.command.clone()); - existing_call.detail.output = existing_call - .detail - .output - .take() - .or_else(|| incoming_call.detail.output.clone()); - if existing_call.detail.changes.is_empty() { - existing_call.detail.changes = incoming_call.detail.changes.clone(); - } - if existing_call.title.trim().is_empty() { - existing_call.title = incoming_call.title.clone(); - } - if existing_call.summary.trim().is_empty() { - existing_call.summary = incoming_call.summary.clone(); - } - if existing_call.kind == "other" && incoming_call.kind != "other" { - existing_call.kind = incoming_call.kind.clone(); - } - existing_call.updated_at = existing_call.updated_at.max(incoming_call.updated_at); - existing_call.status = incoming_call.status.clone(); - } - if existing.text.is_none() { - existing.text = incoming.text; - } - if existing.role.is_none() { - existing.role = incoming.role; - } - if existing.turn_id.is_none() { - existing.turn_id = incoming.turn_id; - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::path::Path; - - fn root() -> &'static Path { - Path::new(".") - } - - #[test] - fn message_item_projects_role_and_text() { - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "direct-codex:turn-1:user", - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "做一个拼图游戏"}], - }), - None, - true, - 0, - ) - .expect("user entry"); - assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_MESSAGE); - assert_eq!(entry.role.as_deref(), Some("user")); - assert_eq!(entry.text.as_deref(), Some("做一个拼图游戏")); - assert_eq!(entry.turn_id.as_deref(), Some("turn-1")); - } - - #[test] - fn tool_item_carries_call_id_and_card() { - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, - }), - None, - true, - 1000, - ) - .expect("tool entry"); - assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_TOOL); - assert_eq!( - entry.call_id.as_deref(), - Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") - ); - assert_eq!(entry.identity(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); - assert_eq!(entry.item_id, "05dc0af1-8023-47fd-ad22-d54df2837b1b"); - assert!(entry.tool_call.is_some()); - } - - #[test] - fn reasoning_item_projects_text_without_role() { - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "reason-1", - "type": "reasoning", - "content": [{"type": "output_text", "text": "先看目录"}], - }), - Some("turn-1"), - true, - 0, - ) - .expect("reasoning entry"); - assert_eq!(entry.kind, DIRECT_CHAT_ENTRY_KIND_REASONING); - assert_eq!(entry.role, None); - assert_eq!(entry.text.as_deref(), Some("先看目录")); - } - - #[test] - fn non_chat_items_and_secrets_are_not_leaked() { - assert!(direct_chat_entry_from_item( - root(), - &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), - Some("turn-1"), - true, - 0, - ) - .is_none()); - - let entry = direct_chat_entry_from_item( - root(), - &json!({ - "id": "msg-1", - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop"}], - }), - Some("turn-1"), - true, - 0, - ) - .expect("assistant entry"); - let text = entry.text.unwrap_or_default(); - assert!( - !text.contains("sk-abcdefghijklmnop"), - "不得泄漏明文密钥:{text}" - ); - } - - #[test] - fn history_merges_call_input_and_output_into_one_card() { - let items = vec![ - json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - }), - json!({ - "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call_output", - "output": "assets\ngame\n", - }), - ]; - let entries = direct_chat_entries_from_history(root(), &items); - assert_eq!(entries.len(), 1, "同一调用只能有一张卡片"); - let card = entries[0].tool_call.as_ref().expect("card"); - assert_eq!(card.kind, "command"); - assert_eq!(card.title, "执行命令"); - assert_eq!(card.detail.command.as_deref(), Some("{\"cmd\": \"ls\"}")); - assert_eq!(card.detail.output.as_deref(), Some("assets\ngame")); - } -} 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 44a2b67ce..20c668a11 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 @@ -10,7 +10,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use uuid::Uuid; -use crate::agent::DirectChatEntry; +use crate::agent::DirectThreadRawItem; const DEFAULT_MAX_EVENTS: usize = 8_192; const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -70,11 +70,9 @@ pub(crate) struct DirectThreadConsumeResult { #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub(crate) struct DirectThreadHistorySlice { - pub(crate) items: Vec, + /// 脱敏原始条目,顺序即文件顺序。与运行态事件载荷同形,前端只有一套投影。 + pub(crate) items: Vec, pub(crate) has_more: bool, - pub(crate) item_timestamps: std::collections::BTreeMap, - /// 与运行态事件同形的聊天条目(顺序即文件顺序)。 - pub(crate) entries: Vec, /// 本次切片的原始条目锚点:无论切片里有没有可显示条目,分页都要靠它继续向前。 pub(crate) first_item_id: Option, } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs new file mode 100644 index 000000000..2dc96f3fe --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs @@ -0,0 +1,581 @@ +//! DirectProject 「原始条目」搬运层。 +//! +//! Thread Manager 只做搬运:这里把 Codex 原始 response item 与 app-server thread item +//! 收敛成同一形状的**脱敏原始条目**,只做三件事——挑字段、脱敏、截断。 +//! +//! 这里不生成工具卡片,不推导标题 / 折叠摘要 / 可见性 / 排序:`title`、`summary`、 +//! `kind` 和"哪些条目要显示"全部是前端投影的职责。运行态事件与历史切片共用本形状, +//! 前端因此只需要一套投影与一套合并规则。 + +use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; +use crate::redact_absolute_path_tokens; +use serde::Serialize; +use serde_json::Value; +use std::path::Path; + +/// 正文(消息 / 思考)上限。 +const DIRECT_THREAD_RAW_TEXT_MAX_CHARS: usize = 8000; +/// 工具明细(命令 / 参数 / 输出)上限。 +const DIRECT_THREAD_RAW_DETAIL_MAX_CHARS: usize = 4000; +/// 单条变更路径上限。 +const DIRECT_THREAD_RAW_PATH_MAX_CHARS: usize = 300; + +/// 原始条目里的一条文件变更。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectThreadRawChange { + pub(crate) path: String, + /// 原始 kind:`add` | `update` | `delete`(缺失时为 `update`)。 + pub(crate) kind: String, +} + +/// 前端唯一消费的条目形状:脱敏、截断后的 Codex 原始条目。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct DirectThreadRawItem { + /// `project.jsonl` 里的 response item id;app-server `item/started` 时是 app-server item id。 + pub(crate) item_id: String, + /// 工具条目的调用 id:两个 id 空间唯一对齐点(原始 item 的 `call_id`)。 + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) call_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) turn_id: Option, + /// Codex 原始 `type`:`message` / `reasoning` / `function_call` / `function_call_output` / + /// `commandExecution` / `fileChange` / `mcpToolCall` / `webSearch` / …(原样透传,不做映射)。 + pub(crate) item_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) role: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) arguments: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) output: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) tool: Option, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub(crate) changes: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) item_status: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub(crate) success: Option, + /// 协议事实:来自 `item/completed` / `rawResponseItem/completed` 为 true。 + pub(crate) completed: bool, + /// 毫秒时间;条目自带时间与记录时间都拿不到时为 0。 + pub(crate) at: u64, +} + +fn bounded(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 +} + +/// 原始值转文本:字符串原样,其它 JSON 值序列化。 +fn value_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + other => serde_json::to_string_pretty(other).ok(), + } +} + +fn detail_text(root: &Path, value: &str) -> String { + bounded( + &sanitize_detail_text(root, value), + DIRECT_THREAD_RAW_DETAIL_MAX_CHARS, + ) +} + +fn item_text(root: &Path, item: &Value) -> Option { + let raw = item + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None + }) + .filter(|text| !text.trim().is_empty())?; + Some(bounded( + &sanitize_detail_text(root, &raw), + DIRECT_THREAD_RAW_TEXT_MAX_CHARS, + )) +} + +fn item_turn_id(item: &Value) -> Option { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if from_metadata.is_some() { + return from_metadata; + } + // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 + let rest = item + .get("id") + .and_then(Value::as_str)? + .trim() + .strip_prefix("direct-codex:")?; + let turn = rest.rsplit_once(':')?.0.trim(); + (!turn.is_empty()).then(|| turn.to_string()) +} + +fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default(); + if from_metadata > 0 { + return from_metadata; + } + for key in ["startedAtMs", "completedAtMs"] { + let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); + if value > 0 { + return value; + } + } + observed_at_ms +} + +fn item_identity(item: &Value) -> Option<(String, Option)> { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + match (id, call_id) { + (Some(id), call_id) => Some((id, call_id)), + (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), + (None, None) => None, + } +} + +fn item_changes(root: &Path, 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(DirectThreadRawChange { + path: bounded( + &sanitize_detail_text(root, path), + DIRECT_THREAD_RAW_PATH_MAX_CHARS, + ), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +/// 把一条 Codex 条目搬运成脱敏原始条目;拿不到身份或类型时返回 `None`。 +/// +/// `observed_at_ms` 只作为条目自带时间缺失时的兜底(运行态用当前时间,历史用文件记录时间)。 +pub(crate) fn direct_thread_raw_item_from_value( + root: &Path, + item: &Value, + turn_id: Option<&str>, + completed: bool, + observed_at_ms: u64, +) -> Option { + if !item.is_object() { + return None; + } + let (item_id, call_id) = item_identity(item)?; + let item_type = item + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())? + .to_string(); + let turn_id = turn_id + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .or_else(|| item_turn_id(item)); + let at = item_at_ms(item, observed_at_ms); + + let text = match item_type.as_str() { + "message" | "agentMessage" | "userMessage" | "reasoning" => item_text(root, item), + _ => None, + }; + let arguments = item + .get("arguments") + .and_then(value_text) + .map(|value| detail_text(root, &value)); + let output = ["output", "aggregatedOutput", "result", "error"] + .iter() + .find_map(|key| item.get(key).and_then(value_text)) + .map(|value| detail_text(root, &value)); + let command = item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| detail_text(root, command)); + + Some(DirectThreadRawItem { + item_id, + call_id, + turn_id, + item_type, + role: item + .get("role") + .and_then(Value::as_str) + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(str::to_string), + text, + name: item + .get("name") + .and_then(Value::as_str) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string), + arguments, + output, + command, + tool: item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)), + changes: item_changes(root, item), + item_status: item + .get("status") + .and_then(Value::as_str) + .map(str::trim) + .filter(|status| !status.is_empty()) + .map(str::to_string), + exit_code: item.get("exitCode").and_then(Value::as_i64), + success: item.get("success").and_then(Value::as_bool), + completed, + at, + }) +} + +/// 历史切片搬运:保持文件顺序,不做任何合并。 +/// +/// 同一调用的 `function_call` 与 `function_call_output` 是两条原始条目,合并属于前端。 +/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 +pub(crate) fn direct_thread_raw_items_from_history( + root: &Path, + items: &[Value], + timestamp_of: impl Fn(&Value) -> u64, +) -> Vec { + items + .iter() + .filter_map(|item| { + direct_thread_raw_item_from_value(root, item, None, true, timestamp_of(item)) + }) + .collect() +} + +/// 项目根目录之后的路径 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,再处理密钥。 +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) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn root() -> &'static Path { + Path::new(".") + } + + #[test] + fn message_item_carries_role_text_and_turn() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + None, + true, + 0, + ) + .expect("user item"); + assert_eq!(item.item_type, "message"); + assert_eq!(item.role.as_deref(), Some("user")); + assert_eq!(item.text.as_deref(), Some("做一个拼图游戏")); + assert_eq!(item.turn_id.as_deref(), Some("turn-1")); + } + + #[test] + fn tool_item_keeps_call_id_and_raw_command() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + None, + true, + 1000, + ) + .expect("tool item"); + assert_eq!(item.item_type, "function_call"); + assert_eq!( + item.call_id.as_deref(), + Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") + ); + assert_eq!(item.name.as_deref(), Some("exec_command")); + assert_eq!(item.arguments.as_deref(), Some("{\"cmd\": \"ls\"}")); + assert_eq!(item.turn_id.as_deref(), Some("turn-1")); + } + + #[test] + fn reasoning_item_keeps_text_without_role() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "reason-1", + "type": "reasoning", + "content": [{"type": "output_text", "text": "先看目录"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("reasoning item"); + assert_eq!(item.item_type, "reasoning"); + assert_eq!(item.role, None); + assert_eq!(item.text.as_deref(), Some("先看目录")); + } + + #[test] + fn secrets_and_absolute_paths_are_not_leaked() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], + }), + Some("turn-1"), + true, + 0, + ) + .expect("assistant item"); + let text = item.text.unwrap_or_default(); + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); + } + + #[test] + fn history_keeps_call_and_output_as_two_raw_items() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let projected = direct_thread_raw_items_from_history(root(), &items, |_| 0); + assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); + assert_eq!(projected[0].item_type, "function_call"); + assert_eq!(projected[1].item_type, "function_call_output"); + assert_eq!( + projected[0].call_id.as_deref(), + projected[1].call_id.as_deref() + ); + } + + #[test] + fn system_role_is_passed_through_for_frontend_to_filter() { + let item = direct_thread_raw_item_from_value( + root(), + &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), + Some("turn-1"), + true, + 0, + ) + .expect("system item"); + assert_eq!(item.role.as_deref(), Some("system")); + assert_eq!(item.item_type, "message"); + } +} 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 82e9989e8..545b1fa11 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 @@ -8,11 +8,9 @@ //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 -use crate::agent::redact_secret_tokens; -use crate::agent::sanitize_error_context; +use super::direct_thread_raw_item::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 crate::redact_absolute_path_tokens; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; @@ -86,134 +84,6 @@ 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 { 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 d941f1c14..8dbdc185b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5383,17 +5383,20 @@ pub(crate) async fn read_direct_project_history_slice( before_item_id.as_deref(), limit.unwrap_or(20), )?; - let entries = direct_chat_entries_from_history(root, &items); let first_item_id = items .first() .and_then(|item| item.get("id")) .and_then(serde_json::Value::as_str) .map(str::to_string); + let items = direct_thread_raw_items_from_history(root, &items, |item| { + item.get("id") + .and_then(serde_json::Value::as_str) + .and_then(|id| item_timestamps.get(id).copied()) + .unwrap_or_default() + }); Ok(DirectThreadHistorySlice { items, has_more, - item_timestamps, - entries, first_item_id, }) }) diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index ee075cfa5..5fc8d07e0 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -4101,6 +4101,7 @@ export function App({ : await readProjectSupervisorActiveSession(invoke, nextProjectPath); let runtimeError = ''; let loadedDirectHistoryHasMore = false; + let loadedDirectHistoryFirstItemId: string | null = null; const projectConversation = directCodexProductRuntime ? (() => { return invoke( @@ -4111,13 +4112,11 @@ export function App({ }, ).then((slice) => { loadedDirectHistoryHasMore = slice.hasMore; + loadedDirectHistoryFirstItemId = slice.firstItemId; return { path: nextProjectPath, agentId: null, - messages: directThreadHistoryItemsToMessages( - slice.items, - slice.itemTimestamps, - ), + messages: directThreadHistoryItemsToMessages(slice.items), } satisfies LocalConversationResult; }); })() @@ -4214,9 +4213,7 @@ export function App({ setProjectSupervisorRuntimeError(runtimeError || resumeError); if (directCodexProductRuntime) { setDirectHistoryHasMore(loadedDirectHistoryHasMore); - directHistoryOldestItemIdRef.current = - conversationMessages.find((message) => message.messageId) - ?.messageId ?? null; + directHistoryOldestItemIdRef.current = loadedDirectHistoryFirstItemId; } setMessages((current) => { // replace 分支同样不能丢掉尚未落盘的运行时消息(初始需求)。 @@ -12493,25 +12490,23 @@ export function App({ if (localProjectPathRef.current !== projectPath) { return; } - 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, - })); + 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, + }), + ); setMessages((current) => [...older, ...current]); setConversationVisibleCount((current) => current + older.length); setDirectHistoryHasMore(slice.hasMore); directHistoryOldestItemIdRef.current = - older.find((message) => message.messageId)?.messageId ?? - directHistoryOldestItemIdRef.current; + slice.firstItemId ?? directHistoryOldestItemIdRef.current; } catch (error) { setWorkspaceStatus( `读取更早的对话历史失败:${error instanceof Error ? error.message : String(error)}`, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts index e2764e51e..ab5de6c00 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -10,6 +10,11 @@ import type { DirectThreadRawEvent, DirectThreadSubscriptionBootstrap, } from './directThreadEvents'; +import { + type DirectThreadRawItem, + isDirectThreadRawItem, + projectDirectThreadRawItem, +} from './directThreadItemProjection'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; @@ -123,17 +128,6 @@ function upsertLiveEntry( return { ...state, live }; } -/** 事件载荷就是聊天条目;不是聊天条目(例如纯工具输出之外的内部 item)返回 null。 */ -export function directChatEntryFromEventPayload( - payload: unknown, -): DirectChatEntry | null { - if (!payload || typeof payload !== 'object') return null; - const entry = payload as DirectChatEntry; - if (typeof entry.itemId !== 'string' || !entry.itemId.trim()) return null; - if (!entry.kind) return null; - return entry; -} - function reduceDirectThreadEvent( state: DirectThreadChatState, event: DirectThreadRawEvent, @@ -168,7 +162,9 @@ function reduceDirectThreadEvent( } case 'item.started': case 'item.completed': { - const entry = directChatEntryFromEventPayload(event.payload); + const entry = isDirectThreadRawItem(event.payload) + ? projectDirectThreadRawItem(event.payload) + : null; if (!entry) return state; return upsertLiveEntry(state, { ...entry, @@ -228,6 +224,24 @@ export function mergeDirectHistoryEntries( }; } +/** + * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 + * + * `items` 必须按文件顺序给出;同一调用的 `function_call` 与 `function_call_output` + * 在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 + */ +export function mergeDirectHistoryItems( + state: DirectThreadChatState, + items: readonly DirectThreadRawItem[], +): DirectThreadChatState { + return mergeDirectHistoryEntries( + state, + items + .map((item) => projectDirectThreadRawItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)), + ); +} + /** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ export function selectDirectChatEntries( state: DirectThreadChatState, diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 6c1cad229..78b8af25d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,4 +1,5 @@ import type { LocalConversationMessageRecord } from '../../app/types'; +import type { DirectThreadRawItem } from './directThreadItemProjection'; export type DirectThreadRawEvent = { seq: number; @@ -19,33 +20,36 @@ export type DirectThreadConsumeResult = { }; export type DirectThreadHistorySlice = { - items: unknown[]; + /** 脱敏原始条目,顺序即文件顺序;与运行态事件载荷同形。 */ + items: DirectThreadRawItem[]; hasMore: boolean; - itemTimestamps?: Record; + /** 本次切片的原始条目锚点:分页继续向前靠它,而不是靠"有没有可显示条目"。 */ + firstItemId: string | null; }; +/** + * 历史条目转聊天消息。 + * + * 迁移期的过渡函数:只保留 `role ∈ {user, assistant}` 的文本条目,工具卡片与交替顺序 + * 由 `directThreadChat` 的 reducer 投影。App.tsx 切换到 reducer 后本函数删除。 + */ export function directThreadHistoryItemsToMessages( - items: unknown[], - itemTimestamps: Readonly> = {}, + items: readonly DirectThreadRawItem[], ): LocalConversationMessageRecord[] { - return items.flatMap((raw) => { - if (!raw || typeof raw !== 'object') return []; - const item = raw as Record; + return items.flatMap((item) => { + if ( + item.itemType !== 'message' && + item.itemType !== 'agentMessage' && + item.itemType !== 'userMessage' + ) { + return []; + } const role = item.role; if (role !== 'user' && role !== 'assistant') return []; const messageRole = role as 'user' | 'assistant'; - const content = Array.isArray(item.content) - ? item.content - .map((part) => - part && typeof part === 'object' && 'text' in part - ? (part as { text?: unknown }).text - : null, - ) - .filter((text): text is string => typeof text === 'string') - .join('') - : ''; + const content = typeof item.text === 'string' ? item.text : ''; if (!content) return []; - const messageId = typeof item.id === 'string' ? item.id : undefined; + const messageId = item.itemId; return [ { schemaVersion: 'agc-direct-project-context.v1', @@ -53,7 +57,7 @@ export function directThreadHistoryItemsToMessages( content, agentId: null, messageId, - updatedAt: messageId ? (itemTimestamps[messageId] ?? 0) : 0, + updatedAt: item.at ?? 0, }, ]; }); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts new file mode 100644 index 000000000..5ff47d33c --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -0,0 +1,281 @@ +/** + * DirectProject「原始条目 → 聊天条目」投影。 + * + * Rust 只搬运脱敏后的 Codex 原始条目(消息 / 思考 / 工具调用),工具卡片的 + * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片 + * 走同一个函数,因此实时与回读不可能出现两套口径。 + */ + +import type { + GameCreatorDirectToolCall, + GameCreatorDirectToolCallChange, + GameCreatorDirectToolCallKind, + GameCreatorDirectToolCallStatus, +} from '../../app/types'; +import type { DirectChatEntry } from './directThreadChat'; + +/** 一个片段里的文件变更(与 Rust `DirectThreadRawChange` 同形)。 */ +export type DirectThreadRawChange = { + path: string; + kind: string; +}; + +/** Rust 搬运的脱敏原始条目:没有卡片语义,只有 Codex 原始字段。 */ +export type DirectThreadRawItem = { + itemId: string; + callId?: string | null; + turnId?: string | null; + itemType: string; + role?: string | null; + text?: string | null; + name?: string | null; + arguments?: string | null; + output?: string | null; + command?: string | null; + tool?: string | null; + changes?: DirectThreadRawChange[] | null; + itemStatus?: string | null; + exitCode?: number | null; + success?: boolean | null; + completed: boolean; + at?: number | null; +}; + +const MESSAGE_ITEM_TYPES = new Set(['message', 'agentMessage', 'userMessage']); +const REASONING_ITEM_TYPES = new Set(['reasoning']); +const TOOL_ITEM_TYPES = new Set([ + 'function_call', + 'function_call_output', + 'commandExecution', + 'fileChange', + 'mcpToolCall', + 'webSearch', + 'contextCompaction', +]); + +const FAILED_ITEM_STATUS = new Set([ + 'failed', + 'declined', + 'cancelled', + 'canceled', + 'aborted', +]); + +/** 折叠态摘要上限,与卡片契约一致。 */ +const TOOL_SUMMARY_MAX_CHARS = 120; + +function text(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function firstLine(value: string): string { + const [line = ''] = value.split('\n'); + const trimmed = line.trim(); + return trimmed.length > TOOL_SUMMARY_MAX_CHARS + ? `${trimmed.slice(0, TOOL_SUMMARY_MAX_CHARS)}…` + : trimmed; +} + +function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind { + switch (name) { + case 'exec_command': + case 'shell': + case 'exec': + return 'command'; + case 'apply_patch': + case 'write_file': + case 'edit_file': + case 'create_file': + return 'file_change'; + case 'web_search': + case 'web_search_preview': + return 'web_search'; + default: + return 'mcp_tool'; + } +} + +function toolKind(raw: DirectThreadRawItem): GameCreatorDirectToolCallKind { + switch (raw.itemType) { + case 'function_call': + return toolKindFromFunctionName(text(raw.name)); + // 输出条目本身不携带工具语义:它只负责补上 `output`。 + case 'function_call_output': + return 'other'; + case 'commandExecution': + return 'command'; + case 'fileChange': + return 'file_change'; + case 'mcpToolCall': + return 'mcp_tool'; + case 'webSearch': + return 'web_search'; + case 'contextCompaction': + return 'context_compaction'; + default: + return 'other'; + } +} + +function toolStatus(raw: DirectThreadRawItem): GameCreatorDirectToolCallStatus { + const itemStatus = text(raw.itemStatus); + if (itemStatus === 'completed') return 'completed'; + if (FAILED_ITEM_STATUS.has(itemStatus)) return 'failed'; + // Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。 + if (typeof raw.exitCode === 'number') { + return raw.exitCode === 0 ? 'completed' : 'failed'; + } + if (typeof raw.success === 'boolean') { + return raw.success ? 'completed' : 'failed'; + } + return raw.completed ? 'completed' : 'running'; +} + +function toolChanges( + raw: DirectThreadRawItem, +): GameCreatorDirectToolCallChange[] { + return (raw.changes ?? []) + .filter((change) => text(change?.path).trim().length > 0) + .map((change) => ({ + path: text(change.path), + kind: text(change.kind) || 'update', + })); +} + +function toolTitle( + kind: GameCreatorDirectToolCallKind, + changes: readonly GameCreatorDirectToolCallChange[], +): string { + switch (kind) { + case 'command': + return '执行命令'; + case 'file_change': { + const paths = new Set(changes.map((change) => change.path)); + return paths.size > 0 ? `编辑 ${paths.size} 个文件` : '编辑文件'; + } + case 'web_search': + return '联网检索'; + case 'context_compaction': + return '整理上下文'; + default: + return '调用工具'; + } +} + +/** + * 工具卡片投影。 + * + * `function_call_output` 只带输出,因此它的标题与摘要留空:后到的输出快照不允许 + * 抹掉先到的命令与标题(合并规则见 `mergeDirectChatEntry`)。 + */ +function projectToolCard( + raw: DirectThreadRawItem, +): GameCreatorDirectToolCall | null { + const kind = toolKind(raw); + const changes = toolChanges(raw); + const command = text(raw.command) || text(raw.arguments); + const tool = text(raw.tool); + const output = text(raw.output); + const isOutputOnly = raw.itemType === 'function_call_output'; + const detail: GameCreatorDirectToolCall['detail'] = {}; + if (command && !isOutputOnly) detail.command = command; + if (output) detail.output = output; + if (changes.length > 0) detail.changes = changes; + if (!detail.command && !detail.output && !detail.changes?.length) return null; + + const summarySource = + (kind === 'mcp_tool' ? tool : '') || + detail.command || + changes[0]?.path || + tool || + ''; + const at = typeof raw.at === 'number' && raw.at > 0 ? raw.at : 0; + return { + schemaVersion: 'agc-tool-call.v1', + id: text(raw.callId) || raw.itemId, + turnId: text(raw.turnId), + kind, + title: isOutputOnly ? '' : toolTitle(kind, changes), + summary: isOutputOnly ? '' : firstLine(summarySource), + status: toolStatus(raw), + detail, + startedAt: at, + updatedAt: at, + }; +} + +/** + * 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。 + * + * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型 + * 都不进聊天视图。 + */ +export function projectDirectThreadRawItem( + raw: DirectThreadRawItem | null | undefined, +): DirectChatEntry | null { + if (!raw) return null; + const itemId = text(raw.itemId).trim(); + const itemType = text(raw.itemType).trim(); + if (!itemId || !itemType) return null; + + if (REASONING_ITEM_TYPES.has(itemType)) { + const body = text(raw.text); + if (!body.trim()) return null; + return { + itemId, + callId: raw.callId ?? null, + turnId: raw.turnId ?? null, + kind: 'reasoning', + role: null, + text: body, + toolCall: null, + at: raw.at ?? 0, + }; + } + + if (MESSAGE_ITEM_TYPES.has(itemType)) { + const role = text(raw.role); + if (role !== 'user' && role !== 'assistant') return null; + const body = text(raw.text); + if (!body) return null; + return { + itemId, + callId: raw.callId ?? null, + turnId: raw.turnId ?? null, + kind: 'message', + role, + text: body, + toolCall: null, + at: raw.at ?? 0, + }; + } + + if (TOOL_ITEM_TYPES.has(itemType)) { + const toolCall = projectToolCard(raw); + if (!toolCall) return null; + return { + itemId, + callId: raw.callId ?? null, + turnId: raw.turnId ?? null, + kind: 'tool', + role: null, + text: null, + toolCall, + at: raw.at ?? 0, + }; + } + + return null; +} + +/** 事件 / 历史切片载荷是否像一条原始条目。 */ +export function isDirectThreadRawItem( + payload: unknown, +): payload is DirectThreadRawItem { + if (!payload || typeof payload !== 'object') return false; + const candidate = payload as Partial; + return ( + typeof candidate.itemId === 'string' && + typeof candidate.itemType === 'string' + ); +} diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index 912e645c7..feeffe84c 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; import { - type DirectChatEntry, directChatEntryIdentity, emptyDirectThreadChatState, - mergeDirectHistoryEntries, + mergeDirectHistoryItems, reduceDirectThreadEvents, resolveDirectThreadBootstrap, selectDirectChatEntries, } from '../src/features/project-workspace/directThreadChat'; import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadRawItem } from '../src/features/project-workspace/directThreadItemProjection'; function event( partial: Partial & @@ -18,26 +18,34 @@ function event( return { seq: 1, payload: {}, ...partial }; } -function toolEntry( - overrides: Partial = {}, -): DirectChatEntry { +/** app-server `item/started`:工具真正开始执行,itemId 就是 call_id。 */ +function rawToolStarted( + overrides: Partial = {}, +): DirectThreadRawItem { return { - itemId: 'call-1', - callId: 'call-1', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', turnId: 'turn-1', - kind: 'tool', - toolCall: { - schemaVersion: 'agc-tool-call.v1', - id: 'call-1', - turnId: 'turn-1', - kind: 'command', - title: '执行命令', - summary: 'ls', - status: 'running', - detail: { command: 'ls', output: undefined, changes: [] }, - startedAt: 1000, - updatedAt: 1000, - }, + itemType: 'function_call', + name: 'exec_command', + arguments: '{"cmd": "ls"}', + completed: false, + at: 1000, + ...overrides, + }; +} + +/** 原始 response item 的 `function_call_output`:itemId 是另一个 id 空间,靠 callId 对齐。 */ +function rawToolOutput( + overrides: Partial = {}, +): DirectThreadRawItem { + return { + itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', + callId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + turnId: 'turn-1', + itemType: 'function_call_output', + output: 'assets\ngame', + completed: true, + at: 2000, ...overrides, }; } @@ -95,10 +103,12 @@ describe('DirectProject 聊天 reducer', () => { payload: { itemId: 'msg-1', turnId: 'turn-1', - kind: 'message', + itemType: 'message', role: 'assistant', text: '你好,我是陶泥儿。', - }, + completed: true, + at: 3000, + } satisfies DirectThreadRawItem, }), ]); const entries = selectDirectChatEntries(done); @@ -106,65 +116,124 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.text).toBe('你好,我是陶泥儿。'); }); - it('工具条目 started/completed 归并成一张卡片', () => { + it('思考增量按 reasoning 条目累计,不混进助手文本', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ - type: 'item.started', + type: 'item.delta', turnId: 'turn-1', - itemId: 'call-1', - payload: toolEntry(), - }), - event({ - type: 'item.completed', - turnId: 'turn-1', - itemId: 'call-1', - payload: { - ...toolEntry(), - toolCall: { - ...toolEntry().toolCall!, - status: 'completed', - detail: { command: 'ls', output: 'assets\ngame', changes: [] }, - updatedAt: 2000, - }, - }, + itemId: 'reason-1', + payload: { delta: '先看目录', kind: 'reasoning' }, }), ]); const entries = selectDirectChatEntries(state); expect(entries).toHaveLength(1); - expect(entries[0]?.toolCall?.status).toBe('completed'); - expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); - expect(entries[0]?.toolCall?.detail.command).toBe('ls'); + expect(entries[0]?.kind).toBe('reasoning'); + expect(entries[0]?.role).toBeNull(); + expect(entries[0]?.text).toBe('先看目录'); }); - it('历史条目与运行态按 callId 合并,重复条目只出现一次', () => { - const historical = mergeDirectHistoryEntries(emptyDirectThreadChatState(), [ - toolEntry(), + it('工具 started / output 跨 id 空间按 callId 归并成一张卡片', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.started', + turnId: 'turn-1', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + payload: rawToolStarted(), + }), + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', + payload: rawToolOutput(), + }), + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(1); + expect(directChatEntryIdentity(entries[0]!)).toBe( + 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + ); + expect(entries[0]?.toolCall?.kind).toBe('command'); + expect(entries[0]?.toolCall?.title).toBe('执行命令'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[0]?.toolCall?.status).toBe('completed'); + }); + + it('历史切片搬运层不合并,合并发生在前端投影', () => { + const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + rawToolStarted(), + rawToolOutput(), { itemId: 'msg-user', - kind: 'message', + itemType: 'message', role: 'user', text: '做一个拼图游戏', + completed: true, + at: 0, + }, + ]); + const entries = selectDirectChatEntries(state); + expect(entries).toHaveLength(2); + expect(entries[0]?.toolCall?.detail.output).toBe('assets\ngame'); + expect(entries[1]?.role).toBe('user'); + }); + + it('系统条目与未识别的 item 类型不进聊天视图', () => { + const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'sys-1', + payload: { + itemId: 'sys-1', + itemType: 'message', + role: 'system', + text: '内部指令', + completed: true, + at: 0, + } satisfies DirectThreadRawItem, + }), + event({ + type: 'item.completed', + turnId: 'turn-1', + itemId: 'plan-1', + payload: { + itemId: 'plan-1', + itemType: 'plan', + completed: true, + at: 0, + } satisfies DirectThreadRawItem, + }), + ]); + expect(selectDirectChatEntries(state)).toHaveLength(0); + }); + + it('历史条目与运行态按身份合并,重复条目只出现一次', () => { + const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ + rawToolStarted(), + { + itemId: 'msg-user', + itemType: 'message', + role: 'user', + text: '做一个拼图游戏', + completed: true, + at: 0, }, ]); const live = reduceDirectThreadEvents(historical, [ event({ type: 'item.completed', turnId: 'turn-1', - itemId: 'call-1', - payload: { - ...toolEntry(), - itemId: 'call-1', - toolCall: { - ...toolEntry().toolCall!, - status: 'completed', - detail: { command: 'ls', output: 'assets', changes: [] }, - }, - }, + itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', + payload: rawToolOutput(), }), ]); const entries = selectDirectChatEntries(live); expect(entries).toHaveLength(2); expect(entries[0]?.toolCall?.status).toBe('completed'); - expect(directChatEntryIdentity(entries[0]!)).toBe('call-1'); + expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); + expect(directChatEntryIdentity(entries[0]!)).toBe( + 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', + ); }); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts index 128cdf135..ccaebe645 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -20,20 +20,29 @@ describe('Direct 回合状态与历史时间', () => { expect(isDirectTurnInProgress(status)).toBe(true); } }); - it('按消息 id 读取信封时间,旧记录不使用当前时间补造', () => { + it('条目时间来自搬运后的 at,前端不自己补造时间', () => { const items = [ { - type: 'message', + itemId: 'direct-codex:turn:user', + itemType: 'message', role: 'user', - id: 'direct-codex:turn:user', - content: [{ type: 'input_text', text: '帮我修改游戏' }], + text: '帮我修改游戏', + completed: true, + at: 1_800_000_000_001, + }, + { + itemId: 'direct-codex:turn:assistant', + itemType: 'message', + role: 'assistant', + text: '好的', + completed: true, }, ]; - const timestamps = { 'direct-codex:turn:user': 1_800_000_000_001 }; - expect( - directThreadHistoryItemsToMessages(items, timestamps)[0]?.updatedAt, - ).toBe(1_800_000_000_001); - expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe(0); + expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe( + 1_800_000_000_001, + ); + // Rust 拿不到条目时间时给 0,前端不得用"当前时间"补造。 + expect(directThreadHistoryItemsToMessages(items)[1]?.updatedAt).toBe(0); expect(items[0]).not.toHaveProperty('recordedAt'); }); }); -- 2.52.0 From 42b702d36209e31df3837f2df70b1dd6f2e0a585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:05:04 +0800 Subject: [PATCH 07/11] =?UTF-8?q?=E6=8C=89=20grill=20=E7=BB=93=E8=AE=BA?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20DirectProject=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E7=9C=9F=E7=9B=B8=E6=BA=90=E6=94=B6=E6=95=9B=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 里程碑与实施计划改定边界:Thread Manager 只搬运脱敏原始条目,不生成卡片形状与可见性 条目身份归一为 call_id ?? id,Thread Manager 与前端不再暴露第二个 id 概念,分页锚点仍取文件里的原始 item id 合并规则收敛为"先到定形、后到补空白",删除逐字段优先级表 主规范补充条目形状三条职责边界,并说明思考正文流式下发不放宽可见范围 ADR 同步:DirectRuntime 自己的进度事件与投影文件保留,前端删除的是 DirectProject 的读路径 失败与中止说明只在运行期显示;历史切片改为从文件尾反向扫描;未知 item 类型透传并加 TODO --- ...irectProject对话历史单一事实源-2026-09-16.md | 18 +++++++---- ...划】DirectProject聊天真相源收敛-2026-09-16.md | 26 ++++++++------- ...碑】DirectProject聊天真相源收敛-2026-09-16.md | 32 ++++++++++++++----- ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 12 ++++++- 4 files changed, 61 insertions(+), 27 deletions(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 76e36bb03..e7b474cd4 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -12,19 +12,25 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - AGC 项目开发对话的持久事实源只有 **项目对话历史**(`.agent/conversations/project.jsonl` 的原始条目);消息文本、工具卡片和它们的先后顺序都从它派生。 - 运行期间的回合状态只来自 **运行态事件**(Thread Manager 的 subscribe / consume / notify);`notify` 只做唤醒,不携带状态。 -- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;`turn-stream.jsonl` 与 `tool-calls.jsonl` 停止写入、停止读取,也不再提供读取命令。 +- **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;DirectProject 聊天框停止读取 `turn-stream.jsonl` 与 `tool-calls.jsonl`,也不再提供供前端读取的命令。DirectRuntime 自己那套进度事件与文件写入属于运行时账本,本轮保留不动。 - 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。 - 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 -- 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「条目 → 视图」投影函数;Rust 做的是机械投影,不是可见性判断。 +- 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 +- 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 +- 条目身份只有一套:`itemId = call_id ?? id`。工具 item 的 app-server id 就是 `call_id`,而 `project.jsonl` 落盘的是原始 response item(两个 id 不同),所以在进队列前归一,不向 Thread Manager 与前端暴露第二个 id 概念。 +- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - `notify` 是唯一唤醒来源:前端在 bootstrap 之后立刻 `consume` 一次补齐竞态窗口,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 - 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 -- 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整条目(同一套脱敏、截断、路径归一),前端按 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 +- 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 +- 思考正文以 `item.delta{kind:"reasoning"}` 流式下发(`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见范围:同一段文本本来就已落进 `project.jsonl` 并在 `item.completed` 展示;plan 文本与命令输出仍只降级为活动状态。 - 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。 - 生命周期锚点独立于 replay 队列保存,`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 -- 删除范围包含 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)以及 `game-creator-direct-turn-update` 事件与两个投影文件模块;保留分页用的历史切片读取。 +- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取,且该切片从文件尾反向扫描。 +- 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 +- 历史切片的 `firstItemId` 是分页锚点,始终取 `project.jsonl` 里的原始 item id,与归一后的条目身份分开计算。 - 审批与提问事件本次只作为同一条事件流 pass-through,不并入聊天 reducer 驱动的状态机,迁移面收敛在历史与运行态一致性上。 ## 备选方案与取舍 @@ -35,8 +41,8 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( ## 影响 -- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再读。 +- 旧项目磁盘上遗留的 `turn-stream.jsonl` / `tool-calls.jsonl` 保留不动,不迁移、不清理、不再由 DirectProject 聊天框读取。 - 工具卡片的脱敏与截断必须在读取期执行一次,不能因为"原始条目已在磁盘"就把未脱敏内容直接渲染到界面。 - 回合结束语义务必由 `turn.completed` 判定;缺少该事件的残留回合不得被渲染成运行中。 - 验收证据是端到端行为,不是单元测试:回合进行中杀掉应用进程后重开项目,应看到部分文本与工具卡片按原顺序出现且不显示忙碌;正常结束后重进应与实时渲染一致;文件系统不得再新增 `turn-stream.jsonl` / `tool-calls.jsonl`。 -- 待实现前验证的风险:`item.started` 的条目 id 来自 app-server item,`item.completed` 的来自 raw response item,两者是否同一 id 空间必须先拿真实 app-server 会话核对;若不是同一 id 空间,活跃条目将永远收不到完成事件。 +- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item。真实 app-server 会话核对仍列为运行时验收项。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index 9815096d9..f9040e238 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -8,31 +8,33 @@ ## 修改边界 -- 允许修改:`agent/direct_thread_manager.rs`、`agent/codex_app_server/`、`agent/runtime_driver/entrypoints.rs`、`agent/direct_project_history.rs`、`main.rs` 命令注册、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 -- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、非 DirectProject Runtime、Codex durable thread 行为、审批弹层现有状态来源。 +- 允许修改:`agent/direct_thread_manager.rs`、`agent/direct_thread_raw_item.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 +- 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、DirectRuntime 自己的进度事件与 `turn-stream.jsonl` / `tool-calls.jsonl` 写入、Codex durable thread 行为、审批弹层现有状态来源。 - 保持 `.env` 未提交修改,不触碰个人配置。 ## 实现顺序 -1. 先用真实 app-server 会话核对 `item.started` 与 `item.completed` 的条目 id 是否同一 id 空间;不一致则先在适配层统一 id,再进入后续步骤。 -2. Thread Manager 事件自足化:`item.started` / `item.completed` 携带经同一套脱敏、截断、路径归一的完整条目,且与历史切片同形。 -3. 删除 Direct 回合事件发点、`direct_turn_stream.rs` / `direct_tool_calls.rs` 两个投影模块及其命令注册,保留历史切片读取与 `subscribe` / `consume` / notify。 -4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,bootstrap 后立刻 consume 一次补齐竞态窗口,此后只由 notify 唤醒,过期后重新订阅并原子替换。 -5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,锚点按原始条目 id 推进,切片无可见条目时自动连拉(上限 5 页)。 -6. 删除前端 `directTurnStream` / `directToolCalls` 状态、活动回合快照接管与流排序回退分支,把聊天投影收敛成"条目 → 视图"一条路径。 -7. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 +1. Rust 只搬运:`agent/direct_thread_raw_item.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状。 +2. 条目身份归一:`itemId = call_id ?? id`,同时从事件 envelope 与前端形状里删掉第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 +3. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 +4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,bootstrap 后立刻 consume 一次补齐竞态窗口,此后只由 notify 唤醒;合并规则只保留"先到定形、后到补空白",并删掉 `deltaText` 缓冲与回合结束后的运行态残留。 +5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)。 +6. App.tsx 接线:订阅 + 立即 consume + notify 唤醒,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 +7. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留。 +8. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 ## 验证命令 -1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread_manager -- --nocapture` -2. `npx vitest run apps/ai-game-creator-shell/tests/directThreadEvents.test.ts apps/ai-game-creator-shell/tests/directTurnPresentation.test.ts` +1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture` +2. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directThreadEvents.test.ts` 3. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` 4. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` ## 风险与回滚点 -- 条目 id 空间不一致会让活跃条目永远收不到完成事件:这是第 1 步的显式前置验证,不通过不进入实现。 +- 条目 id 空间不一致会让活跃条目永远收不到完成事件:第 2 步的归一必须在 Rust 出口完成;前端不得再拿到两个 id。 - 事件 payload 变大(命令输出、文件变更明细):继续沿用既有截断上限,并观察 Thread Manager 单 thread 字节上限是否被提前触发。 - 订阅过期:以重新 `subscribe` + bootstrap 后立即 `consume` + 原子替换处理,需要单测覆盖;不引入定时轮询。 +- 合并规则退化为"先到定形"后,若某类条目只有输出没有调用条目,该输出不显示;这是有意取舍,先观察再决定是否补规则。 - 回滚点:每一步都保持"新源可用即不依赖旧源"的中间态可回退;不允许出现新源未启用而旧源已删除的提交。 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index f0b124e5a..8009c1e3f 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -9,27 +9,41 @@ ## 目标 -AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。删除 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照接管这些并行真相源。 +AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照都不再是聊天视图的输入。 + +边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。条目身份只有一个:Rust 在进队列前归一到 `call_id ?? id`,不再暴露第二套 id 概念。 ## 范围 -- 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的完整脱敏条目,前端按条目身份合并运行中与完成态。 -- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,分页锚点取原始条目 id;不可显示条目由前端跳过并继续取页。 -- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定。 -- 删除不再被使用的运行时事件、持久投影文件读写、命令与前端状态。 +- 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的脱敏**原始条目**,前端用同一个投影函数处理实时与回读。 +- 条目身份归一:`itemId = call_id ?? id`(工具 item 的 app-server id 就是 `call_id`);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id。 +- 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 以 `item.delta{kind:"reasoning"}` 下发正文;plan 文本与命令输出仍只降级为活动状态。 +- 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,切片从**文件尾反向扫描**;不可显示条目由前端跳过并继续取页(上限 5 页)。 +- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定;回合结束后回收该回合运行态条目。 +- 删除前端对 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照的读取,以及只服务这些读取的命令与状态。 ## 不在范围内 - 审批、提问与用户输入请求的状态机迁移;本次事件只作同一条流的 pass-through。 - 跨进程回合账本、按回合统计与持久 turn ledger。 +- DirectRuntime 自己的进度事件与该运行时仍在使用的 `turn-stream.jsonl` / `tool-calls.jsonl` 写入:它们属于运行时的账本,本轮只切 DirectProject 聊天框的读路径。 - 旧项目磁盘上既有投影文件的清理、迁移或回填。 - 非 DirectProject 运行时、Codex durable thread 语义、SpacetimeDB 与 HTTP 契约。 +## 已确认的决策 + +- 投影在前端:Rust 不生成 `kind` / 标题 / 折叠摘要,也不做合并。 +- 合并只保留"先到定形、后到补空白":第一次见到的快照决定卡片形状,后续快照只补输出与状态;不做逐字段优先级表。 +- 条目 id 只有一套:`call_id ?? id`;前端与 Thread Manager 都不再出现第二个 id。 +- 思考正文流式下发不放宽可见范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本。 +- 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。 +- 未知 item 类型由 Rust 原样透传(只有类型与身份),当前由前端投影丢弃。 + ## 依赖与前置条件 - Thread Manager 深模块、app-server 事件适配与 Tauri 桥接已存在。 - 主规范中的生命周期锚点、事件自足与首屏锚点条款已生效。 -- 实现前需用真实 app-server 会话核对 `item.started` 与 `item.completed` 是否同一 id 空间。 +- id 空间已用源码核对:codex-rs `app-server-protocol/src/protocol/thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`,而 `project.jsonl` 落盘的是原始 response item(`id` 与 `call_id` 不同)。真实 app-server 会话核对仍作为运行时验收项。 ## 验收标准 @@ -37,11 +51,13 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - [ ] 回合进行中终止并重启进程后重进项目:已落盘的部分文本与工具卡片按原顺序出现,且界面不显示忙碌态。 - [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。 - [ ] 历史分页在切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。 -- [ ] 文件系统不再新增 `turn-stream.jsonl` / `tool-calls.jsonl`;聊天内容只由项目对话历史与运行态事件重建。 +- [ ] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns` 不再被前端调用。 +- [ ] 同一工具调用在实时与回读各只出现一张卡片(跨 id 空间按归一身份对齐)。 +- [ ] 思考正文在回合进行中即可见,且不进入活动状态文本。 - [ ] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。 ## 证据要求 -- 自动化:Thread Manager 事件契约测试、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 +- 自动化:Thread Manager 事件契约测试、原始条目搬运与脱敏测试、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合 / 先到优先合并)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 - 运行时:真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 - 边界:订阅过期、事件重复与乱序、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 1e66add9c..8ae5d836e 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -124,7 +124,17 @@ Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号, 生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 -`item.started` 与 `item.completed` 必须携带与历史切片同形的完整条目(经同一套脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 +`item.started` 与 `item.completed` 必须携带与历史切片同形的**脱敏原始条目**(经同一套挑字段、脱敏、截断、路径归一),不得只给 item 类型或空 payload。前端不得依赖"按 `itemId` 单点取快照"补齐正文:Rust 不提供 `getItemSnapshot(itemId)`,未完成条目的正文随事件下发,已完成条目一律通过历史读取。 + +条目形状的职责边界固定为三条: + +1. **搬运层不生成展示形状**。Thread Manager 只下发 Codex 原始条目(`itemType` 原样透传,正文与工具明细脱敏后带上限截断),不生成工具卡片的 `kind`、标题、折叠摘要,也不判断哪些条目要显示。 +2. **只有一个条目身份**。`itemId = call_id ?? id`:工具 item 的 app-server id 就是 `call_id`(codex-rs `thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`),而 `project.jsonl` 落盘的是原始 response item(`id` 与 `call_id` 不同),所以在进队列前归一。Thread Manager 与前端都不得再出现第二个 id 概念。 +3. **合并只在前端,且只保留"先到定形、后到补空白"**。第一次见到的快照决定卡片形状,后续快照只补输出与状态;同一调用只出现一张卡片。 + +历史切片的 `firstItemId` 不是上述归一身份:分页锚点必须是 `project.jsonl` 里的原始 item id,由 Rust 从文件扫描单独算出。 + +思考正文以 `item.delta{kind:"reasoning"}` 流式下发(来源是 app-server 的 `item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见文本范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本;plan 文本与命令输出仍然只降级为活动状态,不下发正文。 ### 队列、subscriber 和回收 -- 2.52.0 From 09ad0073fe0905027c41a73e80947889f2115f4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:06:05 +0800 Subject: [PATCH 08/11] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20DirectProject=20?= =?UTF-8?q?=E8=AE=A2=E9=98=85=E8=AF=AD=E4=B9=89=EF=BC=9Abootstrap=20?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E5=B0=B1=E6=98=AF=E5=BD=93=E5=89=8D=E8=A6=81?= =?UTF-8?q?=E5=A4=84=E7=90=86=E7=9A=84=E4=BA=8B=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subscribe 已在同一边界把游标钉在队尾,因此前端直接 reduce bootstrap 事件,不再补一次 consume 主规范、ADR 与实施计划同步删除"竞态窗口"说法,notify 仍是唯一唤醒来源 --- docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md | 2 +- .../plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md | 2 +- ...技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index e7b474cd4..210db6135 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -21,7 +21,7 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。 - 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 -- `notify` 是唯一唤醒来源:前端在 bootstrap 之后立刻 `consume` 一次补齐竞态窗口,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 +- `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不额外补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 - 思考过程与工具活动同样从运行态事件与历史条目推断,界面展示保持不变。 - 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index f9040e238..1fc498481 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -17,7 +17,7 @@ 1. Rust 只搬运:`agent/direct_thread_raw_item.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状。 2. 条目身份归一:`itemId = call_id ?? id`,同时从事件 envelope 与前端形状里删掉第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 3. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 -4. 前端收敛为单一 reducer:bootstrap 与 consume 走同一条事件流,bootstrap 后立刻 consume 一次补齐竞态窗口,此后只由 notify 唤醒;合并规则只保留"先到定形、后到补空白",并删掉 `deltaText` 缓冲与回合结束后的运行态残留。 +4. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要再补一次 `consume`);此后只由 notify 唤醒 `consume`。合并规则只保留"先到定形、后到补空白",并删掉 `deltaText` 缓冲与回合结束后的运行态残留。 5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)。 6. App.tsx 接线:订阅 + 立即 consume + notify 唤醒,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 7. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index 8ae5d836e..b1d6888f7 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -98,7 +98,7 @@ readHistory(threadId, { beforeItemId?, limit }) -> { `notify` 只负责唤醒,不携带事件、cursor 或持久化状态。前端收到通知后调用 `consume`;通知可合并、重复或丢失,事件完整性由 `consume` 保证。 -`subscribe` 把新 subscriber 的游标钉在当时的队尾,因此前端拿到 bootstrap 后必须**立刻 `consume` 一次**,补齐"订阅落定到首条事件"之间的竞态窗口;此后不再轮询,也不设任何定时 `consume`。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。 +`subscribe` 在同一个边界内先把新 subscriber 的游标钉在当时的队尾,再收集 bootstrap 的运行态事件,因此 bootstrap 返回的那批事件**就是**该 subscriber 此刻应处理的事件:前端直接 reduce 它们即可,不存在"再补一次 `consume` 才能补齐竞态窗口"的步骤。此后不再轮询,也不设任何定时 `consume`——唤醒只由 `notify` 负责。用定时器兜底既自举不了(判断"有活动回合"本身依赖事件),也把唤醒机制变成两套。 首屏历史不通过"读取整份对话"的命令获取:`subscribe` 返回的 `lastCompletedItemId` 就是首屏锚点,前端据此调用 `readHistory` 取最近的切片,再按滚动或按钮继续向前分页。系统不提供返回整份对话历史的命令。 -- 2.52.0 From 2748468d127a2c4c6455a535fe0358b26c603be6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:38:31 +0800 Subject: [PATCH 09/11] =?UTF-8?q?DirectProject=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E4=BA=8B=E4=BB=B6=E6=94=B9=E7=94=A8=20ts-rs=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E7=9A=84=20tagged=20enum=20=E5=B9=B6=E5=88=A0?= =?UTF-8?q?=E6=8E=89=20turn=20id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 direct_thread_wire.rs:DirectThreadItem / DirectThreadEvent / 订阅与历史切片全部改成 ts-rs 导出的 tagged enum,取代原大而全的可空结构体 - 删除 direct_thread_raw_item.rs,模块注册与直通引用改到 direct_thread_wire - 条目身份只看一个 itemId:工具条目的第二个 id 在 Rust 边界归一,不再对外暴露 - 删除 DirectProject 聊天事件里的 turn id:生命周期用无载荷的 turn.started / turn.completed{status} 表示 - append 直接接收 DirectThreadEvent 并返回同一事件,队列内部自算 seq - 请求事件改为携带 DirectThreadRequestKind,去掉字符串中转 - 思考增量走 ReasoningDelta 通道,与正文增量共用 item.delta - at 用 #[ts(as = "f64")] 对齐 Tauri JSON 通道的 number - 用 cargo test export_bindings 重新生成 project-workspace/generated 绑定 --- .../src-tauri/src/agent.rs | 4 +- .../src/agent/codex_app_server/mod.rs | 224 ++--- .../src/agent/direct_thread_manager.rs | 448 ++++----- .../src/agent/direct_thread_raw_item.rs | 581 ------------ .../src-tauri/src/agent/direct_thread_wire.rs | 864 ++++++++++++++++++ .../src-tauri/src/agent/direct_tool_calls.rs | 2 +- .../src-tauri/src/commands.rs | 2 +- .../generated/DirectCodexUserContentPart.ts | 3 +- .../generated/DirectCodexUserItem.ts | 10 +- .../DirectCodexUserMessageEnvelope.ts | 4 + .../generated/DirectCodexUserMessageItem.ts | 6 +- .../generated/DirectCodexUserRole.ts | 2 +- .../DirectCodexUserRuntimeRegionPart.ts | 18 +- .../generated/DirectThreadConsumeResult.ts | 4 + .../generated/DirectThreadDeltaKind.ts | 6 + .../generated/DirectThreadEvent.ts | 30 + .../generated/DirectThreadFileChange.ts | 12 + .../generated/DirectThreadHistorySlice.ts | 14 + .../generated/DirectThreadItem.ts | 76 ++ .../generated/DirectThreadRequestKind.ts | 9 + .../DirectThreadSubscriptionBootstrap.ts | 14 + 21 files changed, 1315 insertions(+), 1018 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs create mode 100644 apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts create mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts 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 f3ca892e7..5c8f7d58a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent.rs @@ -21,7 +21,7 @@ mod direct_project_history; mod direct_project_turn_history; mod direct_runtime; mod direct_thread_manager; -mod direct_thread_raw_item; +mod direct_thread_wire; mod direct_tool_bridge; mod direct_tool_calls; mod direct_tools_mcp; @@ -56,7 +56,7 @@ pub(crate) use direct_project_history::*; pub(crate) use direct_project_turn_history::*; pub(crate) use direct_runtime::*; pub(crate) use direct_thread_manager::*; -pub(crate) use direct_thread_raw_item::*; +pub(crate) use direct_thread_wire::*; pub(crate) use direct_tool_bridge::*; pub(crate) use direct_tool_calls::*; pub(crate) use direct_tools_mcp::*; 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 8b789f2e8..f47ec3b29 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 @@ -564,6 +564,17 @@ enum CodexTurnEvent { item_id: String, delta: String, }, + /// 思考正文增量:app-server `item/reasoning/summaryTextDelta` 的明文思考文本。 + /// + /// `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)与 + /// `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)都进这条通道:前者是 + /// reasoning item 的 `summary`,后者是它的 `content`,两段文本都随 `item/completed` + /// 落进 `project.jsonl`、此前也已经在完成时展示给用户。plan 文本与命令输出仍然只降级为 + /// 活动状态,不下发正文。 + ReasoningDelta { + item_id: String, + delta: String, + }, IntermediateText(String), Activity(&'static str), Item { @@ -571,7 +582,7 @@ enum CodexTurnEvent { params: serde_json::Value, }, Request { - event_type: &'static str, + kind: DirectThreadRequestKind, params: serde_json::Value, }, RawItem(serde_json::Value), @@ -741,40 +752,14 @@ fn direct_codex_safe_activity_for_item_value(item: &serde_json::Value) -> &'stat direct_codex_safe_activity_for_item(item_type) } -/// Project an app-server item into the small public payload carried by the -/// DirectProject event queue. Full item contents are persisted in JSONL and -/// must not be forwarded through the runtime event stream. -/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到条目时给空对象。 +/// 运行态事件载荷:与历史切片同形的脱敏原始条目;拿不到身份或类型就整条跳过。 /// /// 这里不生成工具卡片形状:标题、折叠摘要和可见性都是前端投影的职责。 -fn direct_thread_raw_item_payload( +fn direct_thread_event_item( root: &std::path::Path, item: &serde_json::Value, - turn_id: Option<&str>, - completed: bool, - now_ms: u64, -) -> serde_json::Value { - direct_thread_raw_item_from_value(root, item, turn_id, completed, now_ms) - .and_then(|raw| serde_json::to_value(raw).ok()) - .unwrap_or_else(|| serde_json::json!({})) -} - -fn direct_thread_item_id(item: &serde_json::Value) -> Option { - item.get("id") - .and_then(serde_json::Value::as_str) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - -/// 原始 response item 的调用 id:工具条目的 app-server `itemId` 就是这个值, -/// 所以它是两个 id 空间唯一的对齐点。 -fn direct_thread_item_call_id(item: &serde_json::Value) -> Option { - item.get("call_id") - .or_else(|| item.get("callId")) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) +) -> Option { + direct_thread_item_from_value(root, item, direct_tool_call_now_ms()) } fn direct_codex_command_is_game_verification(command: &str) -> bool { @@ -991,19 +976,21 @@ fn direct_codex_safe_activity_for_notification(method: &str) -> Option<&'static } } -fn direct_codex_request_event_type(method: &str) -> Option<&'static str> { +fn direct_codex_request_event_type(method: &str) -> Option { match method { "item/fileChange/requestApproval" | "item/commandExecution/requestApproval" - | "item/permissions/requestApproval" => Some("approval.requested"), - "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => Some("ask.requested"), + | "item/permissions/requestApproval" => Some(DirectThreadRequestKind::ApprovalRequested), + "item/tool/requestUserInput" | "item/mcpToolCall/requestUserInput" => { + Some(DirectThreadRequestKind::AskRequested) + } _ => None, } } -fn direct_codex_resolution_event_type(method: &str) -> Option<&'static str> { +fn direct_codex_resolution_event_type(method: &str) -> Option { match method { - "serverRequest/resolved" => Some("request.resolved"), + "serverRequest/resolved" => Some(DirectThreadRequestKind::RequestResolved), _ => None, } } @@ -1076,6 +1063,33 @@ fn direct_codex_notification_event( intermediate_text: Option, safe_activity: Option<&'static str>, ) -> Option { + // 思考正文走独立通道,交给 DirectProject 的运行态事件;它不因为 + // "preparing 活动" 的降级规则被丢掉,否则界面只能等 item/completed 才看到思考。 + // + // 两条通知都下发正文,不下发活动文本: + // - `item/reasoning/summaryTextDelta`(core `ReasoningContentDelta`)→ reasoning item 的 `summary`; + // - `item/reasoning/textDelta`(core `ReasoningRawContentDelta`)→ reasoning item 的 `content`, + // 正是 `project.jsonl` 里保存、并在此前 `item/completed` 已经展示给用户的同一段文本。 + // 因此这里只是把"完成时才看到"提前为"边生成边看到",没有放宽可见文本的范围; + // 未识别的 plan 文本与命令输出仍然只降级为活动状态,不下发正文。 + if matches!( + method, + "item/reasoning/summaryTextDelta" | "item/reasoning/textDelta" + ) { + return params + .get("delta") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(|delta| CodexTurnEvent::ReasoningDelta { + item_id: params + .get("itemId") + .and_then(serde_json::Value::as_str) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| "direct-missing-item".to_string()), + delta: delta.to_string(), + }); + } let (activity, intermediate_text) = match (&intermediate_text, safe_activity) { (Some(_), Some(activity)) if activity == "preparing" => (Some(activity), None), _ => (safe_activity, intermediate_text), @@ -2943,19 +2957,7 @@ impl CodexAppServerConnection { turn_start_guard.armed = false; let direct_thread_id = history_root.to_string_lossy().into_owned(); if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "turn.started".to_string(), - turn_id: turn_id.clone(), - item_id: None, - call_id: None, - payload: serde_json::json!({ - "threadId": thread_id, - "turnId": turn_id, - }), - }, - ); + append_direct_thread_event(&direct_thread_id, DirectThreadEvent::turn_started()); } let mut receiver = self.register_turn(&turn_id).await; let mut direct_project_history = DirectProjectHistoryAccumulator::default(); @@ -3010,18 +3012,13 @@ impl CodexAppServerConnection { direct_project_history.observe_delta(&item_id, &delta); append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.delta".to_string(), - turn_id: turn_id.clone(), - item_id: Some(item_id.clone()), - call_id: None, - // 事件自足:增量也要说明它是哪类条目的正文, - // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 - payload: serde_json::json!({ - "delta": delta.clone(), - "kind": "message", - }), - }, + // 事件自足:增量自带 item 身份与正文类别(正文 / 思考), + // 前端 reducer 不允许靠猜 itemId 的来源决定 kind。 + DirectThreadEvent::item_delta( + item_id.clone(), + DirectThreadDeltaKind::Message, + delta.clone(), + ), ); } streamed_text.push_str(&delta); @@ -3052,6 +3049,18 @@ impl CodexAppServerConnection { }); } } + Some(CodexTurnEvent::ReasoningDelta { item_id, delta }) => { + if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_delta( + item_id, + DirectThreadDeltaKind::Reasoning, + delta, + ), + ); + } + } Some(CodexTurnEvent::IntermediateText(text)) => { if let Some(observer) = direct_observer.as_deref_mut() { observer(DirectCodexTurnObservation::IntermediateText(text)); @@ -3064,13 +3073,7 @@ impl CodexAppServerConnection { "rawResponseItem/completed 缺少 item".to_string(), )); } - let entry_payload = direct_thread_raw_item_payload( - history_root, - &item, - Some(turn_id.as_str()), - true, - direct_tool_call_now_ms(), - ); + let entry_item = direct_thread_event_item(history_root, &item); let history_root = history_root.to_path_buf(); let history_item = item.clone(); tokio::task::spawn_blocking(move || { @@ -3084,20 +3087,15 @@ impl CodexAppServerConnection { })? .map_err(platform_llm::LlmError::InvalidRequest)?; direct_project_history.complete_item(&item); - let item_id = direct_thread_item_id(&item); - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.completed".to_string(), - turn_id: turn_id.clone(), - item_id, - call_id: direct_thread_item_call_id(&item), - payload: entry_payload, - }, - ); + if let Some(entry_item) = entry_item { + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_completed(entry_item), + ); + } } } - Some(CodexTurnEvent::Request { event_type, params }) => { + Some(CodexTurnEvent::Request { kind, params }) => { if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { let request_id = params .get("requestId") @@ -3107,15 +3105,7 @@ impl CodexAppServerConnection { .map(str::to_string); append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: event_type.to_string(), - turn_id: turn_id.clone(), - item_id: None, - call_id: None, - payload: request_id - .map(|id| serde_json::json!({ "requestId": id })) - .unwrap_or_else(|| serde_json::json!({})), - }, + DirectThreadEvent::request(kind, request_id), ); } } @@ -3226,23 +3216,14 @@ impl CodexAppServerConnection { && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject { - let item_id = direct_thread_item_id(item); - append_direct_thread_event( - &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "item.started".to_string(), - turn_id: turn_id.clone(), - item_id, - call_id: None, - payload: direct_thread_raw_item_payload( - history_root, - item, - Some(turn_id.as_str()), - completed, - direct_tool_call_now_ms(), - ), - }, - ); + if let Some(entry_item) = + direct_thread_event_item(history_root, item) + { + append_direct_thread_event( + &direct_thread_id, + DirectThreadEvent::item_started(entry_item), + ); + } } } } @@ -3284,13 +3265,7 @@ impl CodexAppServerConnection { { append_direct_thread_event( &direct_thread_id, - DirectThreadRawEventDraft { - event_type: "turn.completed".to_string(), - turn_id: turn_id.clone(), - item_id: None, - call_id: None, - payload: serde_json::json!({ "status": status }), - }, + DirectThreadEvent::turn_completed(status.to_string()), ); } match status { @@ -3985,8 +3960,8 @@ async fn read_game_creator_codex_app_server_stdout( continue; } } - let event = if let Some(event_type) = direct_codex_resolution_event_type(method) { - CodexTurnEvent::Request { event_type, params } + let event = if let Some(kind) = direct_codex_resolution_event_type(method) { + CodexTurnEvent::Request { kind, params } } else if let Some(activity) = safe_activity { // Preparing notifications may carry private plan/reasoning text; // expose only the safe activity category. Other categories may @@ -4038,8 +4013,8 @@ async fn read_game_creator_codex_app_server_stdout( ), method if direct_codex_request_event_type(method).is_some() => { CodexTurnEvent::Request { - event_type: direct_codex_request_event_type(method) - .expect("request event type checked above"), + kind: direct_codex_request_event_type(method) + .expect("request kind checked above"), params, } } @@ -4550,15 +4525,10 @@ mod tests { "arguments": { "path": "game/index.html", "token": "secret" }, "result": { "content": "large output" } }); - assert_eq!(direct_thread_item_id(&item).as_deref(), Some("item-1")); // 运行态事件必须自足:载荷是脱敏原始条目,前端不需要再按 itemId 取快照。 - let payload = direct_thread_raw_item_payload( - std::path::Path::new("."), - &item, - Some("turn-1"), - false, - 1000, - ); + let projected = direct_thread_event_item(std::path::Path::new("."), &item).expect("item"); + assert_eq!(projected.item_id(), "item-1"); + let payload = serde_json::to_value(&projected).expect("payload"); assert_eq!( payload.get("itemType").and_then(serde_json::Value::as_str), Some("mcpToolCall") @@ -4567,10 +4537,6 @@ mod tests { payload.get("itemId").and_then(serde_json::Value::as_str), Some("item-1") ); - assert_eq!( - payload.get("turnId").and_then(serde_json::Value::as_str), - Some("turn-1") - ); // 卡片标题 / 折叠摘要 / kind 属于前端投影:载荷里不得出现这些 UI 语义。 assert!(payload.get("toolCall").is_none(), "{payload}"); assert!(payload.get("title").is_none(), "{payload}"); 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 20c668a11..a30816d82 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 @@ -4,13 +4,13 @@ //! 每个 subscriber 一个受保护的消费游标。它不理解前端 reducer,也不负责 JSONL //! 持久化;调用方必须在完成 item 持久化成功后再追加对应完成事件。 -use serde::{Deserialize, Serialize}; -use serde_json::Value; use std::collections::{HashMap, HashSet}; use std::sync::{Mutex, OnceLock}; use uuid::Uuid; -use crate::agent::DirectThreadRawItem; +use crate::agent::{ + DirectThreadConsumeResult, DirectThreadEvent, DirectThreadSubscriptionBootstrap, +}; const DEFAULT_MAX_EVENTS: usize = 8_192; const DEFAULT_MAX_BYTES: usize = 8 * 1024 * 1024; @@ -21,65 +21,11 @@ pub(crate) const DIRECT_THREAD_NOTIFY_EVENT: &str = "game-creator-direct-thread- static DIRECT_THREAD_MANAGER: OnceLock> = OnceLock::new(); static DIRECT_THREAD_MANAGER_APP_HANDLE: OnceLock = OnceLock::new(); -#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadRawEvent { - pub(crate) seq: u64, - #[serde(rename = "type")] - pub(crate) event_type: String, - pub(crate) turn_id: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) item_id: Option, - /// 合并身份的另一半:工具类条目的 `itemId` 是调用 id,原始 item 的 `id` 是 response item id, - /// 两者只在 `callId` 上对齐。前端按 `callId ?? itemId` 归并同一张卡片。 - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) call_id: Option, - pub(crate) payload: Value, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub(crate) struct DirectThreadRawEventDraft { - pub(crate) event_type: String, - pub(crate) turn_id: String, - pub(crate) item_id: Option, - pub(crate) call_id: Option, - pub(crate) payload: Value, -} - -impl DirectThreadRawEvent { - /// 条目合并身份:有 `callId` 就用它,否则用 `itemId`。 - fn item_identity(&self) -> Option<&str> { - self.call_id.as_deref().or(self.item_id.as_deref()) - } -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadSubscriptionBootstrap { - pub(crate) subscription_id: String, - pub(crate) last_completed_item_id: Option, - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadConsumeResult { - pub(crate) events: Vec, -} - -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadHistorySlice { - /// 脱敏原始条目,顺序即文件顺序。与运行态事件载荷同形,前端只有一套投影。 - pub(crate) items: Vec, - pub(crate) has_more: bool, - /// 本次切片的原始条目锚点:无论切片里有没有可显示条目,分页都要靠它继续向前。 - pub(crate) first_item_id: Option, -} - +/// 队列里的一个事件。`seq` 只服务内部游标,不下发:前端按 `consume` 返回的数组顺序处理。 #[derive(Clone, Debug)] struct StoredEvent { - event: DirectThreadRawEvent, + event: DirectThreadEvent, + seq: u64, bytes: usize, cleanable: bool, } @@ -97,15 +43,14 @@ struct ThreadState { total_bytes: usize, active_items: HashSet, unresolved_requests: HashSet, - /// 最新一条 `turn.started` / `turn.completed` 的独立拷贝。 + /// 最近一条 `turn.started` / `turn.completed` 的独立拷贝。 /// /// TODO(thread-manager): 这里有意只保留"锚点",因为 replay 队列会回收可回收事件, /// 队列本身不是完美事件日志——被回收的 `turn.started` / `turn.completed` 不会回放, /// 只有这份拷贝保证新订阅仍能判定"最新回合是否还在跑"。若将来需要回放多个回合的 /// 生命周期(回合账本、跨进程恢复、按回合统计),必须另建持久 ledger, /// 不能靠扩大这份拷贝或放宽回收规则来模拟。 - lifecycle_anchor: Option, - last_completed_item_id: Option, + lifecycle_anchor: Option<(u64, DirectThreadEvent)>, subscribers: HashMap, } @@ -119,7 +64,6 @@ impl Default for ThreadState { active_items: HashSet::new(), unresolved_requests: HashSet::new(), lifecycle_anchor: None, - last_completed_item_id: None, subscribers: HashMap::new(), } } @@ -154,34 +98,29 @@ impl DirectThreadManager { pub(crate) fn append( &mut self, thread_id: &str, - draft: DirectThreadRawEventDraft, - ) -> DirectThreadRawEvent { + event: DirectThreadEvent, + ) -> DirectThreadEvent { let thread = self.threads.entry(thread_id.to_string()).or_default(); thread.next_seq = thread.next_seq.saturating_add(1); - let event = DirectThreadRawEvent { - seq: thread.next_seq, - event_type: draft.event_type, - turn_id: draft.turn_id, - item_id: draft.item_id, - call_id: draft.call_id, - payload: draft.payload, - }; - let cleanable = Self::observe_event(thread, &event); + let seq = thread.next_seq; + let cleanable = Self::observe_event(thread, seq, &event); let bytes = serde_json::to_vec(&event) .map(|value| value.len()) .unwrap_or_default(); thread.total_bytes = thread.total_bytes.saturating_add(bytes); thread.events.push(StoredEvent { event: event.clone(), + seq, bytes, cleanable, }); - Self::mark_item_events_cleanable(thread, event.item_identity()); - if matches!( - event.event_type.as_str(), - "approval.resolved" | "request.resolved" | "ask.resolved" - ) { - Self::mark_request_events_cleanable(thread, request_id(&event).as_deref()); + if let Some(item_id) = event.item_id() { + Self::mark_item_events_cleanable(thread, item_id, seq); + } + if let Some(kind) = event.request_kind() { + if kind.is_resolution() { + Self::mark_request_events_cleanable(thread, event.request_id(), seq); + } } self.evict(thread_id); event @@ -199,19 +138,20 @@ impl DirectThreadManager { .events .iter() .skip(thread.head) - .filter(|stored| Self::is_bootstrap_event(thread, &stored.event, stored.cleanable)) - .map(|stored| stored.event.clone()) + .filter(|stored| Self::is_bootstrap_event(thread, stored)) + .map(|stored| (stored.seq, stored.event.clone())) .collect::>(); - if let Some(anchor) = thread.lifecycle_anchor.as_ref() { - if !events.iter().any(|event| event.seq == anchor.seq) { - events.push(anchor.clone()); + // 生命周期锚点独立保存:即使队列里那条事件已被回收,也要作为 bootstrap 事件返回。 + if let Some((anchor_seq, anchor)) = thread.lifecycle_anchor.as_ref() { + if !events.iter().any(|(seq, _)| seq == anchor_seq) { + events.push((*anchor_seq, anchor.clone())); } } - events.sort_by_key(|event| event.seq); + events.sort_by_key(|(seq, _)| *seq); DirectThreadSubscriptionBootstrap { subscription_id, - last_completed_item_id: thread.last_completed_item_id.clone(), - events, + last_completed_item_id: None, + events: events.into_iter().map(|(_, event)| event).collect(), } } @@ -241,26 +181,28 @@ impl DirectThreadManager { let oldest_seq = thread .events .get(thread.head) - .map(|stored| stored.event.seq) + .map(|stored| stored.seq) .unwrap_or(thread.next_seq.saturating_add(1)); if cursor.saturating_add(1) < oldest_seq { thread.subscribers.remove(subscription_id); return Err(SUBSCRIPTION_EXPIRED.to_string()); } + let mut last_seq = cursor; let events = thread .events .iter() .skip(thread.head) - .filter(|stored| stored.event.seq > cursor) - .map(|stored| stored.event.clone()) + .filter(|stored| stored.seq > cursor) + .map(|stored| { + last_seq = stored.seq; + stored.event.clone() + }) .collect::>(); - if let Some(last) = events.last() { - thread - .subscribers - .get_mut(subscription_id) - .expect("subscriber remains registered") - .cursor = last.seq; - } + thread + .subscribers + .get_mut(subscription_id) + .expect("subscriber remains registered") + .cursor = last_seq; let result = DirectThreadConsumeResult { events }; Self::trim_prefix(thread); Ok(result) @@ -277,91 +219,76 @@ impl DirectThreadManager { }) } - fn observe_event(thread: &mut ThreadState, event: &DirectThreadRawEvent) -> bool { - match event.event_type.as_str() { - "item.started" => { - if let Some(identity) = event.item_identity() { - thread.active_items.insert(identity.to_string()); - } + /// 观察一条事件,返回它本身是否可回收。 + fn observe_event(thread: &mut ThreadState, seq: u64, event: &DirectThreadEvent) -> bool { + match event { + DirectThreadEvent::ItemStarted { item, .. } => { + thread.active_items.insert(item.item_id().to_string()); false } - "item.completed" => { - if let Some(identity) = event.item_identity() { - thread.active_items.remove(identity); - } - // 历史锚点必须是 project.jsonl 里的 response item id,不能用 call id。 - if let Some(item_id) = event.item_id.as_deref() { - thread.last_completed_item_id = Some(item_id.to_string()); - } + DirectThreadEvent::ItemCompleted { item, .. } => { + thread.active_items.remove(item.item_id()); true } - "turn.started" | "turn.completed" => { + DirectThreadEvent::TurnStarted { .. } | DirectThreadEvent::TurnCompleted { .. } => { // 队列只保留最新一条生命周期事件,更早的可能已被回收;见 // `ThreadState::lifecycle_anchor` 的 TODO:这不是完整事件日志。 - thread.lifecycle_anchor = Some(event.clone()); + thread.lifecycle_anchor = Some((seq, event.clone())); true } - "approval.requested" | "request.requested" | "ask.requested" => { - let request_id = request_id(event); + DirectThreadEvent::Request { + kind, request_id, .. + } => { + if kind.is_request() { + if let Some(request_id) = request_id.as_deref() { + thread.unresolved_requests.insert(request_id.to_string()); + } + // 没有 request id 的请求事件无法配对,直接视为可回收。 + return request_id.is_none(); + } if let Some(request_id) = request_id.as_deref() { - thread.unresolved_requests.insert(request_id.to_string()); - } - request_id.is_none() - } - "approval.resolved" | "request.resolved" | "ask.resolved" => { - if let Some(request_id) = request_id(event) { - thread.unresolved_requests.remove(&request_id); + thread.unresolved_requests.remove(request_id); } true } - _ => true, + DirectThreadEvent::ItemDelta { .. } => true, } } - fn is_bootstrap_event( - thread: &ThreadState, - event: &DirectThreadRawEvent, - cleanable: bool, - ) -> bool { - if thread - .lifecycle_anchor - .as_ref() - .is_some_and(|anchor| anchor.seq == event.seq) - { - return true; + /// 新订阅此刻需要补的事件:未完成 item 的完整事件、未解决请求、不可回收的事件。 + fn is_bootstrap_event(thread: &ThreadState, stored: &StoredEvent) -> bool { + if let Some(item_id) = stored.event.item_id() { + return thread.active_items.contains(item_id); } - if let Some(identity) = event.item_identity() { - return thread.active_items.contains(identity); + if let Some(request_id) = stored.event.request_id() { + return thread.unresolved_requests.contains(request_id); } - if let Some(request_id) = request_id(event) { - return thread.unresolved_requests.contains(&request_id); - } - !cleanable + // 生命周期锚点单独补,增量正文这类瞬时事件不回放。 + !stored.cleanable } - fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: Option<&str>) { - let Some(item_id) = item_id else { - return; - }; + fn mark_item_events_cleanable(thread: &mut ThreadState, item_id: &str, seq: u64) { if thread.active_items.contains(item_id) { return; } for stored in &mut thread.events { - if stored.event.item_id.as_deref() == Some(item_id) { + if stored.seq <= seq && stored.event.item_id() == Some(item_id) { stored.cleanable = true; } } } - fn mark_request_events_cleanable(thread: &mut ThreadState, resolved_request_id: Option<&str>) { - let Some(resolved_request_id) = resolved_request_id else { + fn mark_request_events_cleanable(thread: &mut ThreadState, request_id: Option<&str>, seq: u64) { + let Some(resolved_request_id) = request_id else { return; }; for stored in &mut thread.events { - if matches!( - stored.event.event_type.as_str(), - "approval.requested" | "request.requested" | "ask.requested" - ) && request_id(&stored.event).as_deref() == Some(resolved_request_id) + if stored.seq <= seq + && stored + .event + .request_kind() + .is_some_and(|kind| kind.is_request()) + && stored.event.request_id() == Some(resolved_request_id) { stored.cleanable = true; } @@ -379,7 +306,7 @@ impl DirectThreadManager { let can_pop = thread .events .get(thread.head) - .is_some_and(|stored| stored.event.seq <= min_cursor && stored.cleanable); + .is_some_and(|stored| stored.seq <= min_cursor && stored.cleanable); if !can_pop { break; } @@ -419,7 +346,7 @@ impl DirectThreadManager { let oldest_seq = thread .events .get(thread.head) - .map(|stored| stored.event.seq) + .map(|stored| stored.seq) .unwrap_or(thread.next_seq.saturating_add(1)); let slowest = thread .subscribers @@ -448,13 +375,13 @@ pub(crate) fn set_direct_thread_manager_app_handle(app: tauri::AppHandle) { pub(crate) fn append_direct_thread_event( thread_id: &str, - draft: DirectThreadRawEventDraft, -) -> DirectThreadRawEvent { + event: DirectThreadEvent, +) -> DirectThreadEvent { let (event, subscriber_ids) = { let mut manager = global_direct_thread_manager() .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - let event = manager.append(thread_id, draft); + let event = manager.append(thread_id, event); let subscriber_ids = manager.subscriber_ids(thread_id); (event, subscriber_ids) }; @@ -486,84 +413,48 @@ pub(crate) fn consume_direct_thread( .consume(subscription_id) } -fn request_id(event: &DirectThreadRawEvent) -> Option { - event - .payload - .get("requestId") - .and_then(Value::as_str) - .or_else(|| event.payload.get("id").and_then(Value::as_str)) - .filter(|value| !value.is_empty()) - .map(str::to_string) -} - #[cfg(test)] mod tests { use super::*; + use crate::agent::{DirectThreadDeltaKind, DirectThreadItem, DirectThreadRequestKind}; - fn draft(event_type: &str, turn_id: &str, item_id: Option<&str>) -> DirectThreadRawEventDraft { - DirectThreadRawEventDraft { - event_type: event_type.to_string(), - turn_id: turn_id.to_string(), - item_id: item_id.map(str::to_string), - call_id: None, - payload: serde_json::json!({}), + fn message(item_id: &str) -> DirectThreadItem { + DirectThreadItem::Message { + item_id: item_id.to_string(), + role: "assistant".to_string(), + text: "内容".to_string(), + at: 0, } } - /// 工具条目的两个 id 空间必须靠 `callId` 对齐:app-server `item.started` 的 itemId 是调用 id, - /// 原始 item 的 `item.completed` 的 itemId 是 response item id。 - fn draft_with_call_id( - event_type: &str, - turn_id: &str, - item_id: Option<&str>, - call_id: Option<&str>, - ) -> DirectThreadRawEventDraft { - DirectThreadRawEventDraft { - event_type: event_type.to_string(), - turn_id: turn_id.to_string(), - item_id: item_id.map(str::to_string), - call_id: call_id.map(str::to_string), - payload: serde_json::json!({}), - } + fn item_started(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_started(message(item_id)) } - #[test] - fn call_id_joins_started_and_completed_across_id_spaces() { - let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append( - "thread-1", - draft_with_call_id("item.started", "turn-1", Some("call-1"), None), - ); - manager.append( - "thread-1", - draft_with_call_id("item.completed", "turn-1", Some("item-9"), Some("call-1")), - ); - // 活跃条目按合并身份清理,不留下永远收不到完成事件的幽灵条目。 - let bootstrap = manager.subscribe("thread-1"); - assert!( - bootstrap - .events - .iter() - .all(|event| event.event_type != "item.started"), - "已完成的条目不得再作为运行态事件回到 bootstrap" - ); - // 没有订阅者时,可回收事件全部被回收;历史锚点靠独立字段保留,不占队列。 - assert_eq!( - manager.thread_debug("thread-1").map(|debug| debug.0), - Some(0) - ); - // 历史锚点仍然是 response item id,前端才能拿它当分页锚点。 - assert_eq!(bootstrap.last_completed_item_id.as_deref(), Some("item-9")); + fn item_completed(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_completed(message(item_id)) + } + + fn item_delta(item_id: &str) -> DirectThreadEvent { + DirectThreadEvent::item_delta( + item_id.to_string(), + DirectThreadDeltaKind::Message, + "增量".to_string(), + ) + } + + fn request(kind: DirectThreadRequestKind, request_id: Option<&str>) -> DirectThreadEvent { + DirectThreadEvent::request(kind, request_id.map(str::to_string)) } #[test] fn subscribers_have_independent_cursors_on_one_global_queue() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.started", "turn-1", None)); + manager.append("thread-1", DirectThreadEvent::turn_started()); let first = manager.subscribe("thread-1"); let second = manager.subscribe("thread-1"); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); let first_batch = manager .consume(&first.subscription_id) @@ -583,52 +474,49 @@ mod tests { #[test] fn bootstrap_contains_lifecycle_anchor_and_unfinished_events_only() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.started", "turn-1", None)); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append( - "thread-1", - draft("item.completed", "turn-1", Some("item-1")), - ); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-2"))); + manager.append("thread-1", DirectThreadEvent::turn_started()); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); + manager.append("thread-1", item_completed("item-1")); + manager.append("thread-1", item_started("item-2")); let bootstrap = manager.subscribe("thread-1"); - assert_eq!(bootstrap.last_completed_item_id.as_deref(), Some("item-1")); - assert_eq!( - bootstrap - .events - .iter() - .map(|event| event.event_type.as_str()) - .collect::>(), - vec!["turn.started", "item.started"] - ); + assert!(matches!( + bootstrap.events.as_slice(), + [ + DirectThreadEvent::TurnStarted {}, + DirectThreadEvent::ItemStarted { item, .. }, + ] if item.item_id() == "item-2" + )); } #[test] fn completion_releases_item_events_only_after_the_completion_event_is_appended() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); + manager.append("thread-1", item_delta("item-1")); let bootstrap = manager.subscribe("thread-1"); - manager.append( - "thread-1", - draft("item.completed", "turn-1", Some("item-1")), - ); + manager.append("thread-1", item_completed("item-1")); let events = manager .consume(&bootstrap.subscription_id) .expect("consume completion") .events; - assert_eq!(events.len(), 1); - assert_eq!(events[0].event_type, "item.completed"); + assert!(matches!( + events.as_slice(), + [DirectThreadEvent::ItemCompleted { item, .. }] if item.item_id() == "item-1" + )); } #[test] fn slow_subscriber_is_expired_when_queue_limit_is_reached() { let mut manager = DirectThreadManager::with_limits(2, 100_000); let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); + for _ in 0..3 { + manager.append( + "thread-1", + request(DirectThreadRequestKind::RequestResolved, None), + ); + } assert_eq!( manager.consume(&subscription.subscription_id), Err(SUBSCRIPTION_EXPIRED.to_string()) @@ -638,11 +526,11 @@ mod tests { #[test] fn current_subscriber_is_not_expired_by_pinned_queue_head() { let mut manager = DirectThreadManager::with_limits(2, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); + manager.append("thread-1", item_started("item-1")); let subscription = manager.subscribe("thread-1"); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("item.delta", "turn-1", Some("item-1"))); + for _ in 0..3 { + manager.append("thread-1", item_delta("item-1")); + } assert_ne!( manager.consume(&subscription.subscription_id), Err(SUBSCRIPTION_EXPIRED.to_string()) @@ -654,25 +542,16 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.requested".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - call_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request( + DirectThreadRequestKind::ApprovalRequested, + Some("request-1"), + ), ); let bootstrap = manager.subscribe("thread-1"); assert_eq!(bootstrap.events.len(), 1); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.resolved".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - call_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request(DirectThreadRequestKind::RequestResolved, Some("request-1")), ); assert_eq!( manager @@ -689,24 +568,15 @@ mod tests { let mut manager = DirectThreadManager::with_limits(100, 100_000); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.requested".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - call_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request( + DirectThreadRequestKind::ApprovalRequested, + Some("request-1"), + ), ); let subscription = manager.subscribe("thread-1"); manager.append( "thread-1", - DirectThreadRawEventDraft { - event_type: "approval.resolved".to_string(), - turn_id: "turn-1".to_string(), - item_id: None, - call_id: None, - payload: serde_json::json!({"requestId": "request-1"}), - }, + request(DirectThreadRequestKind::RequestResolved, Some("request-1")), ); manager .consume(&subscription.subscription_id) @@ -717,17 +587,25 @@ mod tests { #[test] fn turn_completed_anchor_survives_empty_queue_for_new_subscriber() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("turn.completed", "turn-1", None)); + manager.append( + "thread-1", + DirectThreadEvent::turn_completed("completed".to_string()), + ); let bootstrap = manager.subscribe("thread-1"); - assert_eq!(bootstrap.events.len(), 1); - assert_eq!(bootstrap.events[0].event_type, "turn.completed"); + assert!(matches!( + bootstrap.events.as_slice(), + [DirectThreadEvent::TurnCompleted { status }] if status == "completed" + )); } #[test] fn queue_cleanup_only_removes_a_cleanable_prefix() { let mut manager = DirectThreadManager::with_limits(100, 100_000); - manager.append("thread-1", draft("item.started", "turn-1", Some("item-1"))); - manager.append("thread-1", draft("approval.resolved", "turn-1", None)); + manager.append("thread-1", item_started("item-1")); + manager.append( + "thread-1", + request(DirectThreadRequestKind::RequestResolved, None), + ); let subscription = manager.subscribe("thread-1"); manager .consume(&subscription.subscription_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs deleted file mode 100644 index 2dc96f3fe..000000000 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_raw_item.rs +++ /dev/null @@ -1,581 +0,0 @@ -//! DirectProject 「原始条目」搬运层。 -//! -//! Thread Manager 只做搬运:这里把 Codex 原始 response item 与 app-server thread item -//! 收敛成同一形状的**脱敏原始条目**,只做三件事——挑字段、脱敏、截断。 -//! -//! 这里不生成工具卡片,不推导标题 / 折叠摘要 / 可见性 / 排序:`title`、`summary`、 -//! `kind` 和"哪些条目要显示"全部是前端投影的职责。运行态事件与历史切片共用本形状, -//! 前端因此只需要一套投影与一套合并规则。 - -use crate::agent::redact_secret_tokens; -use crate::agent::sanitize_error_context; -use crate::redact_absolute_path_tokens; -use serde::Serialize; -use serde_json::Value; -use std::path::Path; - -/// 正文(消息 / 思考)上限。 -const DIRECT_THREAD_RAW_TEXT_MAX_CHARS: usize = 8000; -/// 工具明细(命令 / 参数 / 输出)上限。 -const DIRECT_THREAD_RAW_DETAIL_MAX_CHARS: usize = 4000; -/// 单条变更路径上限。 -const DIRECT_THREAD_RAW_PATH_MAX_CHARS: usize = 300; - -/// 原始条目里的一条文件变更。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadRawChange { - pub(crate) path: String, - /// 原始 kind:`add` | `update` | `delete`(缺失时为 `update`)。 - pub(crate) kind: String, -} - -/// 前端唯一消费的条目形状:脱敏、截断后的 Codex 原始条目。 -#[derive(Clone, Debug, Eq, PartialEq, Serialize)] -#[serde(rename_all = "camelCase")] -pub(crate) struct DirectThreadRawItem { - /// `project.jsonl` 里的 response item id;app-server `item/started` 时是 app-server item id。 - pub(crate) item_id: String, - /// 工具条目的调用 id:两个 id 空间唯一对齐点(原始 item 的 `call_id`)。 - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) call_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) turn_id: Option, - /// Codex 原始 `type`:`message` / `reasoning` / `function_call` / `function_call_output` / - /// `commandExecution` / `fileChange` / `mcpToolCall` / `webSearch` / …(原样透传,不做映射)。 - pub(crate) item_type: String, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) role: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) text: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) name: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) arguments: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) output: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) command: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) tool: Option, - #[serde(skip_serializing_if = "Vec::is_empty")] - pub(crate) changes: Vec, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) item_status: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) exit_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) success: Option, - /// 协议事实:来自 `item/completed` / `rawResponseItem/completed` 为 true。 - pub(crate) completed: bool, - /// 毫秒时间;条目自带时间与记录时间都拿不到时为 0。 - pub(crate) at: u64, -} - -fn bounded(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 -} - -/// 原始值转文本:字符串原样,其它 JSON 值序列化。 -fn value_text(value: &Value) -> Option { - match value { - Value::Null => None, - Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), - other => serde_json::to_string_pretty(other).ok(), - } -} - -fn detail_text(root: &Path, value: &str) -> String { - bounded( - &sanitize_detail_text(root, value), - DIRECT_THREAD_RAW_DETAIL_MAX_CHARS, - ) -} - -fn item_text(root: &Path, item: &Value) -> Option { - let raw = item - .get("text") - .and_then(Value::as_str) - .map(str::to_string) - .or_else(|| { - for key in ["content", "summary"] { - let Some(parts) = item.get(key).and_then(Value::as_array) else { - continue; - }; - let joined = parts - .iter() - .filter_map(|part| part.get("text").and_then(Value::as_str)) - .collect::>() - .join(""); - if !joined.trim().is_empty() { - return Some(joined); - } - } - None - }) - .filter(|text| !text.trim().is_empty())?; - Some(bounded( - &sanitize_detail_text(root, &raw), - DIRECT_THREAD_RAW_TEXT_MAX_CHARS, - )) -} - -fn item_turn_id(item: &Value) -> Option { - let from_metadata = item - .get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("turn_id").or_else(|| meta.get("turnId"))) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - if from_metadata.is_some() { - return from_metadata; - } - // 用户条目没有元数据,但 id 里带回合:`direct-codex::user`。 - let rest = item - .get("id") - .and_then(Value::as_str)? - .trim() - .strip_prefix("direct-codex:")?; - let turn = rest.rsplit_once(':')?.0.trim(); - (!turn.is_empty()).then(|| turn.to_string()) -} - -fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { - let from_metadata = item - .get("internal_chat_message_metadata_passthrough") - .and_then(|meta| meta.get("create_time")) - .and_then(Value::as_f64) - .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) - .unwrap_or_default(); - if from_metadata > 0 { - return from_metadata; - } - for key in ["startedAtMs", "completedAtMs"] { - let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); - if value > 0 { - return value; - } - } - observed_at_ms -} - -fn item_identity(item: &Value) -> Option<(String, Option)> { - let call_id = item - .get("call_id") - .or_else(|| item.get("callId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - let id = item - .get("id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string); - match (id, call_id) { - (Some(id), call_id) => Some((id, call_id)), - (None, Some(call_id)) => Some((call_id.clone(), Some(call_id))), - (None, None) => None, - } -} - -fn item_changes(root: &Path, 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(DirectThreadRawChange { - path: bounded( - &sanitize_detail_text(root, path), - DIRECT_THREAD_RAW_PATH_MAX_CHARS, - ), - kind: change - .get("kind") - .and_then(Value::as_str) - .unwrap_or("update") - .to_string(), - }) - }) - .collect::>() - }) - .unwrap_or_default() -} - -/// 把一条 Codex 条目搬运成脱敏原始条目;拿不到身份或类型时返回 `None`。 -/// -/// `observed_at_ms` 只作为条目自带时间缺失时的兜底(运行态用当前时间,历史用文件记录时间)。 -pub(crate) fn direct_thread_raw_item_from_value( - root: &Path, - item: &Value, - turn_id: Option<&str>, - completed: bool, - observed_at_ms: u64, -) -> Option { - if !item.is_object() { - return None; - } - let (item_id, call_id) = item_identity(item)?; - let item_type = item - .get("type") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty())? - .to_string(); - let turn_id = turn_id - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .or_else(|| item_turn_id(item)); - let at = item_at_ms(item, observed_at_ms); - - let text = match item_type.as_str() { - "message" | "agentMessage" | "userMessage" | "reasoning" => item_text(root, item), - _ => None, - }; - let arguments = item - .get("arguments") - .and_then(value_text) - .map(|value| detail_text(root, &value)); - let output = ["output", "aggregatedOutput", "result", "error"] - .iter() - .find_map(|key| item.get(key).and_then(value_text)) - .map(|value| detail_text(root, &value)); - let command = item - .get("command") - .and_then(Value::as_str) - .map(str::trim) - .filter(|command| !command.is_empty()) - .map(|command| detail_text(root, command)); - - Some(DirectThreadRawItem { - item_id, - call_id, - turn_id, - item_type, - role: item - .get("role") - .and_then(Value::as_str) - .map(str::trim) - .filter(|role| !role.is_empty()) - .map(str::to_string), - text, - name: item - .get("name") - .and_then(Value::as_str) - .map(str::trim) - .filter(|name| !name.is_empty()) - .map(str::to_string), - arguments, - output, - command, - tool: item - .get("tool") - .and_then(Value::as_str) - .map(str::trim) - .filter(|tool| !tool.is_empty()) - .map(|tool| sanitize_detail_text(root, tool)), - changes: item_changes(root, item), - item_status: item - .get("status") - .and_then(Value::as_str) - .map(str::trim) - .filter(|status| !status.is_empty()) - .map(str::to_string), - exit_code: item.get("exitCode").and_then(Value::as_i64), - success: item.get("success").and_then(Value::as_bool), - completed, - at, - }) -} - -/// 历史切片搬运:保持文件顺序,不做任何合并。 -/// -/// 同一调用的 `function_call` 与 `function_call_output` 是两条原始条目,合并属于前端。 -/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 -pub(crate) fn direct_thread_raw_items_from_history( - root: &Path, - items: &[Value], - timestamp_of: impl Fn(&Value) -> u64, -) -> Vec { - items - .iter() - .filter_map(|item| { - direct_thread_raw_item_from_value(root, item, None, true, timestamp_of(item)) - }) - .collect() -} - -/// 项目根目录之后的路径 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,再处理密钥。 -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) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; - use std::path::Path; - - fn root() -> &'static Path { - Path::new(".") - } - - #[test] - fn message_item_carries_role_text_and_turn() { - let item = direct_thread_raw_item_from_value( - root(), - &json!({ - "id": "direct-codex:turn-1:user", - "type": "message", - "role": "user", - "content": [{"type": "input_text", "text": "做一个拼图游戏"}], - }), - None, - true, - 0, - ) - .expect("user item"); - assert_eq!(item.item_type, "message"); - assert_eq!(item.role.as_deref(), Some("user")); - assert_eq!(item.text.as_deref(), Some("做一个拼图游戏")); - assert_eq!(item.turn_id.as_deref(), Some("turn-1")); - } - - #[test] - fn tool_item_keeps_call_id_and_raw_command() { - let item = direct_thread_raw_item_from_value( - root(), - &json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, - }), - None, - true, - 1000, - ) - .expect("tool item"); - assert_eq!(item.item_type, "function_call"); - assert_eq!( - item.call_id.as_deref(), - Some("call_00_Gpd0s0Ytm9YgIbwbEXva1473") - ); - assert_eq!(item.name.as_deref(), Some("exec_command")); - assert_eq!(item.arguments.as_deref(), Some("{\"cmd\": \"ls\"}")); - assert_eq!(item.turn_id.as_deref(), Some("turn-1")); - } - - #[test] - fn reasoning_item_keeps_text_without_role() { - let item = direct_thread_raw_item_from_value( - root(), - &json!({ - "id": "reason-1", - "type": "reasoning", - "content": [{"type": "output_text", "text": "先看目录"}], - }), - Some("turn-1"), - true, - 0, - ) - .expect("reasoning item"); - assert_eq!(item.item_type, "reasoning"); - assert_eq!(item.role, None); - assert_eq!(item.text.as_deref(), Some("先看目录")); - } - - #[test] - fn secrets_and_absolute_paths_are_not_leaked() { - let item = direct_thread_raw_item_from_value( - root(), - &json!({ - "id": "msg-1", - "type": "message", - "role": "assistant", - "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], - }), - Some("turn-1"), - true, - 0, - ) - .expect("assistant item"); - let text = item.text.unwrap_or_default(); - assert!( - !text.contains("sk-abcdefghijklmnop"), - "不得泄漏明文密钥:{text}" - ); - assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); - } - - #[test] - fn history_keeps_call_and_output_as_two_raw_items() { - let items = vec![ - json!({ - "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call", - "name": "exec_command", - "arguments": "{\"cmd\": \"ls\"}", - }), - json!({ - "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", - "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", - "type": "function_call_output", - "output": "assets\ngame\n", - }), - ]; - let projected = direct_thread_raw_items_from_history(root(), &items, |_| 0); - assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); - assert_eq!(projected[0].item_type, "function_call"); - assert_eq!(projected[1].item_type, "function_call_output"); - assert_eq!( - projected[0].call_id.as_deref(), - projected[1].call_id.as_deref() - ); - } - - #[test] - fn system_role_is_passed_through_for_frontend_to_filter() { - let item = direct_thread_raw_item_from_value( - root(), - &json!({"id": "sys-1", "type": "message", "role": "system", "content": [{"type": "input_text", "text": "x"}]}), - Some("turn-1"), - true, - 0, - ) - .expect("system item"); - assert_eq!(item.role.as_deref(), Some("system")); - assert_eq!(item.item_type, "message"); - } -} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs new file mode 100644 index 000000000..d9374a3a0 --- /dev/null +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_thread_wire.rs @@ -0,0 +1,864 @@ +//! DirectProject 聊天事件的线上模型与投影。 +//! +//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/generated/`, +//! 与 Rust 定义同源:加一个字段不会只改一边。 +//! +//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与 +//! 合并规则全部属于前端投影,这里一概不出现。 +//! +//! 条目身份在进队列前就归一成**一个** `itemId`:原始文件里工具条目带两个 id(app-server +//! 的调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次, +//! Thread Manager 与前端都只认这一个,不暴露第二个 id 概念。 +//! 历史分页锚点是另一回事,那是文件里的原始 item id,单独取。 + +use crate::agent::redact_secret_tokens; +use crate::agent::sanitize_error_context; +use crate::redact_absolute_path_tokens; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::path::Path; +use ts_rs::TS; + +/// 正文(消息 / 思考)上限。 +const DIRECT_THREAD_TEXT_MAX_CHARS: usize = 8_000; +/// 工具明细(命令 / 参数 / 输出)上限。 +const DIRECT_THREAD_DETAIL_MAX_CHARS: usize = 4_000; +/// 单条变更路径上限。 +const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300; + +/// 一条文件变更。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadFileChange { + pub(crate) path: String, + /// `add` | `update` | `delete` + pub(crate) kind: String, +} + +/// 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 +/// +/// `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 +/// 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。 +/// +/// 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, +/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadItem { + #[serde(rename = "message")] + Message { + /// 归一身份:全链路只有这一个 id。 + item_id: String, + /// 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + role: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "reasoning")] + Reasoning { + item_id: String, + text: String, + #[ts(as = "f64")] + at: u64, + }, + /// 原始 response item 的工具调用:参数在 `arguments`,输出在后续的 + /// [`DirectThreadItem::FunctionCallOutput`](两者共用归一身份)。 + #[serde(rename = "function_call")] + FunctionCall { + item_id: String, + name: String, + arguments: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "function_call_output")] + FunctionCallOutput { + item_id: String, + output: String, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "commandExecution")] + CommandExecution { + item_id: String, + command: String, + #[serde(default)] + output: Option, + /// app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + #[serde(default)] + status: Option, + #[serde(default)] + #[ts(as = "Option")] + exit_code: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "fileChange")] + FileChange { + item_id: String, + changes: Vec, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "mcpToolCall")] + McpToolCall { + item_id: String, + tool: String, + arguments: String, + #[serde(default)] + output: Option, + #[serde(default)] + status: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "webSearch")] + WebSearch { + item_id: String, + #[serde(default)] + query: Option, + #[serde(default)] + output: Option, + #[ts(as = "f64")] + at: u64, + }, + #[serde(rename = "contextCompaction")] + ContextCompaction { + item_id: String, + #[ts(as = "f64")] + at: u64, + }, + /// 未识别的 Codex item 类型:原样透传身份与类型,不投影正文。 + #[serde(rename = "other")] + Other { + item_id: String, + raw_type: String, + #[ts(as = "f64")] + at: u64, + }, +} + +impl DirectThreadItem { + /// 归一身份:Thread Manager 用它登记与释放未完成条目,前端用它合并同一张卡片。 + pub(crate) fn item_id(&self) -> &str { + match self { + Self::Message { item_id, .. } + | Self::Reasoning { item_id, .. } + | Self::FunctionCall { item_id, .. } + | Self::FunctionCallOutput { item_id, .. } + | Self::CommandExecution { item_id, .. } + | Self::FileChange { item_id, .. } + | Self::McpToolCall { item_id, .. } + | Self::WebSearch { item_id, .. } + | Self::ContextCompaction { item_id, .. } + | Self::Other { item_id, .. } => item_id, + } + } +} + +/// 增量正文属于哪类条目。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadDeltaKind { + /// assistant 正文。 + Message, + /// 思考正文。 + Reasoning, +} + +/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadRequestKind { + #[serde(rename = "approval.requested")] + ApprovalRequested, + #[serde(rename = "ask.requested")] + AskRequested, + #[serde(rename = "request.resolved")] + RequestResolved, +} + +impl DirectThreadRequestKind { + /// 未解决的请求要留在 bootstrap 里,直到出现对应的解决事件。 + pub(crate) fn is_request(&self) -> bool { + matches!(self, Self::ApprovalRequested | Self::AskRequested) + } + + pub(crate) fn is_resolution(&self) -> bool { + !self.is_request() + } +} + +/// Thread Manager 下发的运行态事件。 +/// +/// 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: +/// 游标是 Thread Manager 的内部事实,不下发。 +/// +/// 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 +/// 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) enum DirectThreadEvent { + #[serde(rename = "turn.started")] + TurnStarted, + #[serde(rename = "turn.completed")] + TurnCompleted { status: String }, + #[serde(rename = "item.started")] + ItemStarted { item: DirectThreadItem }, + #[serde(rename = "item.completed")] + ItemCompleted { item: DirectThreadItem }, + #[serde(rename = "item.delta")] + ItemDelta { + item_id: String, + kind: DirectThreadDeltaKind, + delta: String, + }, + #[serde(rename = "request")] + Request { + kind: DirectThreadRequestKind, + #[serde(default)] + request_id: Option, + }, +} + +impl DirectThreadEvent { + pub(crate) fn turn_started() -> Self { + Self::TurnStarted + } + + pub(crate) fn turn_completed(status: String) -> Self { + Self::TurnCompleted { status } + } + + pub(crate) fn item_started(item: DirectThreadItem) -> Self { + Self::ItemStarted { item } + } + + pub(crate) fn item_completed(item: DirectThreadItem) -> Self { + Self::ItemCompleted { item } + } + + pub(crate) fn item_delta(item_id: String, kind: DirectThreadDeltaKind, delta: String) -> Self { + Self::ItemDelta { + item_id, + kind, + delta, + } + } + + pub(crate) fn request(kind: DirectThreadRequestKind, request_id: Option) -> Self { + Self::Request { kind, request_id } + } + + /// 事件关联的条目身份:只有 item 事件有。 + pub(crate) fn item_id(&self) -> Option<&str> { + match self { + Self::ItemStarted { item, .. } | Self::ItemCompleted { item, .. } => { + Some(item.item_id()) + } + _ => None, + } + } + + pub(crate) fn request_id(&self) -> Option<&str> { + match self { + Self::Request { request_id, .. } => request_id.as_deref(), + _ => None, + } + } + + pub(crate) fn request_kind(&self) -> Option { + match self { + Self::Request { kind, .. } => Some(*kind), + _ => None, + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadSubscriptionBootstrap { + pub(crate) subscription_id: String, + /// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + #[serde(default)] + pub(crate) last_completed_item_id: Option, + /// 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadConsumeResult { + pub(crate) events: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))] +pub(crate) struct DirectThreadHistorySlice { + /// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + pub(crate) items: Vec, + pub(crate) has_more: bool, + /// 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + #[serde(default)] + pub(crate) first_item_id: Option, +} + +fn bounded(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 detail_text(root: &Path, value: &str) -> String { + bounded( + &sanitize_detail_text(root, value), + DIRECT_THREAD_DETAIL_MAX_CHARS, + ) +} + +/// 原始值转文本:字符串原样,其它 JSON 值序列化(调用方随后脱敏)。 +fn value_text(value: &Value) -> Option { + match value { + Value::Null => None, + Value::String(text) => (!text.trim().is_empty()).then(|| text.trim().to_string()), + other => serde_json::to_string_pretty(other).ok(), + } +} + +fn item_text(root: &Path, item: &Value) -> Option { + let raw = item + .get("text") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| { + for key in ["content", "summary"] { + let Some(parts) = item.get(key).and_then(Value::as_array) else { + continue; + }; + let joined = parts + .iter() + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join(""); + if !joined.trim().is_empty() { + return Some(joined); + } + } + None + }) + .filter(|text| !text.trim().is_empty())?; + Some(bounded( + &sanitize_detail_text(root, &raw), + DIRECT_THREAD_TEXT_MAX_CHARS, + )) +} + +fn item_at_ms(item: &Value, observed_at_ms: u64) -> u64 { + let from_metadata = item + .get("internal_chat_message_metadata_passthrough") + .and_then(|meta| meta.get("create_time")) + .and_then(Value::as_f64) + .map(|seconds| (seconds * 1000.0).clamp(0.0, u64::MAX as f64) as u64) + .unwrap_or_default(); + if from_metadata > 0 { + return from_metadata; + } + for key in ["startedAtMs", "completedAtMs"] { + let value = item.get(key).and_then(Value::as_u64).unwrap_or_default(); + if value > 0 { + return value; + } + } + observed_at_ms +} + +/// 归一身份:工具条目用工具调用 id,其它条目用自己的 `id`;只产出这一个值。 +pub(crate) fn direct_thread_item_identity(item: &Value) -> Option { + let call_id = item + .get("call_id") + .or_else(|| item.get("callId")) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + let id = item + .get("id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + call_id.or(id) +} + +fn item_changes(root: &Path, 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(DirectThreadFileChange { + path: bounded( + &sanitize_detail_text(root, path), + DIRECT_THREAD_PATH_MAX_CHARS, + ), + kind: change + .get("kind") + .and_then(Value::as_str) + .unwrap_or("update") + .to_string(), + }) + }) + .collect::>() + }) + .unwrap_or_default() +} + +fn field_text(root: &Path, item: &Value, key: &str) -> Option { + item.get(key) + .and_then(value_text) + .map(|value| detail_text(root, &value)) +} + +/// 把一条 Codex 原始条目投影成线上条目;拿不到身份或类型时返回 `None`。 +/// +/// `observed_at_ms` 只在条目自带时间缺失时兜底(运行态用当前时间,历史用文件记录时间)。 +pub(crate) fn direct_thread_item_from_value( + root: &Path, + item: &Value, + observed_at_ms: u64, +) -> Option { + if !item.is_object() { + return None; + } + let item_id = direct_thread_item_identity(item)?; + let item_type = item + .get("type") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty())?; + let at = item_at_ms(item, observed_at_ms); + let text = item_text(root, item); + let role = item + .get("role") + .and_then(Value::as_str) + .map(str::trim) + .filter(|role| !role.is_empty()) + .map(str::to_string); + + Some(match item_type { + "message" | "agentMessage" | "userMessage" => DirectThreadItem::Message { + item_id, + role: role.unwrap_or_else(|| { + if item_type == "userMessage" { + "user".to_string() + } else { + "assistant".to_string() + } + }), + text: text?, + at, + }, + "reasoning" => DirectThreadItem::Reasoning { + item_id, + text: text?, + at, + }, + "function_call" => DirectThreadItem::FunctionCall { + item_id, + name: item + .get("name") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(), + arguments: field_text(root, item, "arguments").unwrap_or_default(), + at, + }, + "function_call_output" => DirectThreadItem::FunctionCallOutput { + item_id, + output: field_text(root, item, "output").unwrap_or_default(), + at, + }, + "commandExecution" => DirectThreadItem::CommandExecution { + item_id, + command: item + .get("command") + .and_then(Value::as_str) + .map(str::trim) + .filter(|command| !command.is_empty()) + .map(|command| detail_text(root, command)) + .unwrap_or_default(), + output: ["aggregatedOutput", "output", "error"] + .iter() + .find_map(|key| field_text(root, item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + exit_code: item.get("exitCode").and_then(Value::as_i64), + at, + }, + "fileChange" => DirectThreadItem::FileChange { + item_id, + changes: item_changes(root, item), + at, + }, + "mcpToolCall" => DirectThreadItem::McpToolCall { + item_id, + tool: item + .get("tool") + .and_then(Value::as_str) + .map(str::trim) + .filter(|tool| !tool.is_empty()) + .map(|tool| sanitize_detail_text(root, tool)) + .unwrap_or_default(), + arguments: field_text(root, item, "arguments").unwrap_or_default(), + output: ["result", "error"] + .iter() + .find_map(|key| field_text(root, item, key)), + status: item + .get("status") + .and_then(Value::as_str) + .map(str::to_string), + at, + }, + "webSearch" => DirectThreadItem::WebSearch { + item_id, + query: field_text(root, item, "query").or_else(|| { + item.get("action") + .and_then(|action| action.get("query")) + .and_then(value_text) + .map(|query| detail_text(root, &query)) + }), + output: field_text(root, item, "output"), + at, + }, + "contextCompaction" => DirectThreadItem::ContextCompaction { item_id, at }, + other => DirectThreadItem::Other { + item_id, + raw_type: other.to_string(), + at, + }, + }) +} + +/// 历史切片投影:保持文件顺序,不做任何合并(同一调用的调用与输出是两条条目)。 +/// +/// `timestamp_of` 是文件记录时间,仅在条目自带时间缺失时兜底。 +pub(crate) fn direct_thread_items_from_history( + root: &Path, + items: &[Value], + timestamp_of: impl Fn(&Value) -> u64, +) -> Vec { + items + .iter() + .filter_map(|item| direct_thread_item_from_value(root, item, timestamp_of(item))) + .collect() +} + +/// 项目根目录之后的路径 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,再处理密钥。 +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) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::path::Path; + + fn root() -> &'static Path { + Path::new(".") + } + + #[test] + fn message_item_carries_role_text_and_turn() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "direct-codex:turn-1:user", + "type": "message", + "role": "user", + "content": [{"type": "input_text", "text": "做一个拼图游戏"}], + }), + 0, + ) + .expect("user item"); + assert_eq!( + item, + DirectThreadItem::Message { + item_id: "direct-codex:turn-1:user".to_string(), + role: "user".to_string(), + text: "做一个拼图游戏".to_string(), + at: 0, + } + ); + } + + #[test] + fn app_server_agent_message_defaults_to_assistant_role() { + let item = direct_thread_item_from_value( + root(), + &json!({"id": "msg-1", "type": "agentMessage", "text": "已执行"}), + 1000, + ) + .expect("agent message"); + assert!(matches!( + item, + DirectThreadItem::Message { role, .. } if role == "assistant" + )); + } + + #[test] + fn tool_item_identity_is_normalized_to_one_id() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + "internal_chat_message_metadata_passthrough": {"turn_id": "turn-1"}, + }), + 1000, + ) + .expect("tool item"); + // 只有唯一身份:工具条目在文件里的另一个 id 不再对外暴露。 + assert_eq!(item.item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + assert!(matches!( + item, + DirectThreadItem::FunctionCall { name, arguments, .. } + if name == "exec_command" && arguments == "{\"cmd\": \"ls\"}" + )); + } + + #[test] + fn command_execution_keeps_raw_status_and_exit_code() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "call-1", + "type": "commandExecution", + "command": "ls", + "status": "failed", + "exitCode": 2, + "aggregatedOutput": "boom", + }), + 0, + ) + .expect("command item"); + assert!(matches!( + item, + DirectThreadItem::CommandExecution { + command, + output: Some(output), + status: Some(status), + exit_code: Some(2), + .. + } if command == "ls" && output == "boom" && status == "failed" + )); + } + + #[test] + fn secrets_and_absolute_paths_are_not_leaked() { + let item = direct_thread_item_from_value( + root(), + &json!({ + "id": "msg-1", + "type": "message", + "role": "assistant", + "content": [{"type": "output_text", "text": "key=sk-abcdefghijklmnop at /root/secret/x"}], + }), + 0, + ) + .expect("assistant item"); + let DirectThreadItem::Message { text, .. } = item else { + panic!("message item"); + }; + assert!( + !text.contains("sk-abcdefghijklmnop"), + "不得泄漏明文密钥:{text}" + ); + assert!(!text.contains("/root/secret"), "不得泄漏绝对路径:{text}"); + } + + #[test] + fn history_keeps_call_and_output_as_two_items_with_one_identity() { + let items = vec![ + json!({ + "id": "05dc0af1-8023-47fd-ad22-d54df2837b1b", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call", + "name": "exec_command", + "arguments": "{\"cmd\": \"ls\"}", + }), + json!({ + "id": "fco_01a06fa5-d636-7452-b337-a641c2e6bc76", + "call_id": "call_00_Gpd0s0Ytm9YgIbwbEXva1473", + "type": "function_call_output", + "output": "assets\ngame\n", + }), + ]; + let projected = direct_thread_items_from_history(root(), &items, |_| 0); + assert_eq!(projected.len(), 2, "搬运层不得替前端做合并"); + assert!(matches!( + projected[0], + DirectThreadItem::FunctionCall { .. } + )); + assert!(matches!( + projected[1], + DirectThreadItem::FunctionCallOutput { .. } + )); + // 调用与输出共享同一个归一身份,前端才能把它们并成一张卡片。 + assert_eq!(projected[0].item_id(), projected[1].item_id()); + assert_eq!(projected[0].item_id(), "call_00_Gpd0s0Ytm9YgIbwbEXva1473"); + } + + #[test] + fn unknown_item_types_are_passed_through_without_body() { + let item = direct_thread_item_from_value( + root(), + &json!({"id": "plan-1", "type": "plan", "text": "内部计划"}), + 0, + ) + .expect("unknown item"); + assert!(matches!( + item, + // TODO(direct-thread): 未知类型目前只带类型与身份,前端投影会丢弃它。 + // 哪些类型要显示属于前端可见性决策,需要时改前端,不要在这里加白名单。 + DirectThreadItem::Other { ref raw_type, .. } if raw_type == "plan" + )); + } +} 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 545b1fa11..f73471a56 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 @@ -8,7 +8,7 @@ //! 为什么不复用 `project.jsonl`:那条链路的回读只投影 `role ∈ {user, assistant}` 的 //! 文本条目,而且会被注入 Codex 上下文。往里面塞新形状既装不下,又有污染模型上下文的风险。 -use super::direct_thread_raw_item::sanitize_detail_text; +use super::direct_thread_wire::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}; 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 8dbdc185b..696aff810 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5388,7 +5388,7 @@ pub(crate) async fn read_direct_project_history_slice( .and_then(|item| item.get("id")) .and_then(serde_json::Value::as_str) .map(str::to_string); - let items = direct_thread_raw_items_from_history(root, &items, |item| { + let items = direct_thread_items_from_history(root, &items, |item| { item.get("id") .and_then(serde_json::Value::as_str) .and_then(|id| item_timestamps.get(id).copied()) diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts index 10c4e0f5a..eec5662e2 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -1,5 +1,4 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; export type DirectCodexUserContentPart = diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts index 4c9ea8ca3..aa1ee88dd 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -1,5 +1,9 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; -export type DirectCodexUserItem = DirectCodexUserMessageItem; +/** + * DirectProject 本轮 user input 的唯一结构化入口。 + */ +export type DirectCodexUserItem = { + type: 'message'; +} & DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts new file mode 100644 index 000000000..97e0b0dae --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectCodexUserItem } from './DirectCodexUserItem'; + +export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts index f7447dc5d..a1101c8cb 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -1,11 +1,9 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; import type { DirectCodexUserRole } from './DirectCodexUserRole'; export type DirectCodexUserMessageItem = { - type: 'message'; role: DirectCodexUserRole; - content: DirectCodexUserContentPart[]; + content: Array; id: string; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts index e1dc59c59..0c2540122 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -1,3 +1,3 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts index 296c0d19a..b134fb7bc 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -1,13 +1,13 @@ -// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRuntimeRegionPart = { label: string; - runId?: string; - versionId?: string; - elementTag?: string; - elementRole?: string; - text?: string; - width?: number; - height?: number; - resourceIds: string[]; + runId: string | null; + versionId: string | null; + elementTag: string | null; + elementRole: string | null; + text: string | null; + width: number | null; + height: number | null; + resourceIds: Array; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts new file mode 100644 index 000000000..ea7e6e112 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadConsumeResult.ts @@ -0,0 +1,4 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadEvent } from './DirectThreadEvent'; + +export type DirectThreadConsumeResult = { events: Array }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts new file mode 100644 index 000000000..215819e1a --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadDeltaKind.ts @@ -0,0 +1,6 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 增量正文属于哪类条目。 + */ +export type DirectThreadDeltaKind = 'message' | 'reasoning'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts new file mode 100644 index 000000000..a4b0d7688 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadEvent.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; +import type { DirectThreadItem } from './DirectThreadItem'; +import type { DirectThreadRequestKind } from './DirectThreadRequestKind'; + +/** + * Thread Manager 下发的运行态事件。 + * + * 顺序由数组顺序给出(同一个 subscriber 的 `consume` 按队列顺序返回),因此不需要 `seq`: + * 游标是 Thread Manager 的内部事实,不下发。 + * + * 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,"当前回合是否还在跑"由 + * 生命周期事件在序列中的位置给出,`turn_id` 对前端没有任何额外信息。 + */ +export type DirectThreadEvent = + | { type: 'turn.started' } + | { type: 'turn.completed'; status: string } + | { type: 'item.started'; item: DirectThreadItem } + | { type: 'item.completed'; item: DirectThreadItem } + | { + type: 'item.delta'; + itemId: string; + kind: DirectThreadDeltaKind; + delta: string; + } + | { + type: 'request'; + kind: DirectThreadRequestKind; + requestId: string | null; + }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts new file mode 100644 index 000000000..a510ccb68 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadFileChange.ts @@ -0,0 +1,12 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 一条文件变更。 + */ +export type DirectThreadFileChange = { + path: string; + /** + * `add` | `update` | `delete` + */ + kind: string; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts new file mode 100644 index 000000000..9ac30f139 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadHistorySlice.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadItem } from './DirectThreadItem'; + +export type DirectThreadHistorySlice = { + /** + * 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。 + */ + items: Array; + hasMore: boolean; + /** + * 本次切片的原始 item id 锚点:无论切片里有没有可显示条目,分页都靠它向前。 + */ + firstItemId: string | null; +}; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts new file mode 100644 index 000000000..f1257ddcc --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadItem.ts @@ -0,0 +1,76 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadFileChange } from './DirectThreadFileChange'; + +/** + * 聊天视图的输入条目:一条 Codex 原始条目的脱敏投影。 + * + * `itemType` 就是 Codex 的原始类型,逐字透传;前端按它决定投影成消息、思考还是工具卡片。 + * 未识别的类型走 [`DirectThreadItem::Other`],Rust 不替前端决定它是否可见。 + * + * 条目上的 `at` 是只用于显示的毫秒时间戳:ts-rs 默认把 `u64` 映射成 `bigint`, + * 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。 + */ +export type DirectThreadItem = + | { + itemType: 'message'; + /** + * 归一身份:全链路只有这一个 id。 + */ + itemId: string; + /** + * 原始 role(`user` / `assistant` / `system` / …);显示与否由前端判断。 + */ + role: string; + text: string; + at: number; + } + | { itemType: 'reasoning'; itemId: string; text: string; at: number } + | { + itemType: 'function_call'; + itemId: string; + name: string; + arguments: string; + at: number; + } + | { + itemType: 'function_call_output'; + itemId: string; + output: string; + at: number; + } + | { + itemType: 'commandExecution'; + itemId: string; + command: string; + output: string | null; + /** + * app-server 原始状态:`inProgress` / `completed` / `failed` / `declined` / … + */ + status: string | null; + exitCode: number | null; + at: number; + } + | { + itemType: 'fileChange'; + itemId: string; + changes: Array; + at: number; + } + | { + itemType: 'mcpToolCall'; + itemId: string; + tool: string; + arguments: string; + output: string | null; + status: string | null; + at: number; + } + | { + itemType: 'webSearch'; + itemId: string; + query: string | null; + output: string | null; + at: number; + } + | { itemType: 'contextCompaction'; itemId: string; at: number } + | { itemType: 'other'; itemId: string; rawType: string; at: number }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts new file mode 100644 index 000000000..13ee401f3 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadRequestKind.ts @@ -0,0 +1,9 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +/** + * 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。 + */ +export type DirectThreadRequestKind = + | 'approval.requested' + | 'ask.requested' + | 'request.resolved'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts new file mode 100644 index 000000000..45efbf4b4 --- /dev/null +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectThreadSubscriptionBootstrap.ts @@ -0,0 +1,14 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { DirectThreadEvent } from './DirectThreadEvent'; + +export type DirectThreadSubscriptionBootstrap = { + subscriptionId: string; + /** + * 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。 + */ + lastCompletedItemId: string | null; + /** + * 该 subscriber 此刻应当处理的运行态事件(游标已经在队尾)。 + */ + events: Array; +}; -- 2.52.0 From fdc48fe72516badabf7b6d88dc9098363c3716c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:49:26 +0800 Subject: [PATCH 10/11] =?UTF-8?q?=E5=89=8D=E7=AB=AF=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E6=8A=95=E5=BD=B1=E6=8C=89=20ts-rs=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=94=B6=E5=8F=A3=EF=BC=8C=E5=88=A0=E6=8E=89?= =?UTF-8?q?=20callId=20=E4=B8=8E=E5=9B=9E=E5=90=88=E8=BA=AB=E4=BB=BD=20-?= =?UTF-8?q?=20directThreadItemProjection.ts=20=E7=9B=B4=E6=8E=A5=E6=B6=88?= =?UTF-8?q?=E8=B4=B9=E7=94=9F=E6=88=90=E7=9A=84=20DirectThreadItem?= =?UTF-8?q?=EF=BC=8C=E5=B7=A5=E5=85=B7=E5=8D=A1=E7=89=87=20kind=20/=20?= =?UTF-8?q?=E6=A0=87=E9=A2=98=20/=20=E6=91=98=E8=A6=81=20/=20=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E4=B8=8E=E5=8F=AF=E8=A7=81=E6=80=A7=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E9=83=BD=E5=9C=A8=E8=BF=99=E9=87=8C=20-=20directThreadChat.ts?= =?UTF-8?q?=20=E8=81=8A=E5=A4=A9=E6=9D=A1=E7=9B=AE=E5=8F=AA=E7=95=99?= =?UTF-8?q?=E4=B8=80=E4=B8=AA=20itemId=EF=BC=9A=E5=8E=BB=E6=8E=89=20callId?= =?UTF-8?q?=20=E6=A6=82=E5=BF=B5=EF=BC=8C=E5=8E=BB=E6=8E=89=20deltaText=20?= =?UTF-8?q?=E7=BC=93=E5=86=B2=EF=BC=8C=E5=A2=9E=E9=87=8F=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E5=88=B0=E8=BF=90=E8=A1=8C=E6=80=81=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E6=AD=A3=E6=96=87=20-=20=E5=90=88=E5=B9=B6=E8=A7=84?= =?UTF-8?q?=E5=88=99=E6=94=B6=E6=88=90=E5=85=88=E5=88=B0=E8=80=85=E8=B5=A2?= =?UTF-8?q?=E3=80=81=E5=90=8E=E5=88=B0=E5=8F=AA=E8=A1=A5=E7=A9=BA=EF=BC=9A?= =?UTF-8?q?=E6=AD=A3=E6=96=87=E5=8F=AA=E5=A2=9E=E4=B8=8D=E5=87=8F=E3=80=81?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E7=8A=B6=E6=80=81=E5=85=81=E8=AE=B8=E4=BB=8E?= =?UTF-8?q?=20running=20=E5=8D=87=E7=BA=A7=E5=88=B0=E7=BB=88=E6=80=81=20-?= =?UTF-8?q?=20=E5=9B=9E=E5=90=88=E7=BB=93=E6=9D=9F=E6=8A=8A=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=E6=80=81=E6=9D=A1=E7=9B=AE=E5=B9=B6=E5=85=A5=E5=8E=86?= =?UTF-8?q?=E5=8F=B2=E5=B9=B6=E6=B8=85=E7=A9=BA=20live=EF=BC=8C=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E6=97=A2=E4=B8=8D=E6=B6=88=E5=A4=B1=E4=B9=9F=E4=B8=8D?= =?UTF-8?q?=E9=87=8D=E5=A4=8D=20-=20directThreadEvents.ts=20=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E8=BD=AC=E5=8F=91=20ts-rs=20=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=EF=BC=8C=E5=8F=AA=E4=BF=9D=E7=95=99=E5=8E=86=E5=8F=B2=E6=9D=A1?= =?UTF-8?q?=E7=9B=AE=E8=BD=AC=E6=B6=88=E6=81=AF=E7=9A=84=E8=BF=87=E6=B8=A1?= =?UTF-8?q?=E5=87=BD=E6=95=B0=20-=20ToolCallGroup=20=E4=B8=8E=20toolCallGr?= =?UTF-8?q?oupPresentation=20=E6=94=B9=E7=94=A8=E5=8E=BB=E6=8E=89=E5=9B=9E?= =?UTF-8?q?=E5=90=88=E8=BA=AB=E4=BB=BD=E7=9A=84=20DirectChatToolCard=20-?= =?UTF-8?q?=20generated/index.ts=20=E8=A1=A5=E4=B8=8A=20DirectThread*=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E5=AF=BC=E5=87=BA=20-=20=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E6=9C=AC=E4=BB=93=E5=BA=93=E5=85=B6=E5=AE=83=E6=A8=A1=E5=9D=97?= =?UTF-8?q?=E4=BB=8D=E5=9C=A8=E6=94=B9=E7=9A=84=20DirectCodexUser*=20?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=E9=87=8D=E6=96=B0=E7=94=9F=E6=88=90=EF=BC=88?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E8=AF=AF=E6=94=B9=E4=BB=96=E4=BA=BA=E8=BF=9B?= =?UTF-8?q?=E8=A1=8C=E4=B8=AD=E7=9A=84=E5=A5=91=E7=BA=A6=EF=BC=89=20-=20?= =?UTF-8?q?=E4=B8=A4=E4=B8=AA=20vitest=20=E7=94=A8=E4=BE=8B=E6=96=87?= =?UTF-8?q?=E4=BB=B6=E6=8C=89=E6=96=B0=20API=20=E9=87=8D=E5=86=99=EF=BC=8C?= =?UTF-8?q?11=20=E6=9D=A1=E7=94=A8=E4=BE=8B=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../project-workspace/ToolCallGroup.tsx | 6 +- .../project-workspace/directThreadChat.ts | 280 +++++++------- .../project-workspace/directThreadEvents.ts | 72 ++-- .../directThreadItemProjection.ts | 365 +++++++++--------- .../generated/DirectCodexUserContentPart.ts | 3 +- .../generated/DirectCodexUserItem.ts | 10 +- .../DirectCodexUserMessageEnvelope.ts | 4 - .../generated/DirectCodexUserMessageItem.ts | 6 +- .../generated/DirectCodexUserRole.ts | 2 +- .../DirectCodexUserRuntimeRegionPart.ts | 18 +- .../project-workspace/generated/index.ts | 8 + .../toolCallGroupPresentation.ts | 22 +- .../tests/directThreadChat.test.ts | 200 +++++----- .../tests/directThreadEvents.test.ts | 6 +- 14 files changed, 475 insertions(+), 527 deletions(-) delete mode 100644 apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx index d5e904f51..683e5c2c0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ToolCallGroup.tsx @@ -9,7 +9,7 @@ import { import { useEffect, useId, useState } from 'react'; import { AgentMessageContent } from '../../../../../packages/shared/src/components/AgentMessageContent'; -import type { GameCreatorDirectToolCall } from '../../app/types'; +import type { DirectChatToolCard } from './directThreadChat'; import { formatToolCallDuration, formatTurnDuration, @@ -38,7 +38,7 @@ export function ToolCallGroup({ active = false, className, }: { - calls: GameCreatorDirectToolCall[]; + calls: DirectChatToolCard[]; /** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */ userSentAt?: number | null; /** @@ -169,7 +169,7 @@ function ToolCallRow({ call, active, }: { - call: GameCreatorDirectToolCall; + call: DirectChatToolCard; active: boolean; }) { const [expanded, setExpanded] = useState(false); diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts index ab5de6c00..37adb0c7e 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadChat.ts @@ -1,120 +1,133 @@ /** - * DirectProject 聊天 reducer:把运行态 raw event 与历史切片归并成同一份聊天条目。 + * DirectProject 聊天 reducer:把运行态事件与历史切片归并成同一份聊天条目。 * - * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。这里不判断哪些条目 - * 要显示,也不决定顺序策略之外的任何东西:顺序 = 历史文件顺序 + 运行态独有条目。 + * 事实源只有一个——项目对话历史;运行态事件只负责"当前回合"。顺序 = 历史文件顺序 + + * 运行态独有条目。这里不做可见性判断(那是投影的事),也不认任何回合身份:DirectProject + * 同一时刻只有一个回合在跑,`turn.started` / `turn.completed` 只切换"是否还在跑"这一个布尔。 */ import type { GameCreatorDirectToolCall } from '../../app/types'; import type { - DirectThreadRawEvent, + DirectThreadConsumeResult, + DirectThreadEvent, + DirectThreadHistorySlice, + DirectThreadItem, DirectThreadSubscriptionBootstrap, } from './directThreadEvents'; -import { - type DirectThreadRawItem, - isDirectThreadRawItem, - projectDirectThreadRawItem, -} from './directThreadItemProjection'; +import { projectDirectThreadItem } from './directThreadItemProjection'; export type DirectChatEntryKind = 'message' | 'reasoning' | 'tool'; -/** 与 Rust `DirectChatEntry` 同形:运行态事件与历史切片共用的唯一形状。 */ +/** 聊天卡片里的工具形状:持久化卡片去掉回合身份(Rust 侧已经不下发 turn id)。 */ +export type DirectChatToolCard = Omit; + +/** 聊天视图里的一条条目;运行态事件与历史切片共用的唯一形状。 */ export type DirectChatEntry = { itemId: string; - callId?: string | null; - turnId?: string | null; kind: DirectChatEntryKind; role?: 'user' | 'assistant' | null; text?: string | null; - toolCall?: GameCreatorDirectToolCall | null; + toolCall?: DirectChatToolCard | null; at?: number; }; export type DirectThreadChatState = { subscriptionId: string | null; - /** 首屏历史锚点:`subscribe` 给出的最后一个完成条目 id。 */ + /** 首屏历史锚点:`subscribe` 给出的最后一条完成条目 id。 */ lastCompletedItemId: string | null; - /** 活动回合:只有出现 `turn.started` 且没有对应 `turn.completed` 才有值。 */ - activeTurnId: string | null; + /** 最新回合是否还在跑;只由生命周期事件的先后决定。 */ + turnRunning: boolean; /** 历史切片条目,保持文件顺序。 */ history: DirectChatEntry[]; - /** 运行态条目,保持到达顺序。 */ + /** 当前回合的运行态条目,保持到达顺序;回合结束即并入历史并清空。 */ live: DirectChatEntry[]; - /** 流式正文累计(itemId → text)。 */ - deltaText: Record; }; export function emptyDirectThreadChatState(): DirectThreadChatState { return { subscriptionId: null, lastCompletedItemId: null, - activeTurnId: null, + turnRunning: false, history: [], live: [], - deltaText: {}, }; } -/** 合并身份:工具条目的 app-server itemId 与原始 item 的 callId 是同一个值。 */ -export function directChatEntryIdentity(entry: DirectChatEntry): string { - return (entry.callId ?? '').trim() || entry.itemId; +function longerText( + left: string | null | undefined, + right: string | null | undefined, +): string | null { + const a = typeof left === 'string' ? left : ''; + const b = typeof right === 'string' ? right : ''; + // 正文只增不减:增量往同一段落追加,完成快照可能比累计更长(漏过几条 delta)。 + return b.length > a.length ? b : a; } -function mergeToolCall( - left: GameCreatorDirectToolCall, - right: GameCreatorDirectToolCall, -): GameCreatorDirectToolCall { +function mergeToolStatus( + left: DirectChatToolCard['status'] | null | undefined, + right: DirectChatToolCard['status'] | null | undefined, +): DirectChatToolCard['status'] { + // 只有终态才算数:先到的 `running` 允许被后到的完成 / 失败覆盖,反过来不行。 + if (left === 'running' || !left) return right ?? left ?? 'running'; + return left; +} + +function mergeToolCard( + left: DirectChatToolCard | null, + right: DirectChatToolCard | null, +): DirectChatToolCard | null { + if (!left) return right; + if (!right) return left; return { ...left, - ...right, - title: right.title?.trim() ? right.title : left.title, - summary: right.summary?.trim() ? right.summary : left.summary, - kind: right.kind && right.kind !== 'other' ? right.kind : left.kind, + kind: left.kind && left.kind !== 'other' ? left.kind : right.kind, + title: left.title?.trim() ? left.title : right.title, + summary: left.summary?.trim() ? left.summary : right.summary, + status: mergeToolStatus(left.status, right.status), detail: { - command: right.detail?.command ?? left.detail?.command, - output: right.detail?.output ?? left.detail?.output, - changes: right.detail?.changes?.length - ? right.detail.changes - : left.detail?.changes, + command: left.detail.command ?? right.detail.command, + output: left.detail.output ?? right.detail.output, + changes: left.detail.changes?.length + ? left.detail.changes + : right.detail.changes, }, - startedAt: Math.min( - left.startedAt > 0 ? left.startedAt : right.startedAt, - right.startedAt > 0 ? right.startedAt : left.startedAt, - ), + startedAt: left.startedAt > 0 ? left.startedAt : right.startedAt, + updatedAt: Math.max(left.updatedAt, right.updatedAt), }; } -/** 后到的快照只补空字段:结果快照不能抹掉先到的命令与标题。 */ +/** + * 先到的快照赢,后到的只补空字段。 + * + * 三个例外只有"后到信息一定更全"时才成立:正文取更长的一份、工具状态允许从 `running` + * 升级到终态、`updatedAt` 取较新的时间。其余字段一律先到先用,后到的空值不得抹掉它。 + */ export function mergeDirectChatEntry( existing: DirectChatEntry, incoming: DirectChatEntry, ): DirectChatEntry { - const merged: DirectChatEntry = { - ...existing, - ...incoming, + return { itemId: existing.itemId || incoming.itemId, kind: - incoming.kind === 'tool' || existing.kind === 'tool' + existing.kind === 'tool' || incoming.kind === 'tool' ? 'tool' - : incoming.kind, - text: incoming.text ?? existing.text, - role: incoming.role ?? existing.role, - turnId: incoming.turnId ?? existing.turnId, + : existing.kind, + role: existing.role ?? incoming.role ?? null, + text: longerText(existing.text, incoming.text), + toolCall: mergeToolCard( + existing.toolCall ?? null, + incoming.toolCall ?? null, + ), + at: existing.at || incoming.at, }; - const left = existing.toolCall ?? null; - const right = incoming.toolCall ?? null; - merged.toolCall = - left && right ? mergeToolCall(left, right) : (right ?? left); - return merged; } function upsertLiveEntry( state: DirectThreadChatState, entry: DirectChatEntry, ): DirectThreadChatState { - const identity = directChatEntryIdentity(entry); const index = state.live.findIndex( - (existing) => directChatEntryIdentity(existing) === identity, + (existing) => existing.itemId === entry.itemId, ); if (index < 0) { return { ...state, live: [...state.live, entry] }; @@ -128,49 +141,47 @@ function upsertLiveEntry( return { ...state, live }; } -function reduceDirectThreadEvent( +function appendLiveText( state: DirectThreadChatState, - event: DirectThreadRawEvent, + event: Extract, +): DirectThreadChatState { + const itemId = event.itemId.trim(); + if (!itemId || !event.delta) return state; + const reasoning = event.kind === 'reasoning'; + const existing = state.live.find((entry) => entry.itemId === itemId); + return upsertLiveEntry(state, { + itemId, + kind: reasoning ? 'reasoning' : 'message', + role: reasoning ? null : 'assistant', + text: `${existing?.text ?? ''}${event.delta}`, + }); +} + +export function reduceDirectThreadEvent( + state: DirectThreadChatState, + event: DirectThreadEvent, ): DirectThreadChatState { switch (event.type) { case 'turn.started': - return { ...state, activeTurnId: event.turnId }; + return { ...state, turnRunning: true }; case 'turn.completed': + // 回合结束:条目已经落盘,运行态并入历史后清空,避免同一条目渲染两次。 return { ...state, - activeTurnId: - state.activeTurnId === event.turnId ? null : state.activeTurnId, + turnRunning: false, + history: mergeHistoryEntries(state.history, state.live), + live: [], }; - case 'item.delta': { - const itemId = event.itemId?.trim(); - if (!itemId) return state; - const payload = event.payload as { delta?: unknown; kind?: unknown }; - const delta = typeof payload?.delta === 'string' ? payload.delta : ''; - if (!delta) return state; - const text = `${state.deltaText[itemId] ?? ''}${delta}`; - const reasoning = payload?.kind === 'reasoning'; - return upsertLiveEntry( - { ...state, deltaText: { ...state.deltaText, [itemId]: text } }, - { - itemId, - kind: reasoning ? 'reasoning' : 'message', - role: reasoning ? null : 'assistant', - turnId: event.turnId, - text, - }, - ); - } + case 'item.delta': + return appendLiveText(state, event); case 'item.started': case 'item.completed': { - const entry = isDirectThreadRawItem(event.payload) - ? projectDirectThreadRawItem(event.payload) - : null; - if (!entry) return state; - return upsertLiveEntry(state, { - ...entry, - turnId: entry.turnId ?? event.turnId, - }); + const entry = projectDirectThreadItem(event.item); + return entry ? upsertLiveEntry(state, entry) : state; } + case 'request': + // 审批 / 提问只影响面板交互,不并入聊天条目。 + return state; default: return state; } @@ -178,20 +189,23 @@ function reduceDirectThreadEvent( export function reduceDirectThreadEvents( state: DirectThreadChatState, - events: readonly DirectThreadRawEvent[], + events: readonly DirectThreadEvent[], ): DirectThreadChatState { return events.reduce(reduceDirectThreadEvent, state); } -/** bootstrap 是运行态的唯一权威:原子替换运行态,历史窗口保留。 */ +/** + * bootstrap 是运行态的唯一权威:游标已在队尾,返回的事件就是此刻要处理的事件。 + * + * 历史窗口保留:bootstrap 不重新回读历史切片,那是 `lastCompletedItemId` 的职责。 + */ export function resolveDirectThreadBootstrap( state: DirectThreadChatState, bootstrap: DirectThreadSubscriptionBootstrap, ): DirectThreadChatState { return reduceDirectThreadEvents( { - ...emptyDirectThreadChatState(), - history: state.history, + ...state, subscriptionId: bootstrap.subscriptionId, lastCompletedItemId: bootstrap.lastCompletedItemId ?? null, }, @@ -199,69 +213,59 @@ export function resolveDirectThreadBootstrap( ); } -/** 历史切片并入:重叠条目按身份去重,新切片在前、已有历史在后。 */ -export function mergeDirectHistoryEntries( +/** 事件顺序 = 游标顺序;调用方只需要把 `consume` 的结果喂进来。 */ +export function applyDirectThreadConsumeResult( state: DirectThreadChatState, - entries: readonly DirectChatEntry[], + result: DirectThreadConsumeResult, ): DirectThreadChatState { - const byIdentity = new Map(); - const order: string[] = []; - for (const entry of [...entries, ...state.history]) { - const identity = directChatEntryIdentity(entry); - const existing = byIdentity.get(identity); - if (existing) { - byIdentity.set(identity, mergeDirectChatEntry(existing, entry)); + return reduceDirectThreadEvents(state, result.events); +} + +/** 同一身份的条目合并,先到者在前:历史在前、运行态在后,运行态只补空。 */ +export function mergeHistoryEntries( + leading: readonly DirectChatEntry[], + trailing: readonly DirectChatEntry[], +): DirectChatEntry[] { + const byId = new Map(); + const entries: DirectChatEntry[] = []; + for (const entry of [...leading, ...trailing]) { + const index = byId.get(entry.itemId); + if (index === undefined) { + byId.set(entry.itemId, entries.length); + entries.push(entry); continue; } - byIdentity.set(identity, entry); - order.push(identity); + const existing = entries[index]; + if (existing) entries[index] = mergeDirectChatEntry(existing, entry); } - return { - ...state, - history: order - .map((identity) => byIdentity.get(identity)) - .filter((entry): entry is DirectChatEntry => Boolean(entry)), - }; + return entries; } /** * 历史切片并入:切片是脱敏原始条目,投影规则与运行态完全同一份。 * - * `items` 必须按文件顺序给出;同一调用的 `function_call` 与 `function_call_output` - * 在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 + * 同一调用的调用与输出在这里按身份合并成一张卡片,而不是在 Rust 侧合并。 */ export function mergeDirectHistoryItems( state: DirectThreadChatState, - items: readonly DirectThreadRawItem[], + items: readonly DirectThreadItem[], ): DirectThreadChatState { - return mergeDirectHistoryEntries( - state, - items - .map((item) => projectDirectThreadRawItem(item)) - .filter((entry): entry is DirectChatEntry => Boolean(entry)), - ); + const entries = items + .map((item) => projectDirectThreadItem(item)) + .filter((entry): entry is DirectChatEntry => Boolean(entry)); + return { ...state, history: mergeHistoryEntries(entries, state.history) }; +} + +export function mergeDirectThreadHistorySlice( + state: DirectThreadChatState, + slice: DirectThreadHistorySlice, +): DirectThreadChatState { + return mergeDirectHistoryItems(state, slice.items); } /** 聊天投影输入:历史顺序 + 运行态覆盖;运行态独有条目排在最后。 */ export function selectDirectChatEntries( state: DirectThreadChatState, ): DirectChatEntry[] { - const liveByIdentity = new Map( - state.live.map((entry) => [directChatEntryIdentity(entry), entry]), - ); - const seen = new Set(); - const entries: DirectChatEntry[] = []; - for (const entry of state.history) { - const identity = directChatEntryIdentity(entry); - seen.add(identity); - const live = liveByIdentity.get(identity); - entries.push(live ? mergeDirectChatEntry(entry, live) : entry); - } - for (const entry of state.live) { - const identity = directChatEntryIdentity(entry); - if (seen.has(identity)) continue; - seen.add(identity); - entries.push(entry); - } - return entries; + return mergeHistoryEntries(state.history, state.live); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts index 78b8af25d..e52462b56 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadEvents.ts @@ -1,62 +1,44 @@ +/** + * DirectProject 运行态事件的线上类型。 + * + * 类型由 Rust 侧 ts-rs 导出(改完 Rust 模型后跑 `cargo test export_bindings`),这里只做 + * 入口转发:前端不再自己抄一份形状,字段增删必须改 Rust。 + */ + import type { LocalConversationMessageRecord } from '../../app/types'; -import type { DirectThreadRawItem } from './directThreadItemProjection'; +import type { DirectThreadItem } from './generated'; -export type DirectThreadRawEvent = { - seq: number; - type: string; - turnId: string; - itemId?: string; - payload: Record; -}; - -export type DirectThreadSubscriptionBootstrap = { - subscriptionId: string; - lastCompletedItemId: string | null; - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadConsumeResult = { - events: DirectThreadRawEvent[]; -}; - -export type DirectThreadHistorySlice = { - /** 脱敏原始条目,顺序即文件顺序;与运行态事件载荷同形。 */ - items: DirectThreadRawItem[]; - hasMore: boolean; - /** 本次切片的原始条目锚点:分页继续向前靠它,而不是靠"有没有可显示条目"。 */ - firstItemId: string | null; -}; +export type { + DirectThreadConsumeResult, + DirectThreadDeltaKind, + DirectThreadEvent, + DirectThreadFileChange, + DirectThreadHistorySlice, + DirectThreadItem, + DirectThreadRequestKind, + DirectThreadSubscriptionBootstrap, +} from './generated'; /** * 历史条目转聊天消息。 * - * 迁移期的过渡函数:只保留 `role ∈ {user, assistant}` 的文本条目,工具卡片与交替顺序 - * 由 `directThreadChat` 的 reducer 投影。App.tsx 切换到 reducer 后本函数删除。 + * 过渡函数:`App.tsx` 仍按 `LocalConversationMessageRecord` 渲染,切换成 reducer 之后删除。 + * 只保留 `role ∈ {user, assistant}` 且有正文的条目;工具卡片与交替顺序由 reducer 投影。 */ export function directThreadHistoryItemsToMessages( - items: readonly DirectThreadRawItem[], + items: readonly DirectThreadItem[], ): LocalConversationMessageRecord[] { return items.flatMap((item) => { - if ( - item.itemType !== 'message' && - item.itemType !== 'agentMessage' && - item.itemType !== 'userMessage' - ) { - return []; - } - const role = item.role; - if (role !== 'user' && role !== 'assistant') return []; - const messageRole = role as 'user' | 'assistant'; - const content = typeof item.text === 'string' ? item.text : ''; - if (!content) return []; - const messageId = item.itemId; + if (item.itemType !== 'message') return []; + if (item.role !== 'user' && item.role !== 'assistant') return []; + if (!item.text) return []; return [ { schemaVersion: 'agc-direct-project-context.v1', - role: messageRole, - content, + role: item.role, + content: item.text, agentId: null, - messageId, + messageId: item.itemId, updatedAt: item.at ?? 0, }, ]; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts index 5ff47d33c..f1982c80f 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/directThreadItemProjection.ts @@ -1,57 +1,22 @@ /** * DirectProject「原始条目 → 聊天条目」投影。 * - * Rust 只搬运脱敏后的 Codex 原始条目(消息 / 思考 / 工具调用),工具卡片的 - * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片 - * 走同一个函数,因此实时与回读不可能出现两套口径。 + * 输入是 Rust 侧 ts-rs 导出的 `DirectThreadItem`(脱敏后的 Codex 原始条目),工具卡片的 + * `kind`、标题、折叠摘要、状态判定和可见性全部在这里完成。运行态事件与历史切片走同一个 + * 函数,因此实时与回读不可能出现两套口径。 */ import type { - GameCreatorDirectToolCall, GameCreatorDirectToolCallChange, + GameCreatorDirectToolCallDetail, GameCreatorDirectToolCallKind, GameCreatorDirectToolCallStatus, } from '../../app/types'; -import type { DirectChatEntry } from './directThreadChat'; +import type { DirectChatEntry, DirectChatToolCard } from './directThreadChat'; +import type { DirectThreadItem } from './directThreadEvents'; -/** 一个片段里的文件变更(与 Rust `DirectThreadRawChange` 同形)。 */ -export type DirectThreadRawChange = { - path: string; - kind: string; -}; - -/** Rust 搬运的脱敏原始条目:没有卡片语义,只有 Codex 原始字段。 */ -export type DirectThreadRawItem = { - itemId: string; - callId?: string | null; - turnId?: string | null; - itemType: string; - role?: string | null; - text?: string | null; - name?: string | null; - arguments?: string | null; - output?: string | null; - command?: string | null; - tool?: string | null; - changes?: DirectThreadRawChange[] | null; - itemStatus?: string | null; - exitCode?: number | null; - success?: boolean | null; - completed: boolean; - at?: number | null; -}; - -const MESSAGE_ITEM_TYPES = new Set(['message', 'agentMessage', 'userMessage']); -const REASONING_ITEM_TYPES = new Set(['reasoning']); -const TOOL_ITEM_TYPES = new Set([ - 'function_call', - 'function_call_output', - 'commandExecution', - 'fileChange', - 'mcpToolCall', - 'webSearch', - 'contextCompaction', -]); +/** 折叠态摘要上限,与卡片契约一致。 */ +const TOOL_SUMMARY_MAX_CHARS = 120; const FAILED_ITEM_STATUS = new Set([ 'failed', @@ -61,13 +26,6 @@ const FAILED_ITEM_STATUS = new Set([ 'aborted', ]); -/** 折叠态摘要上限,与卡片契约一致。 */ -const TOOL_SUMMARY_MAX_CHARS = 120; - -function text(value: unknown): string { - return typeof value === 'string' ? value : ''; -} - function firstLine(value: string): string { const [line = ''] = value.split('\n'); const trimmed = line.trim(); @@ -95,51 +53,17 @@ function toolKindFromFunctionName(name: string): GameCreatorDirectToolCallKind { } } -function toolKind(raw: DirectThreadRawItem): GameCreatorDirectToolCallKind { - switch (raw.itemType) { - case 'function_call': - return toolKindFromFunctionName(text(raw.name)); - // 输出条目本身不携带工具语义:它只负责补上 `output`。 - case 'function_call_output': - return 'other'; - case 'commandExecution': - return 'command'; - case 'fileChange': - return 'file_change'; - case 'mcpToolCall': - return 'mcp_tool'; - case 'webSearch': - return 'web_search'; - case 'contextCompaction': - return 'context_compaction'; - default: - return 'other'; - } -} - -function toolStatus(raw: DirectThreadRawItem): GameCreatorDirectToolCallStatus { - const itemStatus = text(raw.itemStatus); - if (itemStatus === 'completed') return 'completed'; - if (FAILED_ITEM_STATUS.has(itemStatus)) return 'failed'; +function toolStatus( + status: string | null, + exitCode: number | null, +): GameCreatorDirectToolCallStatus { + if (status === 'completed') return 'completed'; + if (status && FAILED_ITEM_STATUS.has(status)) return 'failed'; // Codex 的退出码约定:非 0 即失败;缺席时按「已完成」处理。 - if (typeof raw.exitCode === 'number') { - return raw.exitCode === 0 ? 'completed' : 'failed'; + if (typeof exitCode === 'number') { + return exitCode === 0 ? 'completed' : 'failed'; } - if (typeof raw.success === 'boolean') { - return raw.success ? 'completed' : 'failed'; - } - return raw.completed ? 'completed' : 'running'; -} - -function toolChanges( - raw: DirectThreadRawItem, -): GameCreatorDirectToolCallChange[] { - return (raw.changes ?? []) - .filter((change) => text(change?.path).trim().length > 0) - .map((change) => ({ - path: text(change.path), - kind: text(change.kind) || 'update', - })); + return status ? 'running' : 'completed'; } function toolTitle( @@ -162,120 +86,183 @@ function toolTitle( } } -/** - * 工具卡片投影。 - * - * `function_call_output` 只带输出,因此它的标题与摘要留空:后到的输出快照不允许 - * 抹掉先到的命令与标题(合并规则见 `mergeDirectChatEntry`)。 - */ -function projectToolCard( - raw: DirectThreadRawItem, -): GameCreatorDirectToolCall | null { - const kind = toolKind(raw); - const changes = toolChanges(raw); - const command = text(raw.command) || text(raw.arguments); - const tool = text(raw.tool); - const output = text(raw.output); - const isOutputOnly = raw.itemType === 'function_call_output'; - const detail: GameCreatorDirectToolCall['detail'] = {}; - if (command && !isOutputOnly) detail.command = command; - if (output) detail.output = output; +function fileChanges( + item: Extract, +): GameCreatorDirectToolCallChange[] { + return item.changes + .filter((change) => change.path.trim().length > 0) + .map((change) => ({ + path: change.path, + kind: change.kind || 'update', + })); +} + +type ToolCardInput = { + itemId: string; + at: number; + kind: GameCreatorDirectToolCallKind; + /** 输出条目只带输出:标题与摘要留空,交给先到的调用快照。 */ + outputOnly?: boolean; + tool?: string; + command?: string; + output?: string; + status: GameCreatorDirectToolCallStatus; + changes?: GameCreatorDirectToolCallChange[]; +}; + +function buildToolCard(input: ToolCardInput): DirectChatToolCard | null { + const changes = input.changes ?? []; + const detail: GameCreatorDirectToolCallDetail = {}; + if (input.command && !input.outputOnly) detail.command = input.command; + if (input.output) detail.output = input.output; if (changes.length > 0) detail.changes = changes; + // 没有命令 / 输出 / 文件明细的条目不渲染成卡片:一张空卡片对用户没有信息量。 if (!detail.command && !detail.output && !detail.changes?.length) return null; const summarySource = - (kind === 'mcp_tool' ? tool : '') || + (input.kind === 'mcp_tool' ? (input.tool ?? '') : '') || detail.command || changes[0]?.path || - tool || + input.tool || ''; - const at = typeof raw.at === 'number' && raw.at > 0 ? raw.at : 0; return { schemaVersion: 'agc-tool-call.v1', - id: text(raw.callId) || raw.itemId, - turnId: text(raw.turnId), - kind, - title: isOutputOnly ? '' : toolTitle(kind, changes), - summary: isOutputOnly ? '' : firstLine(summarySource), - status: toolStatus(raw), + id: input.itemId, + kind: input.kind, + title: input.outputOnly ? '' : toolTitle(input.kind, changes), + summary: input.outputOnly ? '' : firstLine(summarySource), + status: input.status, detail, - startedAt: at, - updatedAt: at, + startedAt: input.at, + updatedAt: input.at, }; } +function toolCardFromItem(item: DirectThreadItem): DirectChatToolCard | null { + switch (item.itemType) { + case 'function_call': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: toolKindFromFunctionName(item.name), + tool: item.name, + command: item.arguments, + status: 'running', + }); + case 'function_call_output': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'other', + outputOnly: true, + output: item.output, + status: 'completed', + }); + case 'commandExecution': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'command', + command: item.command, + output: item.output ?? '', + status: toolStatus(item.status, item.exitCode), + }); + case 'fileChange': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'file_change', + changes: fileChanges(item), + status: 'completed', + }); + case 'mcpToolCall': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'mcp_tool', + tool: item.tool, + command: item.arguments, + output: item.output ?? '', + status: toolStatus(item.status, null), + }); + case 'webSearch': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'web_search', + command: item.query ?? '', + output: item.output ?? '', + status: 'completed', + }); + case 'contextCompaction': + return buildToolCard({ + itemId: item.itemId, + at: item.at, + kind: 'context_compaction', + command: '整理上下文', + status: 'completed', + }); + default: + return null; + } +} + /** * 原始条目投影成聊天条目;不属于聊天内容的条目返回 `null`。 * - * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型 - * 都不进聊天视图。 + * 可见性判定只在这里:系统 / 开发者 message、无正文的空条目、未识别的 item 类型都不进 + * 聊天视图。`other` 是 Rust 原样透传的未知类型,要不要显示属于前端可见性决策,当前不显示。 */ -export function projectDirectThreadRawItem( - raw: DirectThreadRawItem | null | undefined, +export function projectDirectThreadItem( + item: DirectThreadItem | null | undefined, ): DirectChatEntry | null { - if (!raw) return null; - const itemId = text(raw.itemId).trim(); - const itemType = text(raw.itemType).trim(); - if (!itemId || !itemType) return null; + if (!item) return null; + const itemId = item.itemId.trim(); + if (!itemId) return null; - if (REASONING_ITEM_TYPES.has(itemType)) { - const body = text(raw.text); - if (!body.trim()) return null; - return { - itemId, - callId: raw.callId ?? null, - turnId: raw.turnId ?? null, - kind: 'reasoning', - role: null, - text: body, - toolCall: null, - at: raw.at ?? 0, - }; + switch (item.itemType) { + case 'message': { + const role = + item.role === 'user' + ? 'user' + : item.role === 'assistant' + ? 'assistant' + : null; + if (!role || !item.text.trim()) return null; + return { + itemId, + kind: 'message', + role, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'reasoning': { + if (!item.text.trim()) return null; + return { + itemId, + kind: 'reasoning', + role: null, + text: item.text, + toolCall: null, + at: item.at, + }; + } + case 'other': + // TODO(direct-thread): 未识别类型目前不显示;要让它们出现只改这里,别回 Rust 加白名单。 + return null; + default: { + const toolCall = toolCardFromItem(item); + if (!toolCall) return null; + return { + itemId, + kind: 'tool', + role: null, + text: null, + toolCall, + at: item.at, + }; + } } - - if (MESSAGE_ITEM_TYPES.has(itemType)) { - const role = text(raw.role); - if (role !== 'user' && role !== 'assistant') return null; - const body = text(raw.text); - if (!body) return null; - return { - itemId, - callId: raw.callId ?? null, - turnId: raw.turnId ?? null, - kind: 'message', - role, - text: body, - toolCall: null, - at: raw.at ?? 0, - }; - } - - if (TOOL_ITEM_TYPES.has(itemType)) { - const toolCall = projectToolCard(raw); - if (!toolCall) return null; - return { - itemId, - callId: raw.callId ?? null, - turnId: raw.turnId ?? null, - kind: 'tool', - role: null, - text: null, - toolCall, - at: raw.at ?? 0, - }; - } - - return null; -} - -/** 事件 / 历史切片载荷是否像一条原始条目。 */ -export function isDirectThreadRawItem( - payload: unknown, -): payload is DirectThreadRawItem { - if (!payload || typeof payload !== 'object') return false; - const candidate = payload as Partial; - return ( - typeof candidate.itemId === 'string' && - typeof candidate.itemType === 'string' - ); } diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts index eec5662e2..10c4e0f5a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserContentPart.ts @@ -1,4 +1,5 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + import type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; export type DirectCodexUserContentPart = diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts index aa1ee88dd..4c9ea8ca3 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserItem.ts @@ -1,9 +1,5 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + import type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; -/** - * DirectProject 本轮 user input 的唯一结构化入口。 - */ -export type DirectCodexUserItem = { - type: 'message'; -} & DirectCodexUserMessageItem; +export type DirectCodexUserItem = DirectCodexUserMessageItem; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts deleted file mode 100644 index 97e0b0dae..000000000 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageEnvelope.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DirectCodexUserItem } from './DirectCodexUserItem'; - -export type DirectCodexUserMessageEnvelope = { item: DirectCodexUserItem }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts index a1101c8cb..f7447dc5d 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserMessageItem.ts @@ -1,9 +1,11 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + import type { DirectCodexUserContentPart } from './DirectCodexUserContentPart'; import type { DirectCodexUserRole } from './DirectCodexUserRole'; export type DirectCodexUserMessageItem = { + type: 'message'; role: DirectCodexUserRole; - content: Array; + content: DirectCodexUserContentPart[]; id: string; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts index 0c2540122..e1dc59c59 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRole.ts @@ -1,3 +1,3 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRole = 'user'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts index b134fb7bc..296c0d19a 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/DirectCodexUserRuntimeRegionPart.ts @@ -1,13 +1,13 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +// This file is generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export type DirectCodexUserRuntimeRegionPart = { label: string; - runId: string | null; - versionId: string | null; - elementTag: string | null; - elementRole: string | null; - text: string | null; - width: number | null; - height: number | null; - resourceIds: Array; + runId?: string; + versionId?: string; + elementTag?: string; + elementRole?: string; + text?: string; + width?: number; + height?: number; + resourceIds: string[]; }; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts index f1012a4c5..aeeebc8f8 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/generated/index.ts @@ -3,3 +3,11 @@ export type { DirectCodexUserItem } from './DirectCodexUserItem'; export type { DirectCodexUserMessageItem } from './DirectCodexUserMessageItem'; export type { DirectCodexUserRole } from './DirectCodexUserRole'; export type { DirectCodexUserRuntimeRegionPart } from './DirectCodexUserRuntimeRegionPart'; +export type { DirectThreadConsumeResult } from './DirectThreadConsumeResult'; +export type { DirectThreadDeltaKind } from './DirectThreadDeltaKind'; +export type { DirectThreadEvent } from './DirectThreadEvent'; +export type { DirectThreadFileChange } from './DirectThreadFileChange'; +export type { DirectThreadHistorySlice } from './DirectThreadHistorySlice'; +export type { DirectThreadItem } from './DirectThreadItem'; +export type { DirectThreadRequestKind } from './DirectThreadRequestKind'; +export type { DirectThreadSubscriptionBootstrap } from './DirectThreadSubscriptionBootstrap'; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts index a48a125bd..e41ca0354 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts +++ b/apps/ai-game-creator-shell/src/features/project-workspace/toolCallGroupPresentation.ts @@ -1,7 +1,5 @@ -import type { - GameCreatorDirectToolCall, - GameCreatorDirectToolCallKind, -} from '../../app/types'; +import type { GameCreatorDirectToolCallKind } from '../../app/types'; +import type { DirectChatToolCard } from './directThreadChat'; /** * 工具调用折叠块的纯文案计算:汇总 / 行文案。 @@ -43,7 +41,7 @@ export const TOOL_CALL_ROW_VERBS: Partial< /** 汇总文案:按 kind 计数、固定顺序拼成 `已执行 5 个命令、2 个文件变更`;空集合返回空串。 */ export function toolCallGroupSummary( - calls: GameCreatorDirectToolCall[], + calls: DirectChatToolCard[], running = false, ) { const counts = new Map(); @@ -77,7 +75,7 @@ export function toolCallGroupSummary( } /** 一行工具的文案:只用工具本身的摘要(不带"已运行"这类动词前缀),状态由行尾状态列表达。 */ -export function toolCallRowText(call: GameCreatorDirectToolCall) { +export function toolCallRowText(call: DirectChatToolCard) { // 行首不再写"已运行/已编辑"这类动词前缀:命令状态由行尾的状态列表达 // (执行中 / 已执行 / 失败),前缀会和它重复。 if (call.kind === 'context_compaction') { @@ -86,7 +84,7 @@ export function toolCallRowText(call: GameCreatorDirectToolCall) { return toolCallRowSummary(call); } -function toolCallRowSummary(call: GameCreatorDirectToolCall) { +function toolCallRowSummary(call: DirectChatToolCard) { if (call.kind === 'command') { // 历史摘要可能已被可执行文件路径占满;优先从完整、已脱敏的输入提取正文。 const script = windowsPowerShellCommandBody( @@ -114,7 +112,7 @@ function toolCallRowSummary(call: GameCreatorDirectToolCall) { } /** 仅格式化卡片输入,不修改执行参数、历史记录或工具输出。 */ -export function toolCallInputText(call: GameCreatorDirectToolCall) { +export function toolCallInputText(call: DirectChatToolCard) { const input = call.detail.command?.trim() ?? ''; return call.kind === 'command' ? (windowsPowerShellCommandBody(input) ?? input) @@ -179,7 +177,7 @@ function unwrapDisplayArgument(argument: string) { * 这两种情况不显示耗时,不显示 `0s` / 负数。 */ export function toolCallDurationMs( - call: Pick, + call: Pick, ): number | null { const startedAt = Number.isFinite(call.startedAt) ? call.startedAt : 0; const updatedAt = Number.isFinite(call.updatedAt) ? call.updatedAt : 0; @@ -212,7 +210,7 @@ export function formatToolCallDuration(ms: number | null | undefined) { /** 一回合总用时:该回合所有工具的 `min(startedAt)` → `max(updatedAt)`;取不到返回 `null`。 */ export function turnToolCallDurationMs( - calls: Array>, + calls: Array>, ): number | null { let minStartedAt = Number.POSITIVE_INFINITY; let maxUpdatedAt = Number.NEGATIVE_INFINITY; @@ -253,7 +251,7 @@ export function formatTurnDuration(ms: number | null | undefined) { /** 该回合的结束时间:`max(updatedAt)`;取不到返回 0。 */ export function turnToolCallEndedAt( - calls: Array>, + calls: Array>, ) { let maxUpdatedAt = 0; for (const call of calls) { @@ -287,7 +285,7 @@ export function formatClockTime(timestamp: number | null | undefined) { * 能拿到同回合用户消息时间(`updatedAt > 0`)时显示「发送 → 结束」,取不到就只显示结束时间。 */ export function turnToolCallTimeLabel( - calls: Array>, + calls: Array>, userSentAt: number | null | undefined, ) { const endLabel = formatClockTime(turnToolCallEndedAt(calls)); diff --git a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts index feeffe84c..e381805dd 100644 --- a/apps/ai-game-creator-shell/tests/directThreadChat.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadChat.test.ts @@ -1,95 +1,104 @@ import { describe, expect, it } from 'vitest'; import { - directChatEntryIdentity, emptyDirectThreadChatState, mergeDirectHistoryItems, reduceDirectThreadEvents, resolveDirectThreadBootstrap, selectDirectChatEntries, } from '../src/features/project-workspace/directThreadChat'; -import type { DirectThreadRawEvent } from '../src/features/project-workspace/directThreadEvents'; -import type { DirectThreadRawItem } from '../src/features/project-workspace/directThreadItemProjection'; +import type { DirectThreadEvent } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; function event( - partial: Partial & - Pick, -): DirectThreadRawEvent { - return { seq: 1, payload: {}, ...partial }; + partial: Pick & Partial, +): DirectThreadEvent { + return partial as DirectThreadEvent; } -/** app-server `item/started`:工具真正开始执行,itemId 就是 call_id。 */ -function rawToolStarted( - overrides: Partial = {}, -): DirectThreadRawItem { +/** app-server `item/started`:工具真正开始执行,itemId 就是归一后的唯一身份。 */ +function toolStarted( + overrides: Partial< + Extract + > = {}, +): DirectThreadItem { return { - itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - turnId: 'turn-1', itemType: 'function_call', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', name: 'exec_command', arguments: '{"cmd": "ls"}', - completed: false, at: 1000, ...overrides, }; } -/** 原始 response item 的 `function_call_output`:itemId 是另一个 id 空间,靠 callId 对齐。 */ -function rawToolOutput( - overrides: Partial = {}, -): DirectThreadRawItem { +/** 原始 response item 的输出条目:Rust 已经把它归一成同一个 itemId。 */ +function toolOutput(): DirectThreadItem { return { - itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', - callId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - turnId: 'turn-1', itemType: 'function_call_output', + itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', output: 'assets\ngame', - completed: true, at: 2000, + }; +} + +function messageItem( + overrides: Partial> = {}, +): DirectThreadItem { + return { + itemType: 'message', + itemId: 'msg-1', + role: 'assistant', + text: '你好', + at: 3000, ...overrides, }; } describe('DirectProject 聊天 reducer', () => { - it('只有 turn.started 且未完成的回合才是活动回合', () => { + it('生命周期事件只切换"是否还在跑",不带回合身份', () => { const started = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ type: 'turn.started', turnId: 'turn-1' }), + event({ type: 'turn.started' }), ]); - expect(started.activeTurnId).toBe('turn-1'); + expect(started.turnRunning).toBe(true); const completed = reduceDirectThreadEvents(started, [ - event({ type: 'turn.completed', turnId: 'turn-1' }), + event({ type: 'turn.completed', status: 'completed' }), ]); - expect(completed.activeTurnId).toBeNull(); + expect(completed.turnRunning).toBe(false); }); - it('bootstrap 原子替换运行态,没有生命周期事件就没有活动回合', () => { - const stale = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ type: 'turn.started', turnId: 'turn-old' }), - ]); - const bootstrapped = resolveDirectThreadBootstrap(stale, { - subscriptionId: 'sub-1', - lastCompletedItemId: 'msg-9', - events: [], - }); - expect(bootstrapped.activeTurnId).toBeNull(); + it('bootstrap 事件就是要处理的事件:未完成条目直接进运行态', () => { + const bootstrapped = resolveDirectThreadBootstrap( + emptyDirectThreadChatState(), + { + subscriptionId: 'sub-1', + lastCompletedItemId: 'msg-9', + events: [ + event({ type: 'turn.started' }), + event({ type: 'item.started', item: toolStarted() }), + ], + }, + ); expect(bootstrapped.subscriptionId).toBe('sub-1'); expect(bootstrapped.lastCompletedItemId).toBe('msg-9'); + expect(bootstrapped.turnRunning).toBe(true); + expect(selectDirectChatEntries(bootstrapped)).toHaveLength(1); }); - it('增量正文按条目累计,完成快照覆盖同一段', () => { + it('增量正文按条目累计,完成快照更长时覆盖同一段', () => { const streamed = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.delta', - turnId: 'turn-1', itemId: 'msg-1', - payload: { delta: '你', kind: 'message' }, + kind: 'message', + delta: '你', }), event({ type: 'item.delta', - turnId: 'turn-1', itemId: 'msg-1', - payload: { delta: '好', kind: 'message' }, + kind: 'message', + delta: '好', }), ]); expect(selectDirectChatEntries(streamed)).toHaveLength(1); @@ -98,17 +107,7 @@ describe('DirectProject 聊天 reducer', () => { const done = reduceDirectThreadEvents(streamed, [ event({ type: 'item.completed', - turnId: 'turn-1', - itemId: 'msg-1', - payload: { - itemId: 'msg-1', - turnId: 'turn-1', - itemType: 'message', - role: 'assistant', - text: '你好,我是陶泥儿。', - completed: true, - at: 3000, - } satisfies DirectThreadRawItem, + item: messageItem({ text: '你好,我是陶泥儿。' }), }), ]); const entries = selectDirectChatEntries(done); @@ -120,9 +119,9 @@ describe('DirectProject 聊天 reducer', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.delta', - turnId: 'turn-1', itemId: 'reason-1', - payload: { delta: '先看目录', kind: 'reasoning' }, + kind: 'reasoning', + delta: '先看目录', }), ]); const entries = selectDirectChatEntries(state); @@ -132,26 +131,14 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.text).toBe('先看目录'); }); - it('工具 started / output 跨 id 空间按 callId 归并成一张卡片', () => { + it('工具调用与输出共用唯一 itemId,归并成一张卡片', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ - event({ - type: 'item.started', - turnId: 'turn-1', - itemId: 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - payload: rawToolStarted(), - }), - event({ - type: 'item.completed', - turnId: 'turn-1', - itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', - payload: rawToolOutput(), - }), + event({ type: 'item.started', item: toolStarted() }), + event({ type: 'item.completed', item: toolOutput() }), ]); const entries = selectDirectChatEntries(state); expect(entries).toHaveLength(1); - expect(directChatEntryIdentity(entries[0]!)).toBe( - 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - ); + expect(entries[0]?.itemId).toBe('call_00_Gpd0s0Ytm9YgIbwbEXva1473'); expect(entries[0]?.toolCall?.kind).toBe('command'); expect(entries[0]?.toolCall?.title).toBe('执行命令'); expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); @@ -159,18 +146,27 @@ describe('DirectProject 聊天 reducer', () => { expect(entries[0]?.toolCall?.status).toBe('completed'); }); + it('回合结束把运行态并入历史并清空,条目不会消失也不会重复', () => { + const running = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ + event({ type: 'turn.started' }), + event({ type: 'item.completed', item: messageItem() }), + event({ type: 'item.started', item: toolStarted() }), + ]); + expect(running.live).toHaveLength(2); + + const done = reduceDirectThreadEvents(running, [ + event({ type: 'turn.completed', status: 'completed' }), + ]); + expect(done.live).toHaveLength(0); + expect(done.history).toHaveLength(2); + expect(selectDirectChatEntries(done)).toHaveLength(2); + }); + it('历史切片搬运层不合并,合并发生在前端投影', () => { const state = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ - rawToolStarted(), - rawToolOutput(), - { - itemId: 'msg-user', - itemType: 'message', - role: 'user', - text: '做一个拼图游戏', - completed: true, - at: 0, - }, + toolStarted(), + toolOutput(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), ]); const entries = selectDirectChatEntries(state); expect(entries).toHaveLength(2); @@ -182,58 +178,36 @@ describe('DirectProject 聊天 reducer', () => { const state = reduceDirectThreadEvents(emptyDirectThreadChatState(), [ event({ type: 'item.completed', - turnId: 'turn-1', - itemId: 'sys-1', - payload: { + item: messageItem({ itemId: 'sys-1', - itemType: 'message', role: 'system', text: '内部指令', - completed: true, - at: 0, - } satisfies DirectThreadRawItem, + }), }), event({ type: 'item.completed', - turnId: 'turn-1', - itemId: 'plan-1', - payload: { + item: { + itemType: 'other', itemId: 'plan-1', - itemType: 'plan', - completed: true, + rawType: 'plan', at: 0, - } satisfies DirectThreadRawItem, + } satisfies DirectThreadItem, }), ]); expect(selectDirectChatEntries(state)).toHaveLength(0); }); - it('历史条目与运行态按身份合并,重复条目只出现一次', () => { + it('历史条目与运行态按唯一 id 合并,重复条目只出现一次', () => { const historical = mergeDirectHistoryItems(emptyDirectThreadChatState(), [ - rawToolStarted(), - { - itemId: 'msg-user', - itemType: 'message', - role: 'user', - text: '做一个拼图游戏', - completed: true, - at: 0, - }, + toolStarted(), + messageItem({ itemId: 'msg-user', role: 'user', text: '做一个拼图游戏' }), ]); const live = reduceDirectThreadEvents(historical, [ - event({ - type: 'item.completed', - turnId: 'turn-1', - itemId: 'fco_01a06fa5-d636-7452-b337-a641c2e6bc76', - payload: rawToolOutput(), - }), + event({ type: 'item.completed', item: toolOutput() }), ]); const entries = selectDirectChatEntries(live); expect(entries).toHaveLength(2); expect(entries[0]?.toolCall?.status).toBe('completed'); expect(entries[0]?.toolCall?.detail.command).toBe('{"cmd": "ls"}'); - expect(directChatEntryIdentity(entries[0]!)).toBe( - 'call_00_Gpd0s0Ytm9YgIbwbEXva1473', - ); }); }); diff --git a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts index ccaebe645..6229b26b5 100644 --- a/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts +++ b/apps/ai-game-creator-shell/tests/directThreadEvents.test.ts @@ -4,6 +4,7 @@ import { directThreadHistoryItemsToMessages, isDirectTurnInProgress, } from '../src/features/project-workspace/directThreadEvents'; +import type { DirectThreadItem } from '../src/features/project-workspace/directThreadItemProjection'; describe('Direct 回合状态与历史时间', () => { it('终态和空状态不恢复为活动回合', () => { @@ -27,7 +28,6 @@ describe('Direct 回合状态与历史时间', () => { itemType: 'message', role: 'user', text: '帮我修改游戏', - completed: true, at: 1_800_000_000_001, }, { @@ -35,9 +35,9 @@ describe('Direct 回合状态与历史时间', () => { itemType: 'message', role: 'assistant', text: '好的', - completed: true, + at: 0, }, - ]; + ] satisfies DirectThreadItem[]; expect(directThreadHistoryItemsToMessages(items)[0]?.updatedAt).toBe( 1_800_000_000_001, ); -- 2.52.0 From ed3369a494a1540ddc279ee2a68e357c14f71446 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=BE=B7=E5=AE=87?= Date: Wed, 16 Sep 2026 19:54:05 +0800 Subject: [PATCH 11/11] =?UTF-8?q?=E6=96=87=E6=A1=A3=E5=85=88=E8=A1=8C?= =?UTF-8?q?=EF=BC=9A=E6=8A=8A=20DirectProject=20=E8=81=8A=E5=A4=A9?= =?UTF-8?q?=E7=9C=9F=E7=9B=B8=E6=BA=90=E6=94=B6=E6=95=9B=E7=9A=84=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=94=B9=E5=88=B0=E5=BD=93=E5=89=8D=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 主规范把事件模型写成 ts-rs 导出的 tagged enum,说明 seq 不下发、事件不带回合身份,并补上 ts 绑定生成与 f64 时间戳约定 - ADR 补上 tagged enum 绑定、turn id 删除、正文只增不减与回合回收、前端卡片去掉 turnId、未知类型前端丢弃 - 里程碑与实施计划同步:文件改名到 direct_thread_wire.rs,新增删回合身份与生成绑定两步,验证命令加上 export_bindings - 工具卡片技术方案在文首加修订段:数据来源层作废,表现层契约仍然有效,DirectRuntime 的 tool-calls.jsonl 写入保留 - 对话回合唯一投影里程碑改为 superseded,列出仍然有效与已作废的分界 --- ...irectProject对话历史单一事实源-2026-09-16.md | 18 ++++++--- ...划】DirectProject聊天真相源收敛-2026-09-16.md | 28 +++++++------- ...碑】DirectProject聊天真相源收敛-2026-09-16.md | 18 +++++---- .../【里程碑】对话回合唯一投影-2026-09-16.md | 37 ++++++++++--------- ...ectProject Codex原始历史与异常恢复-2026-09-04.md | 32 ++++++++++------ ...方案】GameAgent对话工具调用卡片-2026-09-14.md | 11 +++++- 6 files changed, 88 insertions(+), 56 deletions(-) diff --git a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md index 210db6135..8e8a43ea8 100644 --- a/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md +++ b/docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md @@ -15,11 +15,14 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - **聊天投影** 在读取与渲染时生成,不落盘、不成为第二事实源;DirectProject 聊天框停止读取 `turn-stream.jsonl` 与 `tool-calls.jsonl`,也不再提供供前端读取的命令。DirectRuntime 自己那套进度事件与文件写入属于运行时账本,本轮保留不动。 - 页面重进的运行态只由 `subscribe` 的 bootstrap 事件重建,删除活动回合快照接管路径。 - 可见性判断留在前端聊天投影:后端历史分页只按原始条目切片,前端自己跳过不可显示条目并推进锚点。 -- 运行态事件与历史切片使用同形条目信封(`turnId` / `itemId` / `seq` / `payload`),Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 +- 线上模型是 **ts-rs 导出的 tagged enum**(`agent/direct_thread_wire.rs`),不是"一个大结构体加一堆可空字段":`DirectThreadItem` 用 `itemType` 区分条目,`DirectThreadEvent` 用 `type` 区分事件,前端直接消费生成的 TS 类型(改完 Rust 模型跑 `cargo test export_bindings`)。条目上的毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 +- 运行态事件与历史切片使用同形条目,Rust 在两侧套同一套安全过滤(脱敏、截断、路径归一),前端只有一个「原始条目 → 视图」投影函数。 - 搬运层不生成展示形状:Thread Manager 只下发脱敏原始条目(`itemType` 原样透传),工具卡片的 `kind`、标题、折叠摘要都由前端生成。 -- 条目身份只有一套:`itemId = call_id ?? id`。工具 item 的 app-server id 就是 `call_id`,而 `project.jsonl` 落盘的是原始 response item(两个 id 不同),所以在进队列前归一,不向 Thread Manager 与前端暴露第二个 id 概念。 -- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。 -- 活动回合的唯一判据是「出现过 `turn.started` 且未出现对应 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 +- 条目身份只有一套:进队列前归一成一个 `itemId`。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,调用与输出共用前者),归一只在 Rust 边界做一次,Thread Manager 与前端都不暴露第二个 id 概念。 +- 事件不带回合身份:DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;前端 state 里只有一个 `turnRunning` 布尔,没有 `turnId`。`subscribe` 返回的条目、增量、请求与队列锚点都不带 turn id。 +- 合并只在前端,规则只保留「先到定形、后到补空白」:第一次见到的快照决定卡片形状,后续快照只补输出与状态,不做逐字段优先级表。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 +- 前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复。 +- 活动回合的唯一判据是「出现过 `turn.started` 且未出现 `turn.completed`」;进程重启后队列消失,历史里的半截回合一律按已结束渲染。 - 分页锚点取原始条目 id;一次翻页操作在前端自动连拉,直到出现可显示条目或 `hasMore=false`,上限 5 页。 - `notify` 是唯一唤醒来源:`subscribe` 的 bootstrap 事件本身就是该 subscriber 此刻要处理的事件(游标已在队尾),前端直接 reduce 它们,不额外补一次 `consume`,之后完全由 `notify` 驱动,不设低频 tick 或任何轮询兜底。 - 迁移按一次干净切换落地:不做灰度、不做运行时开关、不双跑;允许提交序列里存在「新源已启用、旧代码尚未删除」的中间窗口,禁止反向的「新源未启用、旧源已删」。 @@ -27,8 +30,11 @@ AGC 项目开发聊天框当前同时从三处取数据:Direct 回合事件( - 运行态事件必须自足:`item.started` / `item.completed` 携带与历史切片同形的完整**脱敏原始条目**,前端按归一后的 `itemId` 合并快照得到运行中与完成态;不提供按 `itemId` 单点取快照的接口。 - 思考正文以 `item.delta{kind:"reasoning"}` 流式下发(`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta`)。这不放宽可见范围:同一段文本本来就已落进 `project.jsonl` 并在 `item.completed` 展示;plan 文本与命令输出仍只降级为活动状态。 - 首屏历史由 `subscribe` 返回的 `lastCompletedItemId` 锚定,再取最近切片;删除返回整份对话的历史命令。 -- 生命周期锚点独立于 replay 队列保存,`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 -- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取,且该切片从文件尾反向扫描。 +- 生命周期锚点独立于 replay 队列保存(队列会回收 `cleanable` 事件,新订阅的游标又在队尾,回收后无法反推"最新回合是 started 还是 completed"),`subscribe` 必须返回最新的一条 `turn.started` / `turn.completed`,否则新订阅无法判定回合是否仍在运行。 +- 前端工具卡片形状是 `Omit`:聊天卡片不再有回合身份,`tool-calls.jsonl` 的持久化形状仍保留 `turnId`(DirectRuntime 的账本没动)。 +- 未识别 item 类型由 Rust 原样透传(只带类型与身份,Rust 侧留 TODO),当前由前端投影丢弃:哪些类型可见属于前端决策,不回 Rust 加白名单。 +- 删除范围包含前端对 `read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`、`read_direct_project_history`(整份历史)与 `game-creator-direct-turn-update` 事件的调用;保留分页用的历史切片读取(`read_direct_project_history_slice`),且该切片从文件尾反向扫描。 +- 前端删掉 `directTurnStream` / `directToolCalls` / 活动回合快照接管 / 瞬时应答文本这些并行状态,聊天视图只由 reducer 状态投影(含工具卡片)。 - 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 - 历史切片的 `firstItemId` 是分页锚点,始终取 `project.jsonl` 里的原始 item id,与归一后的条目身份分开计算。 - 审批与提问事件本次只作为同一条事件流 pass-through,不并入聊天 reducer 驱动的状态机,迁移面收敛在历史与运行态一致性上。 diff --git a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md index 1fc498481..37e4fbf57 100644 --- a/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【实施计划】DirectProject聊天真相源收敛-2026-09-16.md @@ -8,28 +8,30 @@ ## 修改边界 -- 允许修改:`agent/direct_thread_manager.rs`、`agent/direct_thread_raw_item.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 +- 允许修改:`agent/direct_thread_wire.rs`、`agent/direct_thread_manager.rs`、`agent/codex_app_server/`、`agent/direct_project_history.rs`、`main.rs` 命令注册、`src/features/project-workspace/generated/`(ts-rs 生成目录)、AGC 前端订阅与聊天投影、对应测试与 `docs/`。 - 明确不修改:SpacetimeDB schema 与绑定、HTTP/OpenAPI、DirectRuntime 自己的进度事件与 `turn-stream.jsonl` / `tool-calls.jsonl` 写入、Codex durable thread 行为、审批弹层现有状态来源。 - 保持 `.env` 未提交修改,不触碰个人配置。 ## 实现顺序 -1. Rust 只搬运:`agent/direct_thread_raw_item.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状。 -2. 条目身份归一:`itemId = call_id ?? id`,同时从事件 envelope 与前端形状里删掉第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 -3. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 -4. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要再补一次 `consume`);此后只由 notify 唤醒 `consume`。合并规则只保留"先到定形、后到补空白",并删掉 `deltaText` 缓冲与回合结束后的运行态残留。 -5. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)。 -6. App.tsx 接线:订阅 + 立即 consume + notify 唤醒,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 -7. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留。 -8. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 +1. Rust 只搬运:`agent/direct_thread_wire.rs` 把 Codex 原始条目挑字段、脱敏、截断后下发,事件载荷与历史切片同形,不生成卡片形状;线上模型是 ts-rs 导出的 tagged enum(`DirectThreadItem` / `DirectThreadEvent` / bootstrap / consume / history slice),`at` 标 `#[ts(as = "f64")]`,改完模型跑 `cargo test export_bindings` 生成前端绑定。 +2. 条目身份归一:进队列前收敛成一个 `itemId`,事件 envelope 与前端形状里都不出现第二个 id 概念;历史切片的 `firstItemId` 继续取文件里的原始 item id。 +3. 删掉回合身份:`turn.started` 无载荷、`turn.completed{status}`,条目 / 增量 / 请求 / 生命周期锚点都不带 turn id;队列 `append` 直接收 `DirectThreadEvent`,`seq` 内部自算。 +4. 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 产出 `item.delta{kind:"reasoning"}`;plan 文本与命令输出保持活动状态。 +5. 前端收敛为单一 reducer:`subscribe` 返回的 bootstrap 事件就是已暂存的运行态,游标已经在队尾,前端直接 reduce 这批事件即可(不需要再补一次 `consume`);此后只由 notify 唤醒 `consume`。合并规则只保留"先到定形、后到补空白"(正文只增不减、工具状态允许从 running 升级到终态),`item.delta` 直接追加到运行态条目正文,删掉 `deltaText` 缓冲,`turn.completed` 把运行态条目并入历史再清空。 +6. 首屏与分页:以 `lastCompletedItemId` 为锚点取最近切片,历史读取改为从文件尾反向扫描;锚点按原始 item id 推进,切片无可见条目时自动连拉(上限 5 页)。 +7. App.tsx 接线:订阅 + 立即 reduce bootstrap + notify 唤醒 consume,聊天视图改由 reducer 状态投影(含工具卡片),删除 Direct 回合事件订阅与 `directTurnStream` / `directToolCalls` 状态。 +8. 删除只服务旧读路径的命令与前端调用(`read_direct_project_history`、`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns`),DirectRuntime 自己的写入保留。 +9. 测试与文档收口:补 reducer 单测、解锁跳过的工具卡片用例、更新主规范并把冲突的实施计划与工具卡片文档改写为当前状态。 ## 验证命令 1. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml direct_thread -- --nocapture` -2. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directThreadEvents.test.ts` -3. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` -4. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 -5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` +2. `cargo test --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml export_bindings`(生成 `src/features/project-workspace/generated/`) +3. `npx vitest run apps/ai-game-creator-shell/tests/directThreadChat.test.ts apps/ai-game-creator-shell/tests/directThreadEvents.test.ts` +4. `npx vitest run apps/ai-game-creator-shell/tests/appSurface/project-development.suite.ts` +5. TypeScript 类型检查与 ESLint(范围同前次 DirectProject 迁移)。 +6. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check` ## 风险与回滚点 diff --git a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md index 8009c1e3f..ba4ed5368 100644 --- a/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】DirectProject聊天真相源收敛-2026-09-16.md @@ -11,15 +11,16 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话历史**(`.agent/conversations/project.jsonl`)与 **运行态事件**(Thread Manager `subscribe` / `consume` / `notify`)。Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl` 与活动回合快照都不再是聊天视图的输入。 -边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。条目身份只有一个:Rust 在进队列前归一到 `call_id ?? id`,不再暴露第二套 id 概念。 +边界固定为:Thread Manager 只是**搬运层**——把 Codex 原始条目挑字段、脱敏、截断后下发;工具卡片的形状、可见性与合并全部由前端投影完成。线上模型是 ts-rs 导出的 tagged enum(`agent/direct_thread_wire.rs`),条目身份只有一个:Rust 在进队列前归一成一个 `itemId`,不再暴露第二个 id 概念,也不带任何回合身份。 ## 范围 - 运行态事件自足化:`item.started` / `item.completed` 携带与历史切片同形的脱敏**原始条目**,前端用同一个投影函数处理实时与回读。 -- 条目身份归一:`itemId = call_id ?? id`(工具 item 的 app-server id 就是 `call_id`);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id。 +- 线上模型与提示词同源:条目与事件是 ts-rs 导出的 tagged enum,前端消费生成绑定(改 Rust 模型后跑 `cargo test export_bindings`);毫秒时间戳用 `#[ts(as = "f64")]` 对齐 Tauri JSON 通道的 `number`。 +- 条目身份归一:进队列前收敛成一个 `itemId`(工具条目在 `project.jsonl` 里带调用 id 与 response item id 两个值);历史切片另给 `firstItemId` 作为分页锚点,锚点始终是文件里的原始 item id。 - 思考正文流式:`item/reasoning/summaryTextDelta` 与 `item/reasoning/textDelta` 以 `item.delta{kind:"reasoning"}` 下发正文;plan 文本与命令输出仍只降级为活动状态。 - 历史读取以 `subscribe` 返回的 `lastCompletedItemId` 为首屏锚点,切片从**文件尾反向扫描**;不可显示条目由前端跳过并继续取页(上限 5 页)。 -- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定;回合结束后回收该回合运行态条目。 +- 前端收敛为单一事件 reducer 与单一聊天投影;活动回合只由 `turn.started` 与 `turn.completed` 判定(事件不带 turn id,前端只有一个 `turnRunning` 布尔);`item.delta` 直接追加到运行态条目正文,不保留增量缓冲;`turn.completed` 把该回合运行态条目并入历史再清空。 - 删除前端对 Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照的读取,以及只服务这些读取的命令与状态。 ## 不在范围内 @@ -34,10 +35,12 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - 投影在前端:Rust 不生成 `kind` / 标题 / 折叠摘要,也不做合并。 - 合并只保留"先到定形、后到补空白":第一次见到的快照决定卡片形状,后续快照只补输出与状态;不做逐字段优先级表。 -- 条目 id 只有一套:`call_id ?? id`;前端与 Thread Manager 都不再出现第二个 id。 +- 条目 id 只有一套:归一到 `itemId`;前端与 Thread Manager 都不再出现第二个 id。 +- 事件不带回合身份:`turn.started` 无载荷、`turn.completed{status}`;Thread Manager 的生命周期锚点、条目、增量与请求都不带 turn id。 - 思考正文流式下发不放宽可见范围:被下发的就是此前已在 `item.completed` 展示、并已落进 `project.jsonl` 的同一段文本。 - 失败与中止说明只在运行期显示(不写 `project.jsonl`),页面重进后不再出现。 -- 未知 item 类型由 Rust 原样透传(只有类型与身份),当前由前端投影丢弃。 +- 未知 item 类型由 Rust 原样透传(只有类型与身份,Rust 侧留 TODO),当前由前端投影丢弃。 +- 前端聊天卡片的工具形状是 `Omit`;`tool-calls.jsonl` 的持久化形状与 DirectRuntime 的写入保持不变。 ## 依赖与前置条件 @@ -52,12 +55,13 @@ AGC 项目开发对话的显示与恢复只依赖两项输入:**项目对话 - [ ] 进程存活期间的页面重进(含切走再切回)能恢复运行中回合,并允许终止。 - [ ] 历史分页在切片内全部是不可显示条目时仍能继续向前,不出现锚点停滞。 - [ ] 聊天视图不再读取 `turn-stream.jsonl` / `tool-calls.jsonl` / Direct 回合事件 / 活动回合快照;`read_direct_turn_stream`、`read_direct_tool_calls`、`list_game_creator_direct_active_turns` 不再被前端调用。 -- [ ] 同一工具调用在实时与回读各只出现一张卡片(跨 id 空间按归一身份对齐)。 +- [ ] 同一工具调用在实时与回读各只出现一张卡片(两个 id 空间按归一后的 `itemId` 对齐)。 +- [ ] 前端聊天状态里不再出现第二个 id 概念与任何回合身份字段;事件解析统一来自 ts-rs 生成绑定。 - [ ] 思考正文在回合进行中即可见,且不进入活动状态文本。 - [ ] 订阅过期后重新 `subscribe` 并原子替换状态,不重复渲染已完成的条目。 ## 证据要求 -- 自动化:Thread Manager 事件契约测试、原始条目搬运与脱敏测试、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合 / 先到优先合并)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 +- 自动化:Thread Manager 事件契约测试、原始条目搬运与脱敏测试、ts-rs 绑定生成(`cargo test export_bindings`)、聊天 reducer 单测(bootstrap / consume / 过期重订阅 / 残回合 / 先到优先合并 / 回合回收)、历史分页锚点测试、前端渲染测试(含此前跳过的工具卡片用例)。 - 运行时:真实 app-server 会话下的新回合、杀进程重开、页面重进、分页与终止。 - 边界:订阅过期、事件重复与乱序、不可显示切片、无 `turn.completed` 的残回合、工具输出超长截断与脱敏。 diff --git a/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md b/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md index 191a6f606..c7e01f50a 100644 --- a/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md +++ b/docs/project-memory/plans/【里程碑】对话回合唯一投影-2026-09-16.md @@ -1,29 +1,32 @@ # 对话回合唯一投影 -- Version: 2 -- Status: implemented-awaiting-runtime-acceptance +- Version: 3 +- Status: superseded - Date: 2026-09-16 +- Superseded by: `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` - Parent Spec: ../../technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md -## 范围与评审 +## 结论 -单里程碑修复回合展示和流写入的一致性;补充终态重进恢复、用户发送时间和完成后的过程折叠。评审确认:活动快照及 Direct 事件拥有生命周期,Provider 回放不创建 client 回合;JSONL 信封可选时间字段不污染原始 item,无须数据库或旧数据迁移;最终回复沿用 Runtime 的最后 assistant item 合同,失败提示不折叠。没有身份的旧记录不得做位置猜配。 +本里程碑原先把「回合唯一投影」落在 Direct 回合事件 + `turn-stream.jsonl` + `tool-calls.jsonl` + 活动回合快照这条读路径上,方向已被推翻:聊天视图的输入只剩**项目对话历史**与**运行态事件**两项,Direct 回合事件、`turn-stream.jsonl`、`tool-calls.jsonl`、活动回合快照都不再是聊天视图的输入。后续实现与验收一律以 `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 与 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 为准。 -## 验收 +## 仍然有效的部分 -1. 一个 turn 只有一个呈现入口,用户消息不丢失。 -2. item 增量、完成、持久化和回读保持相同身份与固定顺序。 -3. 工具输入输出保留,重复快照不重复渲染。 -4. TypeScript、最小 Cargo 检查、编码和 diff 检查通过;用户要求不运行测试,实机新回合/重开/分页验收待确认。 -5. 已结束回合重进不显示提交中,真实运行回合可恢复;跨项目/新回合迟到快照无效。 -6. 用户消息时间可刷新恢复,旧无时间记录不造值;完成后中间正文和工具统一折叠,最终回复及失败提示保持可见。 +1. 一个回合只有一个呈现入口,用户消息不丢失;历史里带不了身份(没有 id)的旧记录不得做位置猜配。 +2. 同一 item 的增量、完成、持久化与回读使用同一个身份、同一个顺序;重复快照不重复渲染。 +3. 工具调用的输入与输出都保留,展开后仍显示「输入 / 输出」。 +4. 已结束回合重进不显示提交中;真实运行回合可恢复。 +5. 用户消息时间只来自条目自己的时间戳,旧无时间记录不造值;完成后中间正文与工具统一折叠,最终回复与失败提示保持可见。 +6. 失败与中止说明只在运行期显示,不写进 `project.jsonl`;页面重进后不再出现。 -依赖:既有项目历史和 v1 turn-stream / tool-calls DTO。未完成真实 UI 验收前不进入其它里程碑。 +## 已作废的部分 + +- 由活动回合快照接管运行中回合:改为由 `subscribe` bootstrap 事件判定(事件序列里 `turn.started` 之后没有 `turn.completed` 即运行中)。 +- 按 `turnId` 归并回合并把工具卡片挂在回合上:事件与条目都不再带回合身份,前端只有一个 `turnRunning` 布尔,卡片形状去掉 `turnId`。 +- 用 `turn-stream.jsonl` 的 `seq` 决定文本与工具的交替顺序:改为按运行态事件顺序 + 历史文件顺序投影。 +- 前端订阅 `game-creator-direct-turn-update`:改为 `subscribe` + `notify` 唤醒 `consume`(bootstrap 的事件直接 reduce,不再补一次 `consume`)。 ## 当前证据 -- 定向 TypeScript 类型检查、`cargo check --locked --bin genarrative-ai-game-creator-shell`、编码检查、文档索引检查、`git diff --check` 通过。 -- 已补充回合归属、分页、重复快照、无流回退及 writer 完成/切段、持久快照单调性/跨回合裁剪用例;按用户要求未执行测试,不能作为已通过凭证。 -- 静态自审确认视图只剩统一回合列表,不再存在 mapped/unmapped/live 三个回合流出口;失败提示使用稳定 failure 身份。 -- 真实新回合、历史重开、分页、失败/中断、工具展开输入输出仍待重启原生客户端后验收;仅本地提交,不推送。 -- 本次增量已完成生命周期来源收敛、信封发送时间和完成过程折叠;定向 TypeScript、ESLint、Cargo check、文档索引通过。新增时间幂等/旧记录、终态分类及内容分区用例但未运行;主页进入项目、重新发送/切项目竞态和自动折叠仍待原生实机验收,仅本地提交,不推送。 +- 里程碑版本的静态检查结论(TypeScript、Cargo、编码、文档索引、`git diff --check`)仍然成立;该增量按当时授权未运行测试。 +- 新路径的证据要求见 `../【里程碑】DirectProject聊天真相源收敛-2026-09-16.md` 的「验收标准」与「证据要求」,其中包含解锁此前跳过的工具卡片渲染用例。 diff --git a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md index b1d6888f7..14ce22931 100644 --- a/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md +++ b/docs/technical/【技术方案】DirectProject Codex原始历史与异常恢复-2026-09-04.md @@ -104,23 +104,29 @@ readHistory(threadId, { beforeItemId?, limit }) -> { ### 事件和顺序 -Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号,前端不要求连续。事件 envelope 至少包含: +Thread 内所有公开事件共用一个单调递增 seq,但 **seq 只是 Thread Manager 的内部游标事实,不下发**:同一个 subscriber 的 `consume` 按队列顺序返回事件数组,数组顺序就是前端要处理的顺序,前端因此不需要 item 级 cursor 或第二套 reducer。 + +线上模型是 ts-rs 导出的 tagged enum(`agent/direct_thread_wire.rs`),前端消费 `src/features/project-workspace/generated/` 里的生成绑定,改 Rust 模型后跑 `cargo test export_bindings` 重新生成;毫秒时间戳标 `#[ts(as = "f64")]`,因为 ts-rs 默认把 `u64` 映射成 `bigint`,而 Tauri 的 JSON 通道传的是 `number`。 + +事件按 `type` 区分,条目按 `itemType` 区分: ```ts -{ - seq: number, - type: string, - turnId: string, - itemId?: string, - payload: unknown, -} +type DirectThreadEvent = + | { type: 'turn.started' } + | { type: 'turn.completed'; status: string } + | { type: 'item.started'; item: DirectThreadItem } + | { type: 'item.completed'; item: DirectThreadItem } + | { type: 'item.delta'; itemId: string; kind: 'message' | 'reasoning'; delta: string } + | { type: 'request'; kind: 'approval.requested' | 'ask.requested' | 'request.resolved'; requestId: string | null }; ``` -进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按 `turnId` / `itemId` 分发并 reduce,不需要 item 级 cursor 或第二套 reducer。 +进入 Thread Manager 的是已经完成安全过滤和协议标准化的公开 raw event,不是未经审查的 app-server JSON。事件可交错包含多个并发 item:`item.started`、`item.delta`、`item.completed`、approval/request/resolved 事件,以及 `turn.started`、`turn.completed` 生命周期事件。前端按事件顺序 reduce,只用一个 reducer。 + +**事件不带回合身份。** DirectProject 同一时刻只有一个回合在跑,`turn.started` 无载荷、`turn.completed` 只带 `status`;条目、增量、请求与生命周期锚点都不带 turn id。前端 state 里只有一个 `turnRunning` 布尔,历史条目也不记录回合身份。 一个 thread 同时最多有一个 active turn;一个 turn 内允许多个并发 item。`turn.completed` 必须在该 turn 的完成 item 均成功持久化后进入队列,前端据此结束运行态;不能用“不存在 unfinished item”猜测 turn 是否完成。 -前端 reducer 的活动回合判定只有一条:state 中存在「出现过 `turn.started` 且未出现对应 `turn.completed`」的 `turnId` 时,该 turn 才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 中没有这样的 `turnId`,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 +前端 reducer 的活动回合判定只有一条:事件序列中出现 `turn.started` 且其后没有 `turn.completed` 时才是活动回合,界面才允许显示忙碌态。`subscribe` bootstrap 里没有这样的序列,就表示当前没有活动回合;Thread Manager 队列随进程消失,因此进程重启后历史里留下的半截回合一律按已结束渲染,前端不发明中断态,也不从历史条目反推忙碌态。 生命周期锚点独立于 replay 队列保存:`turn.started` / `turn.completed` 事件即使已被队列前缀回收,`subscribe` 仍必须把最新的一条作为 bootstrap 事件返回。因此进程内任意时刻新建订阅,都能判定最新回合是运行中还是已结束,不依赖"未完成 item 恰好还在队列里"。 @@ -129,8 +135,10 @@ Thread 内所有公开事件共用一个单调递增 seq;seq 允许跳号, 条目形状的职责边界固定为三条: 1. **搬运层不生成展示形状**。Thread Manager 只下发 Codex 原始条目(`itemType` 原样透传,正文与工具明细脱敏后带上限截断),不生成工具卡片的 `kind`、标题、折叠摘要,也不判断哪些条目要显示。 -2. **只有一个条目身份**。`itemId = call_id ?? id`:工具 item 的 app-server id 就是 `call_id`(codex-rs `thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`),而 `project.jsonl` 落盘的是原始 response item(`id` 与 `call_id` 不同),所以在进队列前归一。Thread Manager 与前端都不得再出现第二个 id 概念。 -3. **合并只在前端,且只保留"先到定形、后到补空白"**。第一次见到的快照决定卡片形状,后续快照只补输出与状态;同一调用只出现一张卡片。 +2. **只有一个条目身份**。工具条目在 `project.jsonl` 里带两个 id(调用 id 与 response item id,同一调用的调用与输出共用前者;codex-rs `thread_history.rs` 中所有工具 item 都是 `id: payload.call_id.clone()`),所以在进队列前归一成一个 `itemId`。Thread Manager 与前端都不得再出现第二个 id 概念。 +3. **合并只在前端,且只保留"先到定形、后到补空白"**。第一次见到的快照决定卡片形状,后续快照只补输出与状态;同一调用只出现一张卡片。只有"后到信息一定更全"时才例外:正文取更长的一份、工具状态允许从 `running` 升级到终态、`updatedAt` 取较新的时间。 + +前端不保留增量缓冲:`item.delta` 直接追加到运行态条目的正文(正文只增不减)。`turn.completed` 把当前回合的运行态条目并入历史再清空,条目既不消失也不重复;失败与中止说明只在运行期显示,不写进 `project.jsonl`。 历史切片的 `firstItemId` 不是上述归一身份:分页锚点必须是 `project.jsonl` 里的原始 item id,由 Rust 从文件扫描单独算出。 diff --git a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md index 11ce76401..ddb07e8b6 100644 --- a/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md +++ b/docs/technical/【技术方案】GameAgent对话工具调用卡片-2026-09-14.md @@ -1,5 +1,14 @@ # 【技术方案】GameAgent 对话工具调用卡片(Codex 风格)-2026-09-14 +## 2026-09-16 修订(当前状态) + +本方案的**卡片表现层**(折叠 / 展开、标题与摘要文案、耗时与时间显示、脱敏、无障碍、样式)仍然是有效契约;**数据来源层**已被 `docs/adr/【ADR】DirectProject对话历史单一事实源-2026-09-16.md` 取代,边界改为: + +- DirectProject 聊天框的工具卡片由**运行态事件 + 项目对话历史**在前端投影生成(`features/project-workspace/directThreadItemProjection.ts`),不再读取 `tool-calls.jsonl`,`read_direct_tool_calls` 不再是聊天视图的输入。 +- 报文中不再有 `toolCalls` 增量字段与 `GameCreatorDirectTurnUpdateEvent` 这条实时链路:卡片形状由前端从脱敏原始条目生成,事件里只有 `item.started` / `item.completed` / `item.delta`(线上模型见 `agent/direct_thread_wire.rs`,由 ts-rs 导出绑定)。 +- 卡片身份只有一个 `itemId`(工具条目在 `project.jsonl` 里带的两个 id 已在 Rust 边界归一),前端卡片形状是 `Omit`:聊天卡片不再有回合身份。 +- 下面「### 1. 工具调用条目」「### 2. 实时事件」「### 3. 回读命令」三节描述的是 DirectRuntime 自己的账本(`tool-calls.jsonl` 的写入形状与脱敏规则仍然有效,DirectRuntime 保留),**不再是 DirectProject 聊天框的读路径**;「### 4. 前端合并与渲染」中按 `turnId` 归并、按 `turn-stream.jsonl` 的 `seq` 交替的规则已作废,改为按事件顺序 + 历史文件顺序投影。 + ## 一句话交付 把 GameAgent 右侧对话面板里的「执行命令 / 写文件 / 调工具」从一行中文进度文本,改成 Codex 桌面客户端那样的**可折叠卡片**(折叠态一行摘要,展开态看命令与文件明细),并且在**刷新页面、重开项目后仍然存在**。 @@ -55,7 +64,7 @@ toolCalls?: DirectTurnToolCall[] | null; - 历史文件缺失 → 返回空数组,不报错。 - 单行损坏 → 逐行读字节并逐行解码,跳过该行继续,不整体失败;只有损坏字节与下一行黏成一行(例如写入被截断、缺失换行)时,被丢掉的也只是那**一行**,其后的合法记录必须继续读回(与 Codex item 流一样是"尽力而为"的展示数据,不是业务真相)。 -### 4. 前端合并与渲染(回合唯一归属,连续工具成块) +### 4. 前端合并与渲染(回合唯一归属,连续工具成块)——已作废,见文首修订 - 加载对话时按 `turnId` 归并为唯一回合容器,用户消息保留在该回合前部。有 `turn-stream.jsonl` 时,文本与工具按 item `seq` 交替,连续工具合为一块,遇到文本另起一块;没有流的历史回合才采用“工具块 + 历史正文”。 - 回合完成后,中间文本及所有工具块统一收进默认关闭的“执行过程”;最终回复及失败提示留在外面。展开后仍按原顺序查看中间输出和工具详情;运行中不使用外层折叠区。用户消息的发送时间从消息自身的历史时间读取,不能拿工具起点补造。 -- 2.52.0