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:
@@ -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\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(""));
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user