diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs index 130c01549..133409a7d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/direct_project_history.rs @@ -1,3 +1,4 @@ +use super::direct_thread_item_identity; use super::runtime_actions::acquire_game_creator_agent_runtime_project_write_lock_with_wait; use crate::config::prepare_game_creator_private_path_for_read; use crate::project::{ @@ -102,63 +103,114 @@ fn record(item: &Value) -> Result { const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024; +/// 从文件尾向前回读到的一行。 +/// +/// `terminated` 表示这行后面确实有换行符:文件里只有末行可能没有换行,那种行是 append +/// 侧承诺会修复的截断尾,解析失败时按"历史到此结束"处理,而不是报错。 +struct DirectProjectHistoryReverseLine { + bytes: Vec, + terminated: bool, +} + +/// 从文件尾向前逐行产出 `project.jsonl`。 +/// +/// 历史会随项目长到 MB 级,而"最近一屏"和"按 id 回扫"都只关心尾部若干行:两者共用这一套 +/// 按块回读,"读一屏"不必再从文件头逐行读到尾。 +struct DirectProjectHistoryReverseLines { + path: PathBuf, + file: File, + /// 已读进内存、但还没被换行切出来的更早字节。 + pending: Vec, + chunk: Vec, + /// 下一次回读的起始偏移;0 表示文件头已经读完。 + position: u64, +} + +impl DirectProjectHistoryReverseLines { + fn open(path: &Path) -> Result { + let file = File::open(path) + .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; + let position = file + .metadata() + .map_err(|error| { + format!( + "读取 DirectProject 历史元数据失败:{}: {error}", + path.display() + ) + })? + .len(); + Ok(Self { + path: path.to_path_buf(), + file, + pending: Vec::new(), + chunk: vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES], + position, + }) + } + + fn next_line(&mut self) -> Result, String> { + loop { + if let Some(newline) = self.pending.iter().rposition(|byte| *byte == b'\n') { + let bytes = self.pending[newline + 1..].to_vec(); + self.pending.truncate(newline); + return Ok(Some(DirectProjectHistoryReverseLine { + bytes, + terminated: true, + })); + } + if self.position == 0 { + if self.pending.is_empty() { + return Ok(None); + } + return Ok(Some(DirectProjectHistoryReverseLine { + bytes: std::mem::take(&mut self.pending), + terminated: false, + })); + } + let read_len = usize::try_from(self.position) + .unwrap_or(usize::MAX) + .min(self.chunk.len()); + self.position -= read_len as u64; + self.file + .seek(SeekFrom::Start(self.position)) + .map_err(|error| { + format!( + "定位 DirectProject 历史失败:{}: {error}", + self.path.display() + ) + })?; + self.file + .read_exact(&mut self.chunk[..read_len]) + .map_err(|error| { + format!( + "读取 DirectProject 历史失败:{}: {error}", + self.path.display() + ) + })?; + let mut combined = self.chunk[..read_len].to_vec(); + combined.extend_from_slice(&self.pending); + self.pending = combined; + } + } +} + fn find_direct_project_history_item_by_id_at( path: &Path, item_id: &str, ) -> Result, String> { fire_direct_project_history_scan_probe(DIRECT_PROJECT_HISTORY_SCAN_PROBE_STARTED); - let mut file = File::open(path) - .map_err(|error| format!("打开 DirectProject 历史失败:{}: {error}", path.display()))?; - let mut position = file - .metadata() - .map_err(|error| { - format!( - "读取 DirectProject 历史元数据失败:{}: {error}", - path.display() - ) - })? - .len(); - let mut pending = Vec::new(); - let mut chunk = vec![0u8; DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES]; - - loop { - if position == 0 { - break; - } - let read_len = usize::try_from(position) - .unwrap_or(usize::MAX) - .min(chunk.len()); - position -= read_len as u64; - file.seek(SeekFrom::Start(position)) - .map_err(|error| format!("定位 DirectProject 历史失败:{}: {error}", path.display()))?; - file.read_exact(&mut chunk[..read_len]) - .map_err(|error| format!("读取 DirectProject 历史失败:{}: {error}", path.display()))?; - - let mut combined = Vec::with_capacity(read_len + pending.len()); - combined.extend_from_slice(&chunk[..read_len]); - combined.extend_from_slice(&pending); - let mut line_end = combined.len(); - while let Some(newline) = combined[..line_end].iter().rposition(|byte| *byte == b'\n') { - let line = &combined[newline + 1..line_end]; - if !line.is_empty() { - if let Some(item) = direct_project_history_item_from_line(path, line)? { - if item.get("id").and_then(Value::as_str) == Some(item_id) { - return Ok(Some(item)); - } + let mut lines = DirectProjectHistoryReverseLines::open(path)?; + while let Some(line) = lines.next_line()? { + match direct_project_history_item_from_line(path, &line.bytes) { + Ok(Some(item)) => { + if item.get("id").and_then(Value::as_str) == Some(item_id) { + return Ok(Some(item)); } } - line_end = newline; - } - pending = combined[..line_end].to_vec(); - } - - if !pending.is_empty() { - // The append path repairs an unterminated final JSONL record before - // writing. A duplicate scan must not reject that repairable tail. - if let Ok(Some(item)) = direct_project_history_item_from_line(path, &pending) { - if item.get("id").and_then(Value::as_str) == Some(item_id) { - return Ok(Some(item)); - } + Ok(None) => {} + // 末行没有换行符:append 侧会修复这条截断尾,幂等回扫不能因此失败。 + Err(_) if !line.terminated => break, + Err(error) => return Err(error), } } Ok(None) @@ -530,46 +582,93 @@ fn read_direct_project_history_entries_at(root: &Path) -> Result, limit: usize, -) -> Result<(Vec, bool, BTreeMap), String> { - let items = read_direct_project_history_entries_at(root)?; - let end = match before_item_id { - Some(item_id) => items - .iter() - .position(|(item, _)| item.get("id").and_then(Value::as_str) == Some(item_id)) - .ok_or_else(|| format!("DirectProject 历史中不存在 item:{item_id}"))?, - None => items.len(), - }; +) -> Result<(Vec, bool, BTreeMap, Option), String> { + let path = history_path(root); + if !prepare_game_creator_private_path_for_read(&path, false, "DirectProject 历史")? { + return Ok((Vec::new(), false, BTreeMap::new(), None)); + } let bounded_limit = limit.clamp(1, 200); - let start = end.saturating_sub(bounded_limit); - let slice = &items[start..end]; - let timestamps = slice + let mut lines = DirectProjectHistoryReverseLines::open(&path)?; + let mut newest_first: Vec<(Value, u64)> = Vec::new(); + // 锚点所在行本身不进窗口:它是"下一屏"的边界。 + let mut anchor_seen = before_item_id.is_none(); + let mut has_more = false; + while let Some(line) = lines.next_line()? { + if line.bytes.iter().all(u8::is_ascii_whitespace) { + continue; + } + let parsed: Value = match serde_json::from_slice(&line.bytes) { + Ok(value) => value, + // 与顺序读取一致:只有末行没有换行符时,解析失败才按"可修复的截断尾"结束。 + Err(_) if !line.terminated => break, + Err(error) => { + return Err(format!( + "解析 DirectProject 历史失败:{}: {error}", + path.display() + )) + } + }; + let Some(item) = direct_project_history_item_from_parsed_line(&path, &parsed)? else { + continue; + }; + if !anchor_seen { + anchor_seen = before_item_id.is_some_and(|anchor| { + direct_thread_item_identity(&item).as_deref() == Some(anchor) + }); + continue; + } + if is_direct_project_internal_context_item(&item) { + continue; + } + if newest_first.len() == bounded_limit { + // 收满一屏之后再见一条可显示条目,就足以说明还有更早的历史。 + has_more = true; + break; + } + let recorded_at = parsed + .get("recordedAt") + .and_then(Value::as_u64) + .unwrap_or(0); + newest_first.push((item, recorded_at)); + } + if !anchor_seen { + return Err(format!( + "DirectProject 历史中不存在 item:{}", + before_item_id.unwrap_or_default() + )); + } + newest_first.reverse(); + let recorded_at_ms = newest_first .iter() .filter_map(|(item, at)| { - let id = item.get("id").and_then(Value::as_str)?; - (*at > 0).then(|| (id.to_string(), *at)) + let identity = direct_thread_item_identity(item)?; + (*at > 0).then_some((identity, *at)) }) .collect(); + let first_item_id = newest_first + .first() + .and_then(|(item, _)| direct_thread_item_identity(item)); Ok(( - slice.iter().map(|(item, _)| item.clone()).collect(), - start > 0, - timestamps, + newest_first.into_iter().map(|(item, _)| item).collect(), + has_more, + recorded_at_ms, + first_item_id, )) } +/// 最新一条可显示条目的 itemId:首屏历史锚点。 +/// +/// 与"最近一屏"共用尾部回扫,读一行就能返回,不回读整份历史。 pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result, String> { - Ok(read_direct_project_history_items_at(root)? - .into_iter() - .rev() - .find_map(|item| { - item.get("id") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .map(str::to_string) - })) + Ok(read_direct_project_history_items_slice_at(root, None, 1)?.3) } pub(crate) fn read_direct_project_chat_history_at( @@ -648,12 +747,12 @@ mod tests { "content": [{"type": "input_text", "text": "修改游戏"}], }); append_direct_project_user_message_at(root.path(), &item).unwrap(); - let (items, _, timestamps) = + let (items, _, timestamps, _) = super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); assert_eq!(items, vec![item.clone()]); assert!(timestamps["sent-message"] > 0); append_direct_project_user_message_at(root.path(), &item).unwrap(); - let (_, _, reloaded) = + let (_, _, reloaded, _) = super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); assert_eq!(timestamps, reloaded); } @@ -662,7 +761,7 @@ mod tests { fn old_history_without_envelope_time_stays_unknown() { let root = init_history_project("history-unknown-time"); write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]); - let (_, _, timestamps) = + let (_, _, timestamps, _) = super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap(); assert!(timestamps.is_empty()); assert_eq!( @@ -674,6 +773,71 @@ mod tests { ); } + /// 判据:读一屏只回扫文件尾,不碰文件头。 + /// + /// 文件头放一条坏行(JSON 不合法),只要读取只覆盖"尾部一屏 + 一条"的范围就必须成功; + /// 一旦实现退回"从文件头逐行读到尾",这条用例会因为坏行直接失败。 + #[test] + fn history_window_reads_from_the_tail_without_parsing_the_head() { + let root = init_history_project("history-tail-scan"); + let head = r#"{"type":"response_item","payload":{"type":"message","role":"user","id":"head-broken","content":[{"type":"input_text","text":"坏行"}"#; + write_history_lines( + root.path(), + &[ + head, + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + RESPONSE_ITEM_ROW, + ], + ); + let (items, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), None, 1).unwrap(); + let ids = items + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-2"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-2")); + } + + /// 判据:分页锚点是"本窗口最老一条的归一身份",逐屏向前不重不漏。 + #[test] + fn history_window_paginates_upwards_by_identity_anchor() { + let root = init_history_project("history-pagination"); + write_history_lines( + root.path(), + &[ + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-1"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-2"), + &RESPONSE_ITEM_ROW.replace("codex-item-2", "codex-item-3"), + ], + ); + let (newest, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at(root.path(), None, 2).unwrap(); + let ids = newest + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-2", "codex-item-3"]); + assert!(has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-2")); + + let (older, has_more, _, first_item_id) = + super::read_direct_project_history_items_slice_at( + root.path(), + first_item_id.as_deref(), + 2, + ) + .unwrap(); + let ids = older + .iter() + .filter_map(|item| item.get("id").and_then(Value::as_str)) + .collect::>(); + assert_eq!(ids, vec!["codex-item-1"]); + assert!(!has_more); + assert_eq!(first_item_id.as_deref(), Some("codex-item-1")); + } + /// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。 /// /// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次 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 696aff810..eff140194 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/commands.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/commands.rs @@ -5306,31 +5306,6 @@ pub(crate) async fn read_agent_runtime_error_detail( .await .map_err(|error| format!("读取统一错误诊断后台任务失败:{error}"))? } -#[tauri::command] -pub(crate) async fn read_direct_tool_calls( - project_path: String, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_tool_calls_at(root) - }) - .await - .map_err(|error| format!("读取工具调用历史后台任务失败:{error}"))? -} - -#[tauri::command] -pub(crate) async fn read_direct_turn_stream( - project_path: String, -) -> Result, String> { - tauri::async_runtime::spawn_blocking(move || { - let root = Path::new(project_path.trim()); - enforce_project_permission_policy(root, "conversation.read")?; - read_direct_turn_stream_at(root) - }) - .await - .map_err(|error| format!("读取回合流历史后台任务失败:{error}"))? -} #[tauri::command] pub(crate) fn list_game_creator_direct_active_turns( @@ -5378,20 +5353,15 @@ pub(crate) async fn read_direct_project_history_slice( tauri::async_runtime::spawn_blocking(move || { let root = Path::new(project_path.trim()); enforce_project_permission_policy(root, "conversation.read")?; - let (items, has_more, item_timestamps) = read_direct_project_history_items_slice_at( - root, - before_item_id.as_deref(), - limit.unwrap_or(20), - )?; - 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_items_from_history(root, &items, |item| { - item.get("id") - .and_then(serde_json::Value::as_str) - .and_then(|id| item_timestamps.get(id).copied()) + let (raw_items, has_more, recorded_at_ms, first_item_id) = + read_direct_project_history_items_slice_at( + root, + before_item_id.as_deref(), + limit.unwrap_or(20), + )?; + let items = direct_thread_items_from_history(root, &raw_items, |item| { + direct_thread_item_identity(item) + .and_then(|identity| recorded_at_ms.get(&identity).copied()) .unwrap_or_default() }); Ok(DirectThreadHistorySlice { diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index bec55a981..c240a21fc 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -2782,8 +2782,6 @@ fn main() { archive_game_creator_agent_session, read_local_conversation, read_direct_project_conversation, - read_direct_tool_calls, - read_direct_turn_stream, read_agent_runtime_error_detail, list_game_creator_direct_active_turns, subscribe_direct_project_thread,