fix: stabilize local folder AI document workflow

- scope local-folder PageTree revision and document sidebar rendering to fileTreeScope

- preserve projected table/image attrs for local Markdown aggregate fallback

- avoid FileTree restore forced layouts on cold design open

- add API ChatOnly provider runtime and local OCR task handling regressions
This commit is contained in:
lix-2026
2026-06-02 17:17:49 +08:00
parent 610b23d3a5
commit 9f4b5c4d48
27 changed files with 3825 additions and 229 deletions
@@ -2838,7 +2838,11 @@ fn load_local_folder_page_tree_snapshot_for_scope(
"PageTree scope parentRelativePath 必须指向目录",
));
}
let watch_revision = local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?;
let watch_revision = if parent_relative_path.is_empty() {
local_folder_watch_revision_for_root(&canonical_root, &root_source_uri)?
} else {
local_folder_watch_revision_for_directory(&canonical_root, &scan_root, &root_source_uri)?
};
let cache_key = format!("{root_source_uri}\n{parent_relative_path}");
if let Ok(cache) = local_page_tree_snapshot_cache().lock() {
if let Some(entry) = cache.get(&cache_key) {
@@ -7046,7 +7050,45 @@ fn should_ignore_entry(relative_path: &str, file_name: &str) -> bool {
if matches!(file_name, ".git" | "node_modules" | ".mnote") {
return true;
}
relative_path == ".mnote/trash" || relative_path.starts_with(".mnote/trash/")
relative_path == ".mnote/trash"
|| relative_path.starts_with(".mnote/trash/")
|| is_local_ocr_intermediate_entry(relative_path, file_name)
}
fn is_local_ocr_intermediate_entry(relative_path: &str, file_name: &str) -> bool {
let normalized = relative_path.trim().trim_matches('/').replace('\\', "/");
if normalized.is_empty() {
return false;
}
let segments = normalized
.split('/')
.filter(|segment| !segment.trim().is_empty())
.collect::<Vec<_>>();
if segments.is_empty() {
return false;
}
let in_ocr_dir = segments
.len()
.checked_sub(2)
.and_then(|index| segments.get(index))
.map(|segment| segment.ends_with(".ocr"))
.unwrap_or(false);
let in_ocr_images_dir = segments
.windows(2)
.any(|window| window[0].ends_with(".ocr") && window[1] == "images");
if in_ocr_images_dir {
return true;
}
if !in_ocr_dir {
return false;
}
let lower = file_name.to_ascii_lowercase();
lower == "layout.json"
|| lower == "images"
|| lower.ends_with("_content_list.json")
|| lower.ends_with("_content_list_v2.json")
|| lower.ends_with("_model.json")
|| lower.ends_with("_origin.pdf")
}
fn compare_local_entries(a: &LocalFolderEntry, b: &LocalFolderEntry) -> Ordering {
@@ -7723,6 +7765,14 @@ pub(crate) fn local_workspace_id(root: &Path) -> String {
fn local_folder_watch_revision_for_root(
root: &Path,
root_source_uri: &str,
) -> Result<LocalFolderWatchRevision, WebError> {
local_folder_watch_revision_for_directory(root, root, root_source_uri)
}
fn local_folder_watch_revision_for_directory(
root: &Path,
directory: &Path,
root_source_uri: &str,
) -> Result<LocalFolderWatchRevision, WebError> {
let mut hasher = DefaultHasher::new();
let mut entry_count = 0usize;
@@ -7763,7 +7813,7 @@ fn local_folder_watch_revision_for_root(
visit(
root,
root,
directory,
&mut hasher,
&mut entry_count,
&mut latest_modified_ms,
@@ -9282,9 +9332,10 @@ mod tests {
get_share_links, get_user_access_policy, initialize_local_page_id,
initialize_local_workspace_for_actor, load_local_folder_file_tree_children_snapshot,
load_local_folder_file_tree_snapshot, load_local_folder_file_tree_snapshot_with_reveal,
load_local_folder_page_tree_snapshot, local_folder_watch_revision,
local_markdown_path_page_id, local_resource_write_editor_blocks, local_workspace_id,
open_local_file, read_local_resource, record_shared_cache, record_sync_pending_change,
load_local_folder_page_tree_scope_snapshot, load_local_folder_page_tree_snapshot,
local_folder_watch_revision, local_markdown_path_page_id,
local_resource_write_editor_blocks, local_workspace_id, open_local_file,
read_local_resource, record_shared_cache, record_sync_pending_change,
resolve_local_markdown_page_aggregate, save_local_markdown_page,
update_local_markdown_title, validate_local_access_root, write_local_markdown_asset,
write_local_markdown_page_body, write_local_mindmap_data, write_local_resource,
@@ -9424,6 +9475,30 @@ mod tests {
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_folder_page_tree_scope_cache_ignores_outside_changes() {
let root = temp_root("mnote-page-tree-scope-cache");
init_workspace(&root);
std::fs::create_dir_all(root.join("design")).expect("create design");
std::fs::create_dir_all(root.join("notes")).expect("create notes");
std::fs::write(root.join("design").join("page.md"), "# Design\n").expect("write design");
std::fs::write(root.join("notes").join("outside.md"), "# Outside\n")
.expect("write outside");
let root_uri = format!("file://{}", root.display());
super::reset_local_page_tree_snapshot_test_loads();
let first =
load_local_folder_page_tree_scope_snapshot(&root_uri, "design").expect("first scope");
std::fs::write(root.join("notes").join("next.md"), "# Next\n").expect("write next");
let second =
load_local_folder_page_tree_scope_snapshot(&root_uri, "design").expect("second scope");
assert_eq!(first.projection, second.projection);
assert_eq!(super::local_page_tree_snapshot_scan_test_loads(), 1);
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn local_markdown_identity_uses_path_even_when_frontmatter_has_mnote_id() {
let root = temp_root("mnote-local-frontmatter-path-id");
@@ -13320,12 +13395,41 @@ fn main() {}
init_workspace(&root);
let root_uri = format!("file://{}", root.display());
std::fs::create_dir_all(root.join("docs").join("Page.ocr")).expect("ocr dir");
std::fs::create_dir_all(root.join("docs").join("Page.ocr").join("images"))
.expect("ocr images dir");
std::fs::write(root.join("docs").join("Page.md"), "# Page\n").expect("page");
std::fs::write(
root.join("docs").join("Page.ocr").join("photo.png.ocr.md"),
"---\nmnote_ocr_version: 1\nprovider: mock\nmodel_version: vlm\nowner_document: ../Page.md\nsource_path: ./Page.assets/photo.png\nsource_root_relative_path: docs/Page.assets/photo.png\nsource_size: 3\nsource_mtime_ms: 1\nstatus: done\ncreated_at: 1\nupdated_at: 1\n---\n\nOCR-only-token\n",
)
.expect("ocr markdown");
std::fs::write(root.join("docs").join("Page.ocr").join("layout.json"), "{}")
.expect("ocr layout");
std::fs::write(
root.join("docs")
.join("Page.ocr")
.join("abc_content_list.json"),
"[]",
)
.expect("ocr content list");
std::fs::write(
root.join("docs").join("Page.ocr").join("abc_model.json"),
"{}",
)
.expect("ocr model");
std::fs::write(
root.join("docs").join("Page.ocr").join("abc_origin.pdf"),
b"%PDF-1.4\n",
)
.expect("ocr origin pdf");
std::fs::write(
root.join("docs")
.join("Page.ocr")
.join("images")
.join("page_1.jpg"),
b"jpg",
)
.expect("ocr image asset");
let file_tree = load_local_folder_file_tree_children_snapshot(&root_uri, "docs/Page.ocr")
.expect("file tree");
@@ -13335,6 +13439,20 @@ fn main() {}
assert!(file_items
.iter()
.any(|item| item["title"].as_str() == Some("photo.png.ocr.md")));
for hidden_title in [
"layout.json",
"abc_content_list.json",
"abc_model.json",
"abc_origin.pdf",
"images",
] {
assert!(
!file_items
.iter()
.any(|item| item["title"].as_str() == Some(hidden_title)),
"OCR 中间产物不应出现在 FileTree: {hidden_title}"
);
}
let page_tree = load_local_folder_page_tree_snapshot(&root_uri).expect("page tree");
let page_items = page_tree.projection["items"]