历史切片补新端锚点:首屏窗口取到订阅回执那条为止
direct_project_history 的分页锚点收敛成 DirectProjectHistoryAnchor:Newest(取文件尾)/ Before(不含锚点,翻页用)/ Through(含锚点,首屏边界用) read_direct_project_history_slice 命令新增 throughItemId,与 beforeItemId 互斥,两个都传直接报错 新增三条用例:Through 含锚点且挡掉更新的条目、锚点是最老一条时仍把它算进窗口、锚点不存在时失败关闭 既有用例改走显式锚点(Newest / Before),行为不变 变异验证:把 anchor_seen 初始化成 true(等于忽略锚点)后四条锚点用例变红
This commit is contained in:
@@ -613,13 +613,47 @@ fn direct_project_history_anchor_id(item: &Value) -> Option<String> {
|
|||||||
.or_else(|| direct_thread_item_identity(item))
|
.or_else(|| direct_thread_item_identity(item))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 一屏历史窗口在新端(较新一侧)的锚点。
|
||||||
|
///
|
||||||
|
/// 锚点一律是 `project.jsonl` 里的原始 item id(见 [`direct_project_history_anchor_id`])。
|
||||||
|
pub(crate) enum DirectProjectHistoryAnchor<'a> {
|
||||||
|
/// 不锚定:直接取文件尾最近的一屏。
|
||||||
|
Newest,
|
||||||
|
/// 取锚点条目**之前**的一屏:锚点本身不进窗口,是"下一屏"的边界(向后翻页用)。
|
||||||
|
Before(&'a str),
|
||||||
|
/// 取到锚点条目**为止**的一屏:锚点在窗口内(订阅回执给出的首屏边界用)。
|
||||||
|
Through(&'a str),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'a> DirectProjectHistoryAnchor<'a> {
|
||||||
|
/// 锚点的原始 item id;不锚定时为 `None`。
|
||||||
|
fn item_id(&self) -> Option<&'a str> {
|
||||||
|
match self {
|
||||||
|
Self::Newest => None,
|
||||||
|
Self::Before(item_id) | Self::Through(item_id) => Some(item_id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 锚点条目本身是否属于窗口:`Through` 含锚点,`Before` 把锚点留给下一屏。
|
||||||
|
fn includes_anchor(&self) -> bool {
|
||||||
|
matches!(self, Self::Through(_))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 这一条是不是锚点条目(按原始 item id 比对,不看归一身份)。
|
||||||
|
fn matches_item(&self, item: &Value) -> bool {
|
||||||
|
self.item_id()
|
||||||
|
.is_some_and(|wanted| direct_project_history_anchor_id(item).as_deref() == Some(wanted))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 从文件尾向前回扫一屏历史,返回 `(条目, 还有更早的条目, 记录时间, 本屏最老一条的原始 item id)`。
|
/// 从文件尾向前回扫一屏历史,返回 `(条目, 还有更早的条目, 记录时间, 本屏最老一条的原始 item id)`。
|
||||||
///
|
///
|
||||||
/// 分页锚点是原始 item id,收满 `limit` 条可显示条目后再多看一眼"还有没有更早的
|
/// 窗口的新端由 `anchor` 给出:`Before` 用于向后翻页(锚点本身不进窗口),`Through` 用于订阅
|
||||||
/// 条目"就停,不回读整份历史。
|
/// 回执给出的首屏边界(锚点进窗口),`Newest` 直接取文件尾。收满 `limit` 条可显示条目后再多
|
||||||
|
/// 看一眼"还有没有更早的条目"就停,不回读整份历史。
|
||||||
pub(crate) fn read_direct_project_history_items_slice_at(
|
pub(crate) fn read_direct_project_history_items_slice_at(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
before_item_id: Option<&str>,
|
anchor: DirectProjectHistoryAnchor<'_>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>, Option<String>), String> {
|
) -> Result<(Vec<Value>, bool, BTreeMap<String, u64>, Option<String>), String> {
|
||||||
let path = history_path(root);
|
let path = history_path(root);
|
||||||
@@ -629,8 +663,8 @@ pub(crate) fn read_direct_project_history_items_slice_at(
|
|||||||
let bounded_limit = limit.clamp(1, 200);
|
let bounded_limit = limit.clamp(1, 200);
|
||||||
let mut lines = DirectProjectHistoryReverseLines::open(&path)?;
|
let mut lines = DirectProjectHistoryReverseLines::open(&path)?;
|
||||||
let mut newest_first: Vec<(Value, u64)> = Vec::new();
|
let mut newest_first: Vec<(Value, u64)> = Vec::new();
|
||||||
// 锚点所在行本身不进窗口:它是"下一屏"的边界。
|
// 锚点命中之前先跳过更新的条目(首屏的 `Through` 会把订阅回执之后才完成的条目挡在外面)。
|
||||||
let mut anchor_seen = before_item_id.is_none();
|
let mut anchor_seen = matches!(anchor, DirectProjectHistoryAnchor::Newest);
|
||||||
let mut has_more = false;
|
let mut has_more = false;
|
||||||
while let Some(line) = lines.next_line()? {
|
while let Some(line) = lines.next_line()? {
|
||||||
if line.bytes.iter().all(u8::is_ascii_whitespace) {
|
if line.bytes.iter().all(u8::is_ascii_whitespace) {
|
||||||
@@ -652,10 +686,14 @@ pub(crate) fn read_direct_project_history_items_slice_at(
|
|||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
if !anchor_seen {
|
if !anchor_seen {
|
||||||
anchor_seen = before_item_id.is_some_and(|anchor| {
|
if !anchor.matches_item(&item) {
|
||||||
direct_project_history_anchor_id(&item).as_deref() == Some(anchor)
|
continue;
|
||||||
});
|
}
|
||||||
continue;
|
anchor_seen = true;
|
||||||
|
// `Before` 的锚点所在行本身不进窗口:它是"下一屏"的边界。
|
||||||
|
if !anchor.includes_anchor() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if is_direct_project_internal_context_item(&item) {
|
if is_direct_project_internal_context_item(&item) {
|
||||||
continue;
|
continue;
|
||||||
@@ -674,7 +712,7 @@ pub(crate) fn read_direct_project_history_items_slice_at(
|
|||||||
if !anchor_seen {
|
if !anchor_seen {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"DirectProject 历史中不存在 item:{}",
|
"DirectProject 历史中不存在 item:{}",
|
||||||
before_item_id.unwrap_or_default()
|
anchor.item_id().unwrap_or_default()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
newest_first.reverse();
|
newest_first.reverse();
|
||||||
@@ -700,7 +738,7 @@ pub(crate) fn read_direct_project_history_items_slice_at(
|
|||||||
///
|
///
|
||||||
/// 与"最近一屏"共用尾部回扫,读一行就能返回,不回读整份历史。
|
/// 与"最近一屏"共用尾部回扫,读一行就能返回,不回读整份历史。
|
||||||
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
|
pub(crate) fn read_direct_project_last_item_id_at(root: &Path) -> Result<Option<String>, String> {
|
||||||
Ok(read_direct_project_history_items_slice_at(root, None, 1)?.3)
|
Ok(read_direct_project_history_items_slice_at(root, DirectProjectHistoryAnchor::Newest, 1)?.3)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn read_direct_project_chat_history_at(
|
pub(crate) fn read_direct_project_chat_history_at(
|
||||||
@@ -750,6 +788,7 @@ mod tests {
|
|||||||
append_direct_project_history_item_at, append_direct_project_user_message_at,
|
append_direct_project_history_item_at, append_direct_project_user_message_at,
|
||||||
direct_project_history_scan_probe, history_path, is_direct_project_internal_context_item,
|
direct_project_history_scan_probe, history_path, is_direct_project_internal_context_item,
|
||||||
read_direct_project_chat_history_at, read_direct_project_history_items_at,
|
read_direct_project_chat_history_at, read_direct_project_history_items_at,
|
||||||
|
DirectProjectHistoryAnchor as Anchor,
|
||||||
};
|
};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
@@ -780,12 +819,14 @@ mod tests {
|
|||||||
});
|
});
|
||||||
append_direct_project_user_message_at(root.path(), &item).unwrap();
|
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();
|
super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(items, vec![item.clone()]);
|
assert_eq!(items, vec![item.clone()]);
|
||||||
assert!(timestamps["sent-message"] > 0);
|
assert!(timestamps["sent-message"] > 0);
|
||||||
append_direct_project_user_message_at(root.path(), &item).unwrap();
|
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();
|
super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20)
|
||||||
|
.unwrap();
|
||||||
assert_eq!(timestamps, reloaded);
|
assert_eq!(timestamps, reloaded);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -794,7 +835,8 @@ mod tests {
|
|||||||
let root = init_history_project("history-unknown-time");
|
let root = init_history_project("history-unknown-time");
|
||||||
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
|
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();
|
super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20)
|
||||||
|
.unwrap();
|
||||||
assert!(timestamps.is_empty());
|
assert!(timestamps.is_empty());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
read_direct_project_chat_history_at(root.path())
|
read_direct_project_chat_history_at(root.path())
|
||||||
@@ -822,7 +864,8 @@ mod tests {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
let (items, has_more, _, first_item_id) =
|
let (items, has_more, _, first_item_id) =
|
||||||
super::read_direct_project_history_items_slice_at(root.path(), None, 1).unwrap();
|
super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 1)
|
||||||
|
.unwrap();
|
||||||
let ids = items
|
let ids = items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
||||||
@@ -845,7 +888,8 @@ mod tests {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
let (newest, has_more, _, first_item_id) =
|
let (newest, has_more, _, first_item_id) =
|
||||||
super::read_direct_project_history_items_slice_at(root.path(), None, 2).unwrap();
|
super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 2)
|
||||||
|
.unwrap();
|
||||||
let ids = newest
|
let ids = newest
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
||||||
@@ -857,7 +901,7 @@ mod tests {
|
|||||||
let (older, has_more, _, first_item_id) =
|
let (older, has_more, _, first_item_id) =
|
||||||
super::read_direct_project_history_items_slice_at(
|
super::read_direct_project_history_items_slice_at(
|
||||||
root.path(),
|
root.path(),
|
||||||
first_item_id.as_deref(),
|
Anchor::Before(first_item_id.as_deref().expect("分页锚点")),
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -870,6 +914,107 @@ mod tests {
|
|||||||
assert_eq!(first_item_id.as_deref(), Some("codex-item-1"));
|
assert_eq!(first_item_id.as_deref(), Some("codex-item-1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 判据:首屏窗口的新端边界由 `Through` 锚点给出并**含**锚点条目。
|
||||||
|
///
|
||||||
|
/// 订阅回执里的 `lastCompletedItemId` 就是这条:比它更新的条目属于运行态事件,不能再从
|
||||||
|
/// 历史带一遍,否则同一条目在历史与实时各来一次(此前只靠前端合并兜住)。
|
||||||
|
#[test]
|
||||||
|
fn history_window_through_anchor_includes_it_and_drops_newer_items() {
|
||||||
|
let root = init_history_project("history-through-anchor");
|
||||||
|
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 (items, has_more, _, first_item_id) =
|
||||||
|
super::read_direct_project_history_items_slice_at(
|
||||||
|
root.path(),
|
||||||
|
Anchor::Through("codex-item-2"),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ids = items
|
||||||
|
.iter()
|
||||||
|
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec!["codex-item-1", "codex-item-2"],
|
||||||
|
"锚点本身在窗口内,比它更新的条目必须留给运行态"
|
||||||
|
);
|
||||||
|
assert!(!has_more);
|
||||||
|
assert_eq!(first_item_id.as_deref(), Some("codex-item-1"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判据:锚点就是文件里最老一条时,`Through` 窗口只有它一条且 `hasMore=false`;同一份历史用
|
||||||
|
/// `Before` 取锚点**之前**的一屏会取空——两者不是同一个窗口,方向搞反就会凭空多给一屏或吞掉一条。
|
||||||
|
#[test]
|
||||||
|
fn history_window_through_anchor_at_the_oldest_item_still_keeps_it() {
|
||||||
|
let root = init_history_project("history-through-anchor-oldest");
|
||||||
|
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"),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
let (items, has_more, _, first_item_id) =
|
||||||
|
super::read_direct_project_history_items_slice_at(
|
||||||
|
root.path(),
|
||||||
|
Anchor::Through("codex-item-1"),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let ids = items
|
||||||
|
.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"));
|
||||||
|
|
||||||
|
let (empty, has_more, _, first_item_id) =
|
||||||
|
super::read_direct_project_history_items_slice_at(
|
||||||
|
root.path(),
|
||||||
|
Anchor::Before("codex-item-1"),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(
|
||||||
|
empty.is_empty(),
|
||||||
|
"`Before` 的锚点是下一屏的边界,本身不进窗口"
|
||||||
|
);
|
||||||
|
assert!(!has_more);
|
||||||
|
assert_eq!(first_item_id, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判据:锚点在历史里不存在时失败关闭,错误里带锚点 id(首屏与翻页共用这条)。
|
||||||
|
///
|
||||||
|
/// 变异验证:把"锚点没命中"当成"读完整个文件"(例如沿用旧的 `anchor_seen` 初始化)时,
|
||||||
|
/// 首屏会绕过订阅回执的边界去取文件尾,这条用例与上面的 `Through` 用例会一起变红。
|
||||||
|
#[test]
|
||||||
|
fn history_window_unknown_anchor_fails_closed() {
|
||||||
|
let root = init_history_project("history-unknown-anchor");
|
||||||
|
write_history_lines(root.path(), &[RESPONSE_ITEM_ROW]);
|
||||||
|
|
||||||
|
for anchor in [
|
||||||
|
Anchor::Through("codex-missing"),
|
||||||
|
Anchor::Before("codex-missing"),
|
||||||
|
] {
|
||||||
|
let error = super::read_direct_project_history_items_slice_at(root.path(), anchor, 20)
|
||||||
|
.expect_err("不存在的锚点必须失败关闭");
|
||||||
|
assert!(
|
||||||
|
error.contains("不存在 item:codex-missing"),
|
||||||
|
"错误里要带锚点 id:{error}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 判据:尾部残行(上一行完整、这一行没有换行结尾)跳过继续回扫,不报错也不就地结束。
|
/// 判据:尾部残行(上一行完整、这一行没有换行结尾)跳过继续回扫,不报错也不就地结束。
|
||||||
///
|
///
|
||||||
/// 变异验证:`terminated` 按分支硬编码(旧实现)会把这条残行当成完整行,"读一屏"直接
|
/// 变异验证:`terminated` 按分支硬编码(旧实现)会把这条残行当成完整行,"读一屏"直接
|
||||||
@@ -888,7 +1033,8 @@ mod tests {
|
|||||||
.expect("write truncated history tail");
|
.expect("write truncated history tail");
|
||||||
|
|
||||||
let (items, has_more, _, first_item_id) =
|
let (items, has_more, _, first_item_id) =
|
||||||
super::read_direct_project_history_items_slice_at(root.path(), None, 20).unwrap();
|
super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20)
|
||||||
|
.unwrap();
|
||||||
let ids = items
|
let ids = items
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
||||||
@@ -913,8 +1059,9 @@ mod tests {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
let error = super::read_direct_project_history_items_slice_at(root.path(), None, 20)
|
let error =
|
||||||
.expect_err("换行结尾的坏行必须失败关闭");
|
super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 20)
|
||||||
|
.expect_err("换行结尾的坏行必须失败关闭");
|
||||||
assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}");
|
assert!(error.starts_with("解析 DirectProject 历史失败"), "{error}");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -932,7 +1079,8 @@ mod tests {
|
|||||||
|
|
||||||
// 首屏取 2 条:本屏最老是调用条目,锚点必须是它在文件里的原始 id,不是 call-1。
|
// 首屏取 2 条:本屏最老是调用条目,锚点必须是它在文件里的原始 id,不是 call-1。
|
||||||
let (newest, has_more, _, first_item_id) =
|
let (newest, has_more, _, first_item_id) =
|
||||||
super::read_direct_project_history_items_slice_at(root.path(), None, 2).unwrap();
|
super::read_direct_project_history_items_slice_at(root.path(), Anchor::Newest, 2)
|
||||||
|
.unwrap();
|
||||||
let ids = newest
|
let ids = newest
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
.filter_map(|item| item.get("id").and_then(Value::as_str))
|
||||||
@@ -945,7 +1093,7 @@ mod tests {
|
|||||||
let (older, has_more, _, first_item_id) =
|
let (older, has_more, _, first_item_id) =
|
||||||
super::read_direct_project_history_items_slice_at(
|
super::read_direct_project_history_items_slice_at(
|
||||||
root.path(),
|
root.path(),
|
||||||
first_item_id.as_deref(),
|
Anchor::Before(first_item_id.as_deref().expect("分页锚点")),
|
||||||
2,
|
2,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::agent::{read_direct_project_chat_history_at, read_direct_project_last_item_id_at};
|
use crate::agent::{
|
||||||
|
read_direct_project_chat_history_at, read_direct_project_last_item_id_at,
|
||||||
|
DirectProjectHistoryAnchor,
|
||||||
|
};
|
||||||
use crate::ui_editor::resource::font::FontAsset;
|
use crate::ui_editor::resource::font::FontAsset;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::collections::{BTreeMap, HashSet};
|
use std::collections::{BTreeMap, HashSet};
|
||||||
@@ -5376,21 +5379,34 @@ pub(crate) fn consume_direct_project_thread(
|
|||||||
consume_direct_thread(subscription_id.trim())
|
consume_direct_thread(subscription_id.trim())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 读一屏项目对话历史。
|
||||||
|
///
|
||||||
|
/// 窗口两端各由一个锚点给出,两者互斥(都传会报错):`before_item_id` 是**旧端**边界(不含
|
||||||
|
/// 该条,向后翻页用),`through_item_id` 是**新端**边界(含该条,取 `subscribe` 回执里的
|
||||||
|
/// `lastCompletedItemId`,比它更新的条目只从运行态事件来);都不传就是文件尾最近的一屏。
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub(crate) async fn read_direct_project_history_slice(
|
pub(crate) async fn read_direct_project_history_slice(
|
||||||
project_path: String,
|
project_path: String,
|
||||||
before_item_id: Option<String>,
|
before_item_id: Option<String>,
|
||||||
|
through_item_id: Option<String>,
|
||||||
limit: Option<usize>,
|
limit: Option<usize>,
|
||||||
) -> Result<DirectThreadHistorySlice, String> {
|
) -> Result<DirectThreadHistorySlice, String> {
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
let root = Path::new(project_path.trim());
|
let root = Path::new(project_path.trim());
|
||||||
enforce_project_permission_policy(root, "conversation.read")?;
|
enforce_project_permission_policy(root, "conversation.read")?;
|
||||||
|
let anchor = match (before_item_id.as_deref(), through_item_id.as_deref()) {
|
||||||
|
(Some(_), Some(_)) => {
|
||||||
|
return Err(
|
||||||
|
"DirectProject 历史切片只接受一个锚点(beforeItemId / throughItemId)"
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(Some(before), None) => DirectProjectHistoryAnchor::Before(before),
|
||||||
|
(None, Some(through)) => DirectProjectHistoryAnchor::Through(through),
|
||||||
|
(None, None) => DirectProjectHistoryAnchor::Newest,
|
||||||
|
};
|
||||||
let (raw_items, has_more, recorded_at_ms, first_item_id) =
|
let (raw_items, has_more, recorded_at_ms, first_item_id) =
|
||||||
read_direct_project_history_items_slice_at(
|
read_direct_project_history_items_slice_at(root, anchor, limit.unwrap_or(20))?;
|
||||||
root,
|
|
||||||
before_item_id.as_deref(),
|
|
||||||
limit.unwrap_or(20),
|
|
||||||
)?;
|
|
||||||
let items = direct_thread_items_from_history(root, &raw_items, |item| {
|
let items = direct_thread_items_from_history(root, &raw_items, |item| {
|
||||||
direct_thread_item_identity(item)
|
direct_thread_item_identity(item)
|
||||||
.and_then(|identity| recorded_at_ms.get(&identity).copied())
|
.and_then(|identity| recorded_at_ms.get(&identity).copied())
|
||||||
|
|||||||
Reference in New Issue
Block a user