DirectProject 历史切片改为从文件尾回扫并删掉聊天框只读命令
- 新增 DirectProjectHistoryReverseLines:按块从文件尾逐行回读 project.jsonl - 幂等回扫与"读一屏"共用这一套尾部回读,不再各自从文件头读到尾 - read_direct_project_history_items_slice_at 语义不变仍返回元组,新增第四项为本屏最老的 itemId 作分页锚点 - 分页锚点只按归一身份 itemId 匹配,不再接受第二个 id - read_direct_project_last_item_id_at 改为尾部回扫读一行,不再读整份历史 - 删除只服务聊天框回读的 read_direct_tool_calls / read_direct_turn_stream 命令与注册 - 保留 list_game_creator_direct_active_turns:它服务首页跨页面"运行中的项目",不是聊天框读路径 - 补历史切片尾部回扫与分页锚点两条用例
This commit is contained in:
@@ -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<String, String> {
|
||||
|
||||
const DIRECT_PROJECT_HISTORY_REVERSE_SCAN_CHUNK_BYTES: usize = 16 * 1024;
|
||||
|
||||
/// 从文件尾向前回读到的一行。
|
||||
///
|
||||
/// `terminated` 表示这行后面确实有换行符:文件里只有末行可能没有换行,那种行是 append
|
||||
/// 侧承诺会修复的截断尾,解析失败时按"历史到此结束"处理,而不是报错。
|
||||
struct DirectProjectHistoryReverseLine {
|
||||
bytes: Vec<u8>,
|
||||
terminated: bool,
|
||||
}
|
||||
|
||||
/// 从文件尾向前逐行产出 `project.jsonl`。
|
||||
///
|
||||
/// 历史会随项目长到 MB 级,而"最近一屏"和"按 id 回扫"都只关心尾部若干行:两者共用这一套
|
||||
/// 按块回读,"读一屏"不必再从文件头逐行读到尾。
|
||||
struct DirectProjectHistoryReverseLines {
|
||||
path: PathBuf,
|
||||
file: File,
|
||||
/// 已读进内存、但还没被换行切出来的更早字节。
|
||||
pending: Vec<u8>,
|
||||
chunk: Vec<u8>,
|
||||
/// 下一次回读的起始偏移;0 表示文件头已经读完。
|
||||
position: u64,
|
||||
}
|
||||
|
||||
impl DirectProjectHistoryReverseLines {
|
||||
fn open(path: &Path) -> Result<Self, String> {
|
||||
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<Option<DirectProjectHistoryReverseLine>, 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<Option<Value>, 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<Vec<(Value, u64
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// 从文件尾向前回扫一屏历史,返回 `(条目, 还有更早的条目, 记录时间, 本屏最老一条的 itemId)`。
|
||||
///
|
||||
/// 分页锚点就是条目身份(`itemId`),收满 `limit` 条可显示条目后再多看一眼"还有没有更早的
|
||||
/// 条目"就停,不回读整份历史。
|
||||
pub(crate) fn read_direct_project_history_items_slice_at(
|
||||
root: &Path,
|
||||
before_item_id: Option<&str>,
|
||||
limit: usize,
|
||||
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>), 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<Value>, bool, BTreeMap<String, u64>, Option<String>), 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<Option<String>, 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
assert_eq!(ids, vec!["codex-item-1"]);
|
||||
assert!(!has_more);
|
||||
assert_eq!(first_item_id.as_deref(), Some("codex-item-1"));
|
||||
}
|
||||
|
||||
/// 判据:争用类失败会被"有界退避重试"真的吃掉,最终把条目落一行。
|
||||
///
|
||||
/// 注入标记是"让接下来 N 次单次尝试返回争用失败";退避表只补一次重试,所以注入 1 次
|
||||
|
||||
@@ -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<Vec<DirectToolCall>, 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<Vec<DirectTurnStreamItem>, 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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user