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
+307 -15
View File
@@ -64,6 +64,13 @@ pub(crate) struct OcrInsertRequest {
mode: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct OcrDeleteRequest {
root_uri: String,
source_root_relative_path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct OcrIndex {
@@ -108,6 +115,18 @@ struct MineruClientConfig {
max_polls: usize,
}
#[derive(Debug)]
struct MineruZipAsset {
relative_path: PathBuf,
bytes: Vec<u8>,
}
#[derive(Debug)]
struct MineruZipExtraction {
markdown: String,
assets: Vec<MineruZipAsset>,
}
#[derive(Debug, Clone)]
struct OcrSidecarPlan {
owner_document_path: String,
@@ -284,6 +303,47 @@ pub(crate) async fn status(
Ok(ok_json(&context, json!({ "ok": true, "job": job })))
}
pub(crate) async fn delete_job(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Json(request): Json<OcrDeleteRequest>,
) -> Result<(StatusCode, HeaderMap, Json<Value>), WebError> {
let root =
ensure_local_workspace_write_access_with_state(&state, &context, request.root_uri.trim())
.map_err(|error| error.with_context(&context))?;
let source = normalize_relative_path(&request.source_root_relative_path)?;
let mut index = read_ocr_index(&root)?;
let removed = index.entries.remove(&source);
if let Some(entry) = &removed {
let sidecar = root.join(&entry.ocr_root_relative_path);
ensure_target_under_root(&root, &sidecar, "local_ocr_delete_root_escape")?;
if sidecar.exists() {
fs::remove_file(&sidecar).map_err(|error| {
WebError::bad_request_code(
"local_ocr_delete_failed",
format!("无法删除 OCR Markdown {}: {error}", sidecar.display()),
)
.with_context(&context)
})?;
}
cleanup_empty_ocr_sidecar_dir(&root, &sidecar)?;
}
write_ocr_index(&root, &index)?;
let key = format!("{}:{source}", request.root_uri.trim());
if let Ok(mut jobs) = state.local_ocr_active_jobs.write() {
jobs.remove(&key);
}
broadcast_ocr_job_deleted(&state, request.root_uri.trim(), &source, removed.as_ref());
Ok(ok_json(
&context,
json!({
"ok": true,
"deleted": removed.is_some(),
"sourceRootRelativePath": source,
}),
))
}
pub(crate) async fn read(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
@@ -519,7 +579,9 @@ async fn run_mineru_ocr(
entry = advance_ocr_entry(&entry, "downloading", now_ms(), "", None);
upsert_and_broadcast_ocr_index_entry(state, root, root_uri, entry)?;
let zip_bytes = download_mineru_result_zip(&client, &zip_url).await?;
extract_mineru_markdown_from_zip(&zip_bytes)
let extraction = extract_mineru_markdown_and_assets_from_zip(&zip_bytes)?;
write_mineru_zip_assets(plan, &extraction.assets)?;
Ok(extraction.markdown)
}
#[derive(Debug)]
@@ -704,7 +766,9 @@ async fn download_mineru_result_zip(
})
}
fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
fn extract_mineru_markdown_and_assets_from_zip(
bytes: &[u8],
) -> Result<MineruZipExtraction, WebError> {
let cursor = Cursor::new(bytes);
let mut archive = zip::ZipArchive::new(cursor).map_err(|error| {
WebError::bad_gateway_code(
@@ -713,6 +777,7 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
)
})?;
let mut candidates = Vec::<(String, String)>::new();
let mut assets = Vec::<MineruZipAsset>::new();
for index in 0..archive.len() {
let mut file = archive.by_index(index).map_err(|error| {
WebError::bad_gateway_code(
@@ -721,19 +786,36 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
)
})?;
let name = file.name().replace('\\', "/");
if !name.to_ascii_lowercase().ends_with(".md") || name.contains("/.") {
if file.is_dir() || name.contains("/.") {
continue;
}
let mut markdown = String::new();
file.read_to_string(&mut markdown).map_err(|error| {
if name.to_ascii_lowercase().ends_with(".md") {
let mut markdown = String::new();
file.read_to_string(&mut markdown).map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_markdown_read_failed",
format!("MinerU Markdown 读取失败: {error}"),
)
})?;
candidates.push((name, markdown));
continue;
}
let Some(relative_path) = safe_mineru_asset_relative_path(&name) else {
continue;
};
let mut asset_bytes = Vec::new();
file.read_to_end(&mut asset_bytes).map_err(|error| {
WebError::bad_gateway_code(
"mineru_result_markdown_read_failed",
format!("MinerU Markdown 读取失败: {error}"),
"mineru_result_asset_read_failed",
format!("MinerU 资源读取失败: {error}"),
)
})?;
candidates.push((name, markdown));
assets.push(MineruZipAsset {
relative_path,
bytes: asset_bytes,
});
}
candidates
let markdown = candidates
.into_iter()
.max_by_key(|(name, markdown)| {
let preferred =
@@ -747,7 +829,55 @@ fn extract_mineru_markdown_from_zip(bytes: &[u8]) -> Result<String, WebError> {
"mineru_result_markdown_missing",
"MinerU 结果包中缺少 Markdown 文件",
)
})
})?;
Ok(MineruZipExtraction { markdown, assets })
}
fn safe_mineru_asset_relative_path(name: &str) -> Option<PathBuf> {
let normalized = name.trim().trim_start_matches('/').replace('\\', "/");
if normalized.is_empty() {
return None;
}
let mut path = PathBuf::new();
for component in Path::new(&normalized).components() {
match component {
Component::Normal(value) => {
let text = value.to_str()?.trim();
if text.is_empty() || text == "." || text == ".." || text.starts_with('.') {
return None;
}
path.push(text);
}
_ => return None,
}
}
(!path.as_os_str().is_empty()).then_some(path)
}
fn write_mineru_zip_assets(
plan: &OcrSidecarPlan,
assets: &[MineruZipAsset],
) -> Result<(), WebError> {
let sidecar_dir = plan.ocr_path.parent().unwrap_or_else(|| Path::new(""));
for asset in assets {
let target = sidecar_dir.join(&asset.relative_path);
ensure_target_under_root(sidecar_dir, &target, "local_ocr_asset_root_escape")?;
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"local_ocr_asset_create_failed",
format!("无法创建 OCR 资源目录 {}: {error}", parent.display()),
)
})?;
}
fs::write(&target, &asset.bytes).map_err(|error| {
WebError::bad_request_code(
"local_ocr_asset_write_failed",
format!("无法写入 OCR 资源 {}: {error}", target.display()),
)
})?;
}
Ok(())
}
fn parse_mineru_json(body: &str, code: &'static str) -> Result<Value, WebError> {
@@ -760,7 +890,52 @@ fn find_upload_url(value: &Value) -> Option<String> {
if let Some(url) = find_json_string_by_keys(value, &["upload_url", "uploadUrl"]) {
return Some(url);
}
None
find_json_url_array_item_by_keys(
value,
&[
"file_urls",
"fileUrls",
"file_url",
"fileUrl",
"urls",
"upload_urls",
"uploadUrls",
],
)
}
fn find_json_url_array_item_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
match value {
Value::Object(map) => {
for key in keys {
if let Some(found) = map.get(*key).and_then(find_first_non_empty_json_string) {
return Some(found);
}
}
for nested in map.values() {
if let Some(found) = find_json_url_array_item_by_keys(nested, keys) {
return Some(found);
}
}
None
}
Value::Array(items) => items
.iter()
.find_map(|item| find_json_url_array_item_by_keys(item, keys)),
_ => None,
}
}
fn find_first_non_empty_json_string(value: &Value) -> Option<String> {
match value {
Value::String(text) => {
let trimmed = text.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
}
Value::Array(items) => items.iter().find_map(find_first_non_empty_json_string),
Value::Object(map) => map.values().find_map(find_first_non_empty_json_string),
_ => None,
}
}
fn find_json_string_by_keys(value: &Value, keys: &[&str]) -> Option<String> {
@@ -1065,6 +1240,70 @@ fn upsert_and_broadcast_ocr_index_entry(
Ok(())
}
fn cleanup_empty_ocr_sidecar_dir(root: &Path, sidecar: &Path) -> Result<(), WebError> {
let Some(parent) = sidecar.parent() else {
return Ok(());
};
ensure_target_under_root(root, parent, "local_ocr_delete_root_escape")?;
let Ok(entries) = fs::read_dir(parent) else {
return Ok(());
};
let has_other_sidecars = entries.filter_map(Result::ok).any(|entry| {
entry
.file_name()
.to_string_lossy()
.to_ascii_lowercase()
.ends_with(".ocr.md")
});
if !has_other_sidecars {
fs::remove_dir_all(parent).map_err(|error| {
WebError::bad_request_code(
"local_ocr_delete_failed",
format!("无法删除 OCR sidecar 目录 {}: {error}", parent.display()),
)
})?;
}
Ok(())
}
fn broadcast_ocr_job_deleted(
state: &AppState,
root_uri: &str,
source_root_relative_path: &str,
removed: Option<&OcrIndexEntry>,
) {
let now = now_ms();
let job = json!({
"jobId": removed.map(|entry| entry.job_id.as_str()).unwrap_or(""),
"ownerDocumentId": removed.map(|entry| entry.owner_document_id.as_str()).unwrap_or(""),
"ownerDocumentPath": removed.map(|entry| entry.owner_document_path.as_str()).unwrap_or(""),
"sourceRootRelativePath": source_root_relative_path,
"ocrRootRelativePath": removed.map(|entry| entry.ocr_root_relative_path.as_str()).unwrap_or(""),
"provider": removed.map(|entry| entry.provider.as_str()).unwrap_or(""),
"modelVersion": removed.map(|entry| entry.model_version.as_str()).unwrap_or(""),
"status": "deleted",
"stageLabel": "已删除",
"stale": false,
"updatedAtMs": now,
"finishedAtMs": now,
"plainTextPreview": "",
"error": null,
});
let payload = json!({
"schema": "mnote.local_ocr.job.updated.v1",
"kind": "local_ocr_job_updated",
"eventType": "local_ocr.job.updated",
"sourceKind": "local_folder",
"rootUri": root_uri,
"relativePath": source_root_relative_path,
"documentId": removed.map(|entry| entry.owner_document_id.as_str()).unwrap_or(""),
"revision": now.to_string(),
"job": job,
});
let _ = state.local_ocr_job_tx.send(payload.clone());
let _ = state.stream_delta_tx.send(payload);
}
fn broadcast_ocr_job_update(state: &AppState, root: &Path, root_uri: &str, entry: &OcrIndexEntry) {
let job = ocr_job_payload(root, entry);
let key = format!("{root_uri}:{}", entry.source_root_relative_path);
@@ -1420,7 +1659,7 @@ mod tests {
.collect()
}
fn build_test_mineru_zip(markdown: &str) -> Vec<u8> {
fn build_test_mineru_zip_with_files(markdown: &str, files: &[(&str, &[u8])]) -> Vec<u8> {
let mut bytes = Cursor::new(Vec::<u8>::new());
{
let mut writer = zip::ZipWriter::new(&mut bytes);
@@ -1428,6 +1667,12 @@ mod tests {
.start_file("full.md", zip::write::SimpleFileOptions::default())
.expect("zip start file");
writer.write_all(markdown.as_bytes()).expect("zip markdown");
for (name, content) in files {
writer
.start_file(*name, zip::write::SimpleFileOptions::default())
.expect("zip asset start file");
writer.write_all(content).expect("zip asset");
}
writer.finish().expect("zip finish");
}
bytes.into_inner()
@@ -1585,6 +1830,35 @@ mod tests {
assert!(read_payload["markdown"]
.as_str()
.is_some_and(|markdown| markdown.contains("Route OCR Token")));
let delete_response = app()
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/local-folder/ocr/jobs")
.header("content-type", "application/json")
.header("x-mnote-actor-id", "user_test")
.header("x-mnote-actor-type", "user")
.body(Body::from(
json!({
"rootUri": root_uri,
"sourceRootRelativePath": "docs/Page.assets/photo.png"
})
.to_string(),
))
.expect("delete request"),
)
.await
.expect("delete response");
assert_eq!(delete_response.status(), StatusCode::OK);
assert!(!root
.join("docs")
.join("Page.ocr")
.join("photo.png.ocr.md")
.exists());
assert!(read_ocr_index(&root)
.expect("index after delete")
.entries
.is_empty());
let _ = fs::remove_dir_all(root);
}
@@ -1690,7 +1964,10 @@ mod tests {
let base_url = format!("http://{}", listener.local_addr().expect("mock addr"));
let upload_count = Arc::new(AtomicUsize::new(0));
let poll_count = Arc::new(AtomicUsize::new(0));
let zip_bytes = Arc::new(build_test_mineru_zip("# MinerU Result\n\n识别文本"));
let zip_bytes = Arc::new(build_test_mineru_zip_with_files(
"# MinerU Result\n\n![OCR 图](images/ocr.png)\n\n识别文本",
&[("images/ocr.png", b"png-bytes")],
));
let mock_mineru = axum::Router::new()
.route(
@@ -1699,8 +1976,12 @@ mod tests {
let base_url = base_url.clone();
|| async move {
Json(json!({
"batch_id": "batch_1",
"file_urls": [{ "upload_url": format!("{base_url}/upload/source") }]
"code": 0,
"msg": "ok",
"data": {
"batch_id": "batch_1",
"file_urls": [format!("{base_url}/upload/source")]
}
}))
}
}),
@@ -1831,7 +2112,18 @@ mod tests {
fs::read_to_string(root.join("docs").join("Page.ocr").join("photo.png.ocr.md"))
.expect("sidecar");
assert!(sidecar.contains("provider: mineru"));
assert!(sidecar.contains("![OCR 图](images/ocr.png)"));
assert!(sidecar.contains("识别文本"));
assert_eq!(
fs::read(
root.join("docs")
.join("Page.ocr")
.join("images")
.join("ocr.png")
)
.expect("sidecar image"),
b"png-bytes"
);
mock_handle.abort();
let _ = fs::remove_dir_all(root);