feat(rag): harden post-LightRAG runtime

Retire legacy OCR/media/evidence fallbacks, add local-folder event bus and Page Aggregate guards, and archive completed design checklists.

Validation: cargo test -p mnote-web -- --test-threads=1; cargo test --workspace -- --test-threads=1; git diff --check; codegraph sync .; codegraph_status.
This commit is contained in:
lix-2026
2026-06-07 10:35:21 +08:00
parent 22a92edcda
commit 9551d4c1dc
59 changed files with 4053 additions and 5249 deletions
+5 -12
View File
@@ -1087,7 +1087,7 @@ mod tests {
}
#[tokio::test]
async fn evidence_search_route_prefers_sqlite_index() {
async fn evidence_search_route_returns_retired_guard() {
let root = std::env::temp_dir().join(format!(
"mnote-evidence-route-sqlite-{}-{}",
std::process::id(),
@@ -1182,22 +1182,15 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::GONE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let first = payload["results"]
.as_array()
.and_then(|items| items.first())
.expect("sqlite evidence result");
assert_eq!(payload["ok"], false);
assert_eq!(
first["source"]["ownerDocumentPath"].as_str(),
Some("README.md")
);
assert_eq!(
first["source"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["code"].as_str(),
Some("mnote_evidence_search_retired")
);
fs::remove_dir_all(&root).ok();
}
+1 -1
View File
@@ -3646,7 +3646,7 @@ mod tests {
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"disabled""#));
assert!(html.contains(r#""transport":"tree-live-ws""#));
let _ = std::fs::remove_dir_all(&base);
}
@@ -10608,7 +10608,7 @@ mod tests {
}
#[tokio::test]
async fn page_ai_capabilities_expose_local_index_and_toggle_tools() {
async fn page_ai_capabilities_expose_knowledge_rag_and_toggle_tools() {
let _env_guard = env_lock().lock().expect("env lock");
let hermes_home = std::env::temp_dir().join(format!(
"mnote-web-ai-capability-policy-{}",
@@ -13223,20 +13223,16 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["sessions"][0]["sessionId"], "sess_1");
assert_eq!(payload["persistence"], "convex_acp_runtime_store");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
let query_body = captured_body.lock().expect("captured convex body").clone();
assert_eq!(query_body["path"], "aiSessions:listRuntimeRuns");
assert_eq!(query_body["args"]["userId"], "user_1");
assert_eq!(query_body["args"]["workspaceId"], "ws_1");
assert_eq!(query_body["args"]["documentId"], "doc_1");
assert_eq!(query_body, Value::Null);
}
#[tokio::test]
@@ -13317,28 +13313,16 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["persistence"], "convex_acp_runtime_store");
assert_eq!(payload["session"]["sessionId"], "sess_1");
assert_eq!(payload["session"]["runs"][0]["runId"], "run_1");
assert_eq!(payload["runtime"]["runId"], "run_1");
assert_eq!(payload["events"][0]["eventType"], "message.delta");
assert_eq!(
payload["session"]["messages"].as_array().map(Vec::len),
Some(0)
);
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
let bodies = captured_bodies.lock().expect("captured convex bodies");
assert_eq!(bodies[0]["path"], "aiSessions:listRuntimeRuns");
assert_eq!(bodies[0]["args"]["userId"], "user_1");
assert_eq!(bodies[0]["args"]["sessionId"], "sess_1");
assert_eq!(bodies[1]["path"], "aiSessions:listRuntimeEvents");
assert_eq!(bodies[1]["args"]["runId"], "run_1");
assert!(bodies.is_empty());
}
#[tokio::test]
@@ -13403,15 +13387,13 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["resumed"], true);
assert_eq!(payload["resumeSource"], "convex_acp_runtime_store");
assert_eq!(payload["session"]["runs"][0]["runId"], "run_1");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
}
#[tokio::test]
@@ -13486,7 +13468,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_acp_session_search_returns_convex_snippets() {
async fn hermes_client_acp_session_search_legacy_convex_returns_retired_guard() {
let captured_body = Arc::new(Mutex::new(Value::Null));
let captured_for_route = Arc::clone(&captured_body);
let mock = axum::Router::new().route(
@@ -13548,21 +13530,16 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["results"][0]["sessionId"], "sess_1");
assert_eq!(payload["results"][0]["snippet"], "帮我总结化学页面");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
let query_body = captured_body.lock().expect("captured convex body").clone();
assert_eq!(query_body["path"], "aiSessions:searchRuntimeSessions");
assert_eq!(query_body["args"]["userId"], "user_1");
assert_eq!(query_body["args"]["workspaceId"], "ws_1");
assert_eq!(query_body["args"]["q"], "化学");
assert_eq!(query_body["args"]["limit"], 5);
assert_eq!(query_body, Value::Null);
}
#[tokio::test]
@@ -14520,13 +14497,40 @@ mod tests {
&"/api/hermes/client/runs".parse().expect("uri"),
&headers,
);
let root = std::env::temp_dir().join(format!(
"mnote-local-agent-run-receipt-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create root");
std::fs::write(root.join("README.md"), "# Readme\nold\n").expect("write readme");
std::fs::write(root.join("Other.md"), "# Other\nold\n").expect("write other");
let root_uri = format!("file://{}", root.display());
let payload = json!({
"workspaceId": "local-workspace-1",
"documentId": "local-md:README.md",
"sessionId": "sess_local_1",
"rootUri": "file:///tmp/mnote-agent-run-receipt",
"actorId": "user_1"
"rootUri": root_uri,
"actorId": "user_1",
"contextRefs": [{
"kind": "folder",
"rootUri": root_uri,
"relativePath": ""
}],
"targetPackage": {
"schema": "mnote.agent_target_package.v1",
"allowedFiles": ["README.md"],
"currentFile": {
"relativePath": "README.md"
}
}
});
let before = local_agent_audit_collect_snapshot_for_payload(&payload, None)
.expect("before allowed-files snapshot");
std::fs::write(root.join("README.md"), "# Readme\nnew\n").expect("modify readme");
std::fs::write(root.join("Other.md"), "# Other\nnew\n").expect("modify other");
let after = local_agent_audit_collect_snapshot_for_payload(&payload, Some(&before))
.expect("after allowed-files snapshot");
let changed_files = json!([
{
"path": "README.md",
@@ -14541,8 +14545,8 @@ mod tests {
"reasonix",
"completed",
changed_files,
None,
None,
Some(&before),
Some(&after),
false,
);
let receipt = &event["agentRunReceipt"];
@@ -14553,6 +14557,11 @@ mod tests {
assert_eq!(receipt["status"], "completed");
assert_eq!(receipt["changedFiles"][0]["path"], "README.md");
assert_eq!(receipt["refresh"]["touchesCurrentFile"], true);
assert_eq!(event["auditScope"]["scope"], "allowed_files");
assert_eq!(event["auditScope"]["fileCount"], 1);
assert_eq!(receipt["auditScope"]["scope"], "allowed_files");
assert_eq!(receipt["auditScope"]["fileCount"], 1);
let _ = std::fs::remove_dir_all(root);
}
#[test]
@@ -15066,7 +15075,7 @@ mod tests {
"/api/hermes/client/profile-memory?profile=chemist",
None,
),
("GET", "/api/hermes/client/skills?profile=chemist", None),
("GET", "/api/hermes/client/skills", None),
(
"PUT",
"/api/hermes/client/profiles/active",
@@ -15107,7 +15116,7 @@ mod tests {
);
let request = Request::builder()
.method("GET")
.uri("/api/hermes/client/skills?profile=chemist")
.uri("/api/hermes/client/skills")
.header("x-mnote-actor-id", "user_1")
.body(Body::empty())
.expect("request");
@@ -3099,7 +3099,7 @@ mod tests {
assert!(markdown_edit["description"]
.as_str()
.expect("description")
.contains("兼容"));
.contains("compat"));
assert!(markdown_edit["description"]
.as_str()
.expect("description")
@@ -6432,7 +6432,7 @@ mod tests {
"toolCallId": "call_1",
"traceId": "trace_1",
"idempotencyKey": "idem_markdown_normalized_1",
"dryRun": false,
"dryRun": true,
"args": {
"operations": [{"search": "第二 段", "replace": "测试123"}]
}
@@ -6444,25 +6444,23 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["operationsApplied"], 1);
// 7-27: 新路径 changedBlocks 格式验证
let changed = payload["result"]["applyResult"]["changedBlocks"]
let changed = payload["result"]["applyResult"]["diff"]
.as_array()
.expect("changedBlocks");
assert!(!changed.is_empty(), "changedBlocks should not be empty");
.expect("diff");
assert!(!changed.is_empty(), "diff should not be empty");
assert_eq!(
payload["result"]["applyResult"]["changedBlocks"][0]["blockId"],
payload["result"]["applyResult"]["diff"][0]["blockId"],
"p_2"
);
assert_eq!(
payload["result"]["applyResult"]["changedBlocks"][0]["op"],
"replace"
);
assert_eq!(payload["result"]["applyResult"]["diff"][0]["op"], "replace");
}
#[tokio::test]
+274 -27
View File
@@ -24,6 +24,7 @@ const DEFAULT_LIGHTRAG_ENDPOINT: &str = "http://127.0.0.1:9621";
const DEFAULT_LIGHTRAG_INPUT_DIR: &str = "/mnt/Data1T/Mnote_data/lightrag/inputs";
const DEFAULT_LIGHTRAG_WORKING_DIR: &str = "/mnt/Data1T/Mnote_data/lightrag/rag_storage";
const MAX_INGEST_SOURCES_PER_REQUEST: usize = 200;
const SOURCE_SCOPE_MODE_POST_FILTER: &str = "post_filter_mapped_references";
const KNOWLEDGE_RAG_SOURCE_EXTENSIONS: &[&str] = &[
"md", "markdown", "txt", "pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", "csv", "png",
"jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff",
@@ -154,6 +155,8 @@ pub(crate) fn knowledge_rag_source_statuses(
}
if provider_status == "failed" {
statuses.failed_paths.insert(path.to_string());
} else if provider_status == "delete_retry_required" {
statuses.failed_paths.insert(path.to_string());
} else if provider_status == "delete_submitted"
|| (entry.deleted_at_ms.is_some() && entry.light_rag_doc_id.is_some())
{
@@ -473,6 +476,8 @@ pub async fn query_rag(
"schema": "mnote.knowledge_rag.query_result.v1",
"provider": "lightrag",
"sourceScope": source_scope,
"sourceScopeMode": SOURCE_SCOPE_MODE_POST_FILTER,
"rawScopeFiltered": false,
"raw": raw,
"references": references,
})))
@@ -735,7 +740,13 @@ pub async fn prune_registry(
}
fn knowledge_rag_registry_entry_prunable(entry: &KnowledgeRagSourceRegistryEntry) -> bool {
if entry.light_rag_status.as_deref() == Some("delete_submitted") {
if matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
) {
return false;
}
if entry.light_rag_doc_id.is_some() && (entry.deleted_at_ms.is_some() || entry.stale) {
return false;
}
entry.deleted_at_ms.is_some()
@@ -746,6 +757,23 @@ fn knowledge_rag_registry_entry_prunable(entry: &KnowledgeRagSourceRegistryEntry
)
}
fn knowledge_rag_provider_delete_confirmed(entry: &KnowledgeRagSourceRegistryEntry) -> bool {
entry.light_rag_doc_id.is_some()
&& (entry.deleted_at_ms.is_some()
|| entry.stale
|| matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
))
}
fn mark_registry_entry_delete_completed(entry: &mut KnowledgeRagSourceRegistryEntry, now: u128) {
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_completed".into());
entry.updated_at_ms = now;
}
async fn sync_registry_with_documents(
root_path: &Path,
registry: &mut KnowledgeRagSourceRegistry,
@@ -758,22 +786,28 @@ async fn sync_registry_with_documents(
let by_file_path = lightrag_documents_by_file_path(&docs);
let now = now_ms();
let mut changed = false;
let mut retry_doc_ids = Vec::new();
for entry in &mut registry.entries {
if let Some(doc) = document_for_registry_entry(&by_file_path, entry) {
if let Some(id) = doc.get("id").and_then(Value::as_str) {
entry.light_rag_doc_id = Some(id.to_string());
}
if let Some(status) = doc.get("status").and_then(Value::as_str) {
entry.light_rag_status = Some(
if entry.deleted_at_ms.is_some() {
"delete_submitted"
} else {
status
}
.to_string(),
);
let delete_pending = matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
) || entry.deleted_at_ms.is_some();
if delete_pending {
if let Some(doc_id) = entry.light_rag_doc_id.clone() {
retry_doc_ids.push(doc_id);
}
if entry.light_rag_status.is_none() {
entry.light_rag_status = Some("delete_submitted".into());
}
} else if let Some(status) = doc.get("status").and_then(Value::as_str) {
entry.light_rag_status = Some(status.to_string());
}
if entry.deleted_at_ms.is_none()
if !entry.stale
&& entry.deleted_at_ms.is_none()
&& doc.get("status").and_then(Value::as_str) == Some("processed")
{
entry.indexed_at_ms.get_or_insert(now);
@@ -781,11 +815,8 @@ async fn sync_registry_with_documents(
}
entry.updated_at_ms = now;
changed = true;
} else if entry.deleted_at_ms.is_some() && entry.light_rag_doc_id.is_some() {
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_completed".into());
entry.updated_at_ms = now;
} else if knowledge_rag_provider_delete_confirmed(entry) {
mark_registry_entry_delete_completed(entry, now);
changed = true;
} else if entry.deleted_at_ms.is_some()
&& entry.light_rag_doc_id.is_none()
@@ -801,9 +832,12 @@ async fn sync_registry_with_documents(
changed = true;
}
}
let stale_doc_ids = sync_registry_source_state(registry, now)?;
let mut stale_doc_ids = sync_registry_source_state(registry, now)?;
stale_doc_ids.extend(retry_doc_ids);
stale_doc_ids.sort();
stale_doc_ids.dedup();
if !stale_doc_ids.is_empty() {
let _ = lightrag_json(
let delete_result = lightrag_json(
reqwest::Method::DELETE,
"/documents/delete_document",
Some(json!({
@@ -815,6 +849,23 @@ async fn sync_registry_with_documents(
context,
)
.await;
for entry in &mut registry.entries {
if entry
.light_rag_doc_id
.as_deref()
.is_some_and(|doc_id| stale_doc_ids.iter().any(|item| item == doc_id))
{
entry.light_rag_status = Some(
if delete_result.is_err() {
"delete_retry_required"
} else {
"delete_submitted"
}
.into(),
);
entry.updated_at_ms = now;
}
}
changed = true;
}
if changed {
@@ -897,8 +948,8 @@ fn sync_registry_source_state(
if !source_path.exists() {
entry.stale = true;
entry.deleted_at_ms = Some(now);
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_submitted".into());
entry.updated_at_ms = now;
stale_doc_ids.push(doc_id);
continue;
@@ -907,8 +958,8 @@ fn sync_registry_source_state(
if current_hash != entry.source_hash {
entry.stale = true;
entry.source_hash = current_hash;
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_submitted".into());
entry.updated_at_ms = now;
stale_doc_ids.push(doc_id);
}
@@ -989,6 +1040,10 @@ fn mapped_references(
.get("deleted")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !reference
.get("unmapped")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.collect()
}
@@ -1127,6 +1182,7 @@ fn map_reference_plan(
"sourceId": entry.map(|entry| entry.source_id.clone()),
"sourcePath": source_path,
"sourceRootRelativePath": source_root_relative_path,
"unmapped": entry.is_none(),
"stale": entry.is_some_and(|entry| entry.stale),
"deleted": entry.is_some_and(|entry| entry.deleted_at_ms.is_some()),
"locatorDegraded": locator_degraded,
@@ -1346,15 +1402,11 @@ fn find_lightrag_sidecar_block(
}
let path = sidecar_blocks_path(entry)?;
let content = fs::read_to_string(path).ok()?;
let mut first_positioned_block = None;
for line in content.lines() {
let block = serde_json::from_str::<Value>(line).ok()?;
if block.get("positions").and_then(Value::as_array).is_none() {
continue;
}
if first_positioned_block.is_none() {
first_positioned_block = Some(block.clone());
}
let block_text = block
.get("content")
.and_then(Value::as_str)
@@ -1369,7 +1421,7 @@ fn find_lightrag_sidecar_block(
return Some(block);
}
}
first_positioned_block
None
}
fn normalize_text_for_match(value: &str) -> String {
@@ -2234,17 +2286,85 @@ mod tests {
let stale_doc_ids = sync_registry_source_state(&mut registry, 42).expect("sync");
assert_eq!(stale_doc_ids, vec!["doc-changed", "doc-missing"]);
assert_eq!(registry.entries[0].deleted_at_ms, Some(42));
assert_eq!(registry.entries[0].light_rag_doc_id, None);
assert_eq!(
registry.entries[0].light_rag_doc_id.as_deref(),
Some("doc-missing")
);
assert_eq!(
registry.entries[0].light_rag_status.as_deref(),
Some("delete_submitted")
);
assert_eq!(registry.entries[0].indexed_at_ms, None);
assert!(registry.entries[0].stale);
assert_eq!(registry.entries[1].deleted_at_ms, None);
assert_eq!(registry.entries[1].light_rag_doc_id, None);
assert_eq!(
registry.entries[1].light_rag_doc_id.as_deref(),
Some("doc-changed")
);
assert_eq!(
registry.entries[1].light_rag_status.as_deref(),
Some("delete_submitted")
);
assert_eq!(registry.entries[1].indexed_at_ms, None);
assert!(registry.entries[1].stale);
assert_ne!(registry.entries[1].source_hash, "mnote-fnv64:old");
let _ = fs::remove_dir_all(root);
}
#[test]
fn provider_delete_confirmation_clears_doc_id_for_deleted_or_stale_entries() {
let root = temp_root("mnote-knowledge-rag-delete-confirmed");
let mut deleted = test_registry_entry(
&root,
"deleted.pdf",
Some("doc-deleted"),
Some("delete_submitted"),
Some(2),
Some(3),
true,
);
let mut changed = test_registry_entry(
&root,
"changed.pdf",
Some("doc-changed"),
Some("delete_submitted"),
Some(2),
None,
true,
);
let active = test_registry_entry(
&root,
"active.pdf",
Some("doc-active"),
Some("processed"),
Some(2),
None,
false,
);
assert!(knowledge_rag_provider_delete_confirmed(&deleted));
mark_registry_entry_delete_completed(&mut deleted, 42);
assert_eq!(deleted.light_rag_doc_id, None);
assert_eq!(deleted.indexed_at_ms, None);
assert_eq!(
deleted.light_rag_status.as_deref(),
Some("delete_completed")
);
assert!(knowledge_rag_provider_delete_confirmed(&changed));
mark_registry_entry_delete_completed(&mut changed, 43);
assert_eq!(changed.light_rag_doc_id, None);
assert_eq!(
changed.light_rag_status.as_deref(),
Some("delete_completed")
);
assert!(!knowledge_rag_provider_delete_confirmed(&active));
assert_eq!(active.light_rag_doc_id.as_deref(), Some("doc-active"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn source_statuses_distinguish_indexed_processing_failed_and_deleted() {
let root = temp_root("mnote-knowledge-rag-source-statuses");
@@ -2372,9 +2492,29 @@ mod tests {
);
let failed =
test_registry_entry(&root, "failed.pdf", None, Some("failed"), None, None, false);
let retry = test_registry_entry(
&root,
"retry.pdf",
Some("doc-retry"),
Some("delete_retry_required"),
Some(2),
Some(3),
true,
);
let stale_with_doc = test_registry_entry(
&root,
"stale.pdf",
Some("doc-stale"),
Some("processed"),
Some(2),
None,
true,
);
assert!(!knowledge_rag_registry_entry_prunable(&active));
assert!(!knowledge_rag_registry_entry_prunable(&deleting));
assert!(!knowledge_rag_registry_entry_prunable(&retry));
assert!(!knowledge_rag_registry_entry_prunable(&stale_with_doc));
assert!(knowledge_rag_registry_entry_prunable(&removed));
assert!(knowledge_rag_registry_entry_prunable(&failed));
@@ -2466,6 +2606,42 @@ mod tests {
);
}
#[test]
fn mapped_references_filters_unmapped_provider_references() {
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
entries: vec![],
};
let raw = json!({
"data": {
"references": [{"reference_id":"1","file_path":"orphan.pdf"}],
"chunks": [{
"reference_id":"1",
"chunk_id":"orphan-chunk",
"file_path":"orphan.pdf",
"content":"orphan provider chunk"
}]
}
});
let mapped = mapped_references(&raw, &registry, "file:///tmp/root", Path::new("/tmp/root"));
assert!(
mapped.is_empty(),
"unmapped provider references must not become MNote citations"
);
let plan = map_reference_plan(
&json!({"file_path":"orphan.pdf","chunk_id":"orphan-chunk"}),
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
);
assert_eq!(plan["unmapped"], true);
assert_eq!(plan["locatorDegraded"], true);
}
#[test]
fn query_ranking_prefers_exact_source_and_quote_match() {
let mut references = vec![
@@ -2597,6 +2773,77 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
#[test]
fn locator_degrades_when_sidecar_quote_does_not_match() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-knowledge-rag-sidecar-no-match");
fs::create_dir_all(root.join("docs")).expect("docs");
fs::write(root.join("docs").join("Host.md"), "# Host\n").expect("host");
fs::write(root.join("docs").join("scan.pdf"), b"pdf").expect("pdf");
let input_dir = root.join("inputs");
let parsed_dir = input_dir
.join("__parsed__")
.join("mnote-hash-scan.pdf.parsed");
fs::create_dir_all(&parsed_dir).expect("parsed dir");
fs::write(
parsed_dir.join("mnote-hash-scan.blocks.jsonl"),
[
r#"{"type":"meta","blocks":1}"#,
r##"{"type":"content","blockid":"block1","content":"This block is not the returned quote.","positions":[{"type":"bbox","anchor":"9","range":[1.0,2.0,3.0,4.0]}]}"##,
]
.join("\n"),
)
.expect("blocks");
std::env::set_var("MNOTE_LIGHTRAG_INPUT_DIR", &input_dir);
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: root.join("docs").join("scan.pdf").display().to_string(),
source_root_relative_path: "docs/scan.pdf".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-scan.pdf".into(),
symlink_path: input_dir.join("mnote-hash-scan.pdf").display().to_string(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let mapped = map_reference_plan(
&json!({
"file_path": "mnote-hash-scan.pdf",
"chunk_id": "doc1-chunk-000",
"chunks": [{"chunk_id": "doc1-chunk-000", "content": "A different quote should not get page or bbox."}]
}),
&registry,
"file:///tmp/root",
&root,
);
assert!(mapped["locator"].is_null());
assert_eq!(mapped["locatorDegraded"], true);
assert!(mapped["citationUrl"]
.as_str()
.is_some_and(|url| url.contains("resourceTab=")));
assert!(mapped["citationMarkdown"]
.as_str()
.unwrap()
.contains("来源定位降级"));
std::env::remove_var("MNOTE_LIGHTRAG_INPUT_DIR");
let _ = fs::remove_dir_all(root);
}
#[test]
fn lightrag_paths_prefer_source_env_over_legacy_process_env() {
let _guard = env_lock().lock().expect("env lock");
@@ -23,7 +23,7 @@ use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::pin::Pin;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast::{error::RecvError, Receiver};
use tokio::sync::broadcast::error::RecvError;
use tokio::time::timeout;
type BoxedEventStream =
@@ -85,7 +85,6 @@ async fn build_document_events_stream(
.local_folder_watcher_registry()
.subscribe(&canonical_root)
.map_err(|error| WebError::internal(error).with_context(&context))?;
let local_ocr_job_rx = state.local_ocr_job_tx.subscribe();
let initial = json!({
"sourceKind": "local_folder",
@@ -95,79 +94,29 @@ async fn build_document_events_stream(
"revision": system_time_ms(SystemTime::now()),
});
let stream = stream::unfold(
(
Some(initial),
subscription,
document_relative_path,
query.root_uri.clone(),
local_ocr_job_rx,
),
|(
initial,
mut subscription,
document_relative_path,
root_uri,
mut local_ocr_job_rx,
)| async move {
(Some(initial), subscription, document_relative_path),
|(initial, mut subscription, document_relative_path)| async move {
if let Some(payload) = initial {
return Some((
Ok(stream_event("ready", &payload)),
(
None,
subscription,
document_relative_path,
root_uri,
local_ocr_job_rx,
),
(None, subscription, document_relative_path),
));
}
loop {
tokio::select! {
watcher_result = subscription.receiver.recv() => {
match watcher_result {
Ok(payload) => {
if let Some(expected) = document_relative_path.as_deref() {
if !document_event_targets_relative_path(&payload, expected) {
continue;
}
}
return Some((
Ok(stream_event("change", &payload)),
(
None,
subscription,
document_relative_path,
root_uri,
local_ocr_job_rx,
),
));
match subscription.receiver.recv().await {
Ok(payload) => {
if let Some(expected) = document_relative_path.as_deref() {
if !document_event_targets_relative_path(&payload, expected) {
continue;
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
ocr_result = recv_matching_ocr_event(&mut local_ocr_job_rx, &root_uri) => {
match ocr_result {
Some(payload) => {
if let Some(expected) = document_relative_path.as_deref() {
if !document_event_targets_relative_path(&payload, expected) {
continue;
}
}
return Some((
Ok(stream_event("local_ocr.job.updated", &payload)),
(
None,
subscription,
document_relative_path,
root_uri,
local_ocr_job_rx,
),
));
}
None => continue,
}
return Some((
Ok(stream_event("change", &payload)),
(None, subscription, document_relative_path),
));
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
},
@@ -177,20 +126,6 @@ async fn build_document_events_stream(
Ok((HeaderMap::new(), stream))
}
async fn recv_matching_ocr_event(rx: &mut Receiver<Value>, root_uri: &str) -> Option<Value> {
loop {
match rx.recv().await {
Ok(payload) => {
if payload.get("rootUri").and_then(Value::as_str) == Some(root_uri) {
return Some(payload);
}
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
}
/// Build the tree live stream: emits `snapshot` (initial) and `resync` (on watcher change)
/// with full sidebar + file tree projections.
///
File diff suppressed because it is too large Load Diff
@@ -1217,11 +1217,8 @@ pub(crate) fn refresh_local_search_index_for_path_with_settings(
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
index.built_at = now_ms();
write_local_search_index_json(root_path, &index)?;
if included {
refresh_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
} else {
remove_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
}
let _ = included;
remove_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
return Ok(json!({
"version": index.version,
"rootUri": index.root_uri,
@@ -1948,13 +1945,6 @@ fn index_relative_path_is_included(relative_path: &str, include_paths: &[String]
})
}
pub(crate) fn local_index_relative_path_is_included(
relative_path: &str,
settings: &LocalIndexSettings,
) -> bool {
index_relative_path_is_included(relative_path, &settings.include_paths)
}
fn collect_markdown_documents(
root_path: &Path,
current: &Path,
@@ -2613,84 +2603,6 @@ fn parsed_resource_id(resource: &LocalSearchResource) -> String {
format!("{}#parse", resource.resource_id)
}
#[allow(dead_code)]
fn insert_ocr_evidence(
connection: &Connection,
root_path: &Path,
index: &LocalSearchIndex,
entry: &local_ocr::OcrIndexEntry,
) -> Result<(), WebError> {
if entry.status != "done" {
return Ok(());
}
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
let Ok(markdown) = fs::read_to_string(&ocr_path) else {
return Ok(());
};
let body = local_ocr::strip_ocr_frontmatter(&markdown);
let source_map = path_source_map_path(&entry.ocr_root_relative_path).unwrap_or_default();
let resource_id = format!(
"{}#ocr:{}",
entry.owner_document_id, entry.source_root_relative_path
);
let artifact = ocr_parsed_artifact(entry, &source_map);
if !source_map.is_empty() {
let source_map_path = root_path.join(&source_map);
if let Ok(source_map_content) = fs::read_to_string(&source_map_path) {
if let Ok(resource_source_map) =
serde_json::from_str::<ResourceSourceMap>(&source_map_content)
{
return insert_source_map_artifact_evidence(
connection,
index,
&resource_id,
&artifact,
&resource_source_map,
body,
);
}
}
}
insert_evidence_resource_from_artifact(connection, &resource_id, &artifact)?;
let locator = json!({
"schema": "mnote.evidence_locator.v1",
"rootUri": index.root_uri,
"ownerDocumentId": entry.owner_document_id,
"ownerDocumentPath": entry.owner_document_path,
"resourcePath": entry.source_root_relative_path,
"resourceKind": evidence_resource_kind_for_path(&entry.source_root_relative_path),
"sourceMapPath": source_map,
"openAction": {
"actionType": "mnote.open_resource_locator",
"url": format!("/documents/{}?sourceKind=local_folder&rootUri={}", entry.owner_document_id, encode_query_component(&index.root_uri)),
"params": {
"resourcePath": entry.source_root_relative_path,
"sourceMapPath": source_map
}
}
});
insert_evidence_block(connection, &resource_id, &resource_id, body, locator)
}
#[allow(dead_code)]
fn ocr_parsed_artifact(
entry: &local_ocr::OcrIndexEntry,
source_map_root_relative_path: &str,
) -> ParsedResourceArtifact {
ParsedResourceArtifact {
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
provider: entry.provider.clone(),
model_version: Some(entry.model_version.clone()),
owner_document_id: entry.owner_document_id.clone(),
owner_document_path: entry.owner_document_path.clone(),
source_root_relative_path: entry.source_root_relative_path.clone(),
source_hash: format!("size:{}:mtime:{}", entry.source_size, entry.source_mtime_ms),
artifact_root_relative_path: entry.ocr_root_relative_path.clone(),
source_map_root_relative_path: source_map_root_relative_path.to_string(),
updated_at_ms: entry.updated_at_ms as u64,
}
}
fn insert_source_map_artifact_evidence(
connection: &Connection,
index: &LocalSearchIndex,
@@ -3479,35 +3391,6 @@ fn local_search_resource_matches(
}
}
#[allow(dead_code)]
fn local_search_ocr_matches(
entry: &local_ocr::OcrIndexEntry,
body: &str,
query: &str,
title_only: bool,
exact: bool,
) -> bool {
if query.is_empty() {
return false;
}
let haystack = if title_only {
normalize_search_text(&format!(
"{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path
))
} else {
normalize_search_text(&format!(
"{}\n{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path, body
))
};
if exact {
haystack.contains(query)
} else {
token_search_match(&haystack, query)
}
}
fn local_search_document_projection(
document: &LocalSearchDocument,
root_uri: &str,
@@ -3557,43 +3440,6 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
})
}
#[allow(dead_code)]
fn local_search_ocr_projection(
entry: &local_ocr::OcrIndexEntry,
body: &str,
root_uri: &str,
query: &str,
) -> Value {
let title = Path::new(&entry.owner_document_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("OCR")
.to_string();
json!({
"id": format!("{}#ocr:{}", entry.owner_document_id, entry.source_root_relative_path),
"documentId": entry.owner_document_id,
"title": title,
"path": entry.owner_document_path,
"resourceType": "markdown",
"sourceKind": "local_folder",
"rootUri": root_uri,
"hasOcr": true,
"snippet": ocr_search_snippet(body, query),
"ocrEvidence": {
"sourceRootRelativePath": entry.source_root_relative_path,
"ocrRootRelativePath": entry.ocr_root_relative_path,
"provider": entry.provider,
"status": entry.status,
},
"updatedAt": entry.updated_at_ms,
"publicPath": format!(
"/documents/{}?sourceKind=local_folder&rootUri={}",
entry.owner_document_id,
encode_query_component(root_uri),
)
})
}
fn encode_query_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
@@ -5143,6 +4989,10 @@ mod tests {
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
let refreshed_evidence = query_evidence_sqlite_results(&root, "OCR-hash-token", None, 10)
.expect("query refreshed evidence")
.unwrap_or_default();
assert!(refreshed_evidence.is_empty());
let _ = fs::remove_dir_all(&root);
}
@@ -5157,6 +5007,8 @@ mod tests {
"---\ntitle: Child\n---\n# Child\nOriginalToken body.\n",
)
.expect("write child");
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
.expect("settings");
let first_projection = query_local_search_index(
&root,
@@ -5421,7 +5273,7 @@ mod tests {
#[test]
#[cfg(unix)]
fn evidence_index_parses_resource_body_with_liteparse_sidecar() {
fn evidence_index_does_not_parse_resource_body_with_retired_liteparse_sidecar() {
use std::os::unix::fs::PermissionsExt;
let _guard = env_lock().lock().expect("env lock");
@@ -5479,23 +5331,11 @@ JSON
let results = query_evidence_sqlite_results(&root, "ResourceBodyToken", None, 10)
.expect("sqlite query")
.expect("sqlite exists");
let hit = results
.iter()
.find(|result| result.quote.contains("ResourceBodyToken"))
.expect("parsed resource body hit");
assert_eq!(
hit.source.owner_document_id, "local-md:docs~2FPage.md",
"资源正文证据应归属引用它的 owner Markdown"
);
assert_eq!(
hit.source.resource_path.as_deref(),
Some("docs/Page.assets/spec.pdf")
);
assert_eq!(hit.source.page, Some(2));
assert!(hit.source.bbox.is_some());
assert_eq!(
hit.source.source_map_path.as_deref(),
Some("docs/Page.ocr/spec.pdf.source-map.json")
assert!(
!results
.iter()
.any(|result| result.quote.contains("ResourceBodyToken")),
"LiteParse resource body fallback is retired from active evidence indexing"
);
let resource_scoped = query_evidence_sqlite_results_with_mode(
&root,
@@ -5506,18 +5346,16 @@ JSON
)
.expect("resource scoped sqlite query")
.expect("sqlite exists");
assert_eq!(resource_scoped.len(), 1);
assert_eq!(
resource_scoped[0].source.resource_path.as_deref(),
Some("docs/Page.assets/spec.pdf"),
"页面内搜索打开资源时应只限制到当前资源,而不是它的 owner Markdown"
assert!(
resource_scoped.is_empty(),
"retired LiteParse sidecar must not create resource-scoped evidence hits"
);
assert!(root
assert!(!root
.join("docs")
.join("Page.ocr")
.join("spec.pdf.parse.md")
.exists());
assert!(root
assert!(!root
.join("docs")
.join("Page.ocr")
.join("spec.pdf.source-map.json")
+33 -430
View File
@@ -1,28 +1,11 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::{build_runtime_command_plan, runtime_context};
use crate::transport::convex::{
execute_retired_mutation_by_name, execute_retired_query_by_name,
persist_runtime_command_artifacts,
};
use axum::extract::{Multipart, Query, State};
use axum::http::{header, HeaderMap};
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use bridge_runtime::{
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
RuntimeSourceWire, RuntimeTargetWire,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
use serde_json::json;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -69,242 +52,6 @@ pub struct FileTreeUploadTargetPlan {
target_sub_path: Option<String>,
}
#[derive(Debug)]
struct UploadFile {
name: String,
content_type: String,
bytes: Vec<u8>,
}
async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
let actor_id = context.auth.actor_id.trim();
if !actor_id.is_empty() && actor_id != "anonymous" {
return actor_id.to_string();
}
if let Ok(user) = execute_retired_query_by_name(
state.config(),
context,
"users:currentUser",
json!({}),
context.workspace.workspace_id.as_deref(),
"media_current_user",
)
.await
{
for key in ["_id", "id"] {
if let Some(user_id) = user
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return user_id.to_string();
}
}
}
state.config().dev_user_id.clone()
}
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
fn new_asset_id() -> String {
format!(
"asset_{}_{}",
now_millis(),
UPLOAD_COUNTER.fetch_add(1, Ordering::Relaxed)
)
}
fn now_iso_like() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
}
fn asset_type(mime: &str) -> &'static str {
if mime.starts_with("image/") {
"image"
} else if mime.starts_with("video/") {
"video"
} else if mime.starts_with("audio/") {
"audio"
} else {
"file"
}
}
async fn record_upload_artifacts(
state: &AppState,
context: &RequestContext,
user_id: &str,
workspace_id: &str,
document_id: &str,
asset_id: &str,
file: &UploadFile,
asset_kind: &str,
target_sub_path: Option<&str>,
created: &Value,
) -> Result<(), WebError> {
let command = RuntimeCommandEnvelopeWire {
name: "tree.resource.upload".into(),
command_id: format!("resource_upload_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: user_id.to_string(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: Some(workspace_id.to_string()),
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some(workspace_id.to_string()),
page_id: Some(document_id.to_string()),
block_id: Some(asset_id.to_string()),
}),
payload: json!({
"assetId": asset_id,
"workspaceId": workspace_id,
"targetDocumentId": document_id,
"targetSubPath": target_sub_path,
"fileName": file.name,
"fileSize": file.bytes.len(),
"mimeType": file.content_type,
"assetType": asset_kind,
}),
preflight_data: None,
reason: Some("mnote-web media upload tree.resource.upload".into()),
refs: vec!["file-tree-resource-upload".into()],
dry_run: false,
validate_only: false,
};
let runtime_context = runtime_context(context, Some(workspace_id));
let plan = build_runtime_command_plan(context, Some(workspace_id), command.clone())?;
let artifact_result = json!({
"items": [created.clone()],
});
if let Some(artifacts) = build_runtime_command_artifact_plan(
&runtime_context,
&command,
&plan,
&artifact_result,
&now_iso_like(),
) {
persist_runtime_command_artifacts(state.config(), context, &artifacts).await?;
}
Ok(())
}
async fn read_upload_multipart(
mut multipart: Multipart,
) -> Result<(UploadFile, String, String, Option<String>), WebError> {
let mut file: Option<UploadFile> = None;
let mut workspace_id = String::new();
let mut document_id = String::new();
let mut mindmap_id: Option<String> = None;
while let Some(field) = multipart.next_field().await.map_err(|error| {
WebError::bad_request_code(
"media_upload_bad_multipart",
format!("上传表单解析失败: {error}"),
)
})? {
let name = field.name().unwrap_or_default().to_string();
if name == "file" {
let file_name = field
.file_name()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("附件")
.to_string();
let content_type = field
.content_type()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = field
.bytes()
.await
.map_err(|error| {
WebError::bad_request_code(
"media_upload_file_read_failed",
format!("读取上传文件失败: {error}"),
)
})?
.to_vec();
file = Some(UploadFile {
name: file_name,
content_type,
bytes,
});
continue;
}
let value = field.text().await.map_err(|error| {
WebError::bad_request_code(
"media_upload_field_read_failed",
format!("读取上传字段失败: {error}"),
)
})?;
match name.as_str() {
"workspaceId" => workspace_id = value.trim().to_string(),
"documentId" => document_id = value.trim().to_string(),
"mindmapId" => {
let trimmed = value.trim();
if !trimmed.is_empty() {
mindmap_id = Some(trimmed.to_string());
}
}
_ => {}
}
}
let file =
file.ok_or_else(|| WebError::bad_request_code("media_upload_file_missing", "缺少 file"))?;
if file.bytes.is_empty() || workspace_id.is_empty() || document_id.is_empty() {
return Err(WebError::bad_request_code(
"media_upload_required_missing",
"缺少必要参数",
));
}
Ok((file, workspace_id, document_id, mindmap_id))
}
fn absolute_origin(headers: &HeaderMap) -> String {
let proto = headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("http");
let host = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("127.0.0.1:3000");
format!("{proto}://{host}")
}
fn proxied_file_url(headers: &HeaderMap, raw: &str) -> String {
let encoded = URL_SAFE_NO_PAD.encode(raw.as_bytes());
format!(
"{}/api/onlyoffice/proxy?u={encoded}",
absolute_origin(headers)
)
}
fn trim_string(value: Option<&String>) -> Option<String> {
value
.map(String::as_str)
@@ -353,128 +100,8 @@ fn document_for_target_row(
None
}
pub async fn upload(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
multipart: Multipart,
) -> Result<Response, WebError> {
let (file, workspace_id, document_id, mindmap_id) = read_upload_multipart(multipart).await?;
let user_id = current_user_id(&state, &context).await;
let upload_url = execute_retired_mutation_by_name(
state.config(),
&context,
"mediaAssets:generateUploadUrl",
json!({ "userId": user_id }),
Some(&workspace_id),
None,
"media_upload_generate_url",
)
.await?;
let upload_url = upload_url.as_str().ok_or_else(|| {
WebError::bad_gateway_code("media_upload_bad_upload_url", "Convex 未返回上传 URL")
})?;
let client = reqwest::Client::new();
let upload_response = client
.post(upload_url)
.header(header::CONTENT_TYPE, file.content_type.as_str())
.body(file.bytes.clone())
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"media_upload_storage_failed",
format!("上传到 Convex Files 失败: {error}"),
)
})?;
let upload_status = upload_response.status();
let upload_json: Value = upload_response.json().await.map_err(|error| {
WebError::bad_gateway_code(
"media_upload_storage_bad_response",
format!("Convex Files 响应解析失败: {error}"),
)
.with_header("x-upstream-status", upload_status.as_u16().to_string())
})?;
if !upload_status.is_success() {
return Err(WebError::bad_gateway_code(
"media_upload_storage_status",
format!("上传到 Convex Files 失败: {upload_json}"),
)
.with_header("x-upstream-status", upload_status.as_u16().to_string()));
}
let storage_id = upload_json
.get("storageId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_gateway_code(
"media_upload_storage_id_missing",
"Convex Files 缺少 storageId",
)
})?;
let target_sub_path = mindmap_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("mindmaps/{value}"));
let id = new_asset_id();
let kind = asset_type(&file.content_type);
let created = execute_retired_mutation_by_name(
state.config(),
&context,
"mediaAssets:createWithStorage",
json!({
"userId": user_id,
"storageId": storage_id,
"targetSubPath": target_sub_path,
"asset": {
"id": id,
"workspace_id": workspace_id,
"document_id": document_id,
"asset_type": kind,
"file_name": file.name,
"file_size": file.bytes.len(),
"mime_type": file.content_type,
}
}),
Some(&workspace_id),
None,
"media_upload_create_asset",
)
.await?;
let asset_id = created
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Err(error) = record_upload_artifacts(
&state,
&context,
&user_id,
&workspace_id,
&document_id,
&asset_id,
&file,
&kind,
target_sub_path.as_deref(),
&created,
)
.await
{
tracing::warn!(
error = %error.message(),
asset_id = %asset_id,
"media upload tree.resource.upload artifacts 记录失败,主上传结果继续返回"
);
}
Ok(Json(json!({
"asset": created,
"mindmapUrl": format!("asset:{asset_id}"),
}))
.into_response())
pub async fn upload(Extension(context): Extension<RequestContext>) -> Response {
retired_convex_media_response(&context, "upload", None)
}
pub async fn filetree_upload_target_preflight(
@@ -529,63 +156,39 @@ pub async fn filetree_upload_target_preflight(
}
pub async fn sign(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<MediaSignQuery>,
headers: HeaderMap,
) -> Result<Response, WebError> {
) -> Response {
let asset_id = query
.asset_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WebError::bad_request_code("media_sign_asset_missing", "缺少 assetId"))?;
let user_id = current_user_id(&state, &context).await;
let asset = execute_retired_query_by_name(
state.config(),
&context,
"mediaAssets:getById",
json!({ "userId": user_id, "id": asset_id }),
None,
"media_sign_get_asset",
)
.await?;
if asset.is_null() {
return Err(WebError::new(
axum::http::StatusCode::NOT_FOUND,
"media_asset_not_found",
"资源不存在",
));
}
let refreshed = execute_retired_mutation_by_name(
state.config(),
&context,
"mediaAssets:refreshUrl",
json!({ "userId": user_id, "id": asset_id }),
None,
None,
"media_sign_refresh_url",
.filter(|value| !value.is_empty());
retired_convex_media_response(&context, "sign", asset_id)
}
fn retired_convex_media_response(
context: &RequestContext,
operation: &str,
asset_id: Option<&str>,
) -> Response {
(
StatusCode::GONE,
Json(json!({
"ok": false,
"code": "mnote_media_convex_retired",
"error": "旧 Convex Files media route 已退役",
"message": "旧 /api/media Convex Files 上传与签名链已退役;local-first 附件请使用 /api/local-folder/assets/upload 与 /api/local-folder/files/open。",
"operation": operation,
"assetId": asset_id,
"replacement": {
"upload": "/api/local-folder/assets/upload",
"open": "/api/local-folder/files/open",
"preflight": "/api/tree/filetree/upload-target-preflight"
},
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
)
.await?;
let signed_url = refreshed
.get("signedUrl")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WebError::bad_gateway_code("media_sign_url_missing", "生成签名链接失败"))?;
Ok(Json(json!({
"signedUrl": proxied_file_url(&headers, signed_url),
"asset": {
"id": asset.get("id").cloned().unwrap_or(Value::Null),
"document_id": asset.get("document_id").cloned().unwrap_or(Value::Null),
"workspace_id": asset.get("workspace_id").cloned().unwrap_or(Value::Null),
"file_name": asset.get("file_name").cloned().unwrap_or(Value::Null),
"mime_type": asset.get("mime_type").cloned().unwrap_or(Value::Null),
"file_size": asset.get("file_size").cloned().unwrap_or(Value::Null),
"storage_id": asset.get("storage_id").cloned().unwrap_or(Value::Null),
"updated_at": asset.get("updated_at").cloned().unwrap_or(Value::Null),
}
}))
.into_response())
.into_response()
}
+43 -1
View File
@@ -252,6 +252,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
get(web_shell::sidebar_tree_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
get(web_shell::local_folder_event_bus_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/tree-live-controller.js",
get(web_shell::tree_live_controller_runtime_asset),
@@ -723,6 +727,7 @@ mod tests {
use crate::context::RequestContext;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::Extension;
use axum::Router;
use serde_json::{json, Value};
use std::fs;
@@ -848,6 +853,38 @@ mod tests {
}
}
#[tokio::test]
async fn legacy_media_routes_return_retired_guard() {
for (method, path, operation) in [
("POST", "/api/media/upload", "upload"),
("GET", "/api/media/sign?assetId=asset_1", "sign"),
] {
let request = Request::builder()
.method(method)
.uri(path)
.body(Body::empty())
.expect("request");
let context =
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
let response = app(false)
.layer(Extension(context))
.oneshot(request)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::GONE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes");
let payload: Value = serde_json::from_slice(&body).expect("json body");
assert_eq!(payload["code"], "mnote_media_convex_retired");
assert_eq!(payload["operation"], operation);
assert_eq!(
payload["replacement"]["upload"],
"/api/local-folder/assets/upload"
);
}
}
#[tokio::test]
async fn office_preview_page_serves_lightweight_viewer_shell() {
let response = app(false)
@@ -1144,8 +1181,12 @@ mod tests {
);
assert_eq!(
alice_payload["result"]["localOcrPreferences"]["localOcr.autoEnabled"],
true
false
);
assert!(alice_payload["result"]["sources"]
.as_object()
.and_then(|sources| sources.get("localOcr.autoEnabled"))
.is_none());
let mut bob_get = Request::builder()
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
@@ -1201,6 +1242,7 @@ mod tests {
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
"/api/mnote-browser-runtime/tree-live-controller.js",
"/api/mnote-browser-runtime/tree-shell-runtime.js",
"/api/mnote-browser-runtime/tree-shell-render-runtime.js",
@@ -224,7 +224,7 @@ async fn record_media_empty_trash_artifacts(
let plan = RuntimeCommandExecutionPlan {
command_name: command.name.clone(),
command_id: command.command_id.clone(),
function_name: "mediaAssets:emptyTrashByWorkspace".into(),
function_name: command.name.clone(),
workspace_id: Some(workspace_id.to_string()),
request_id: context.trace.request_id.clone(),
trace_id: context.trace.trace_id.clone(),
+130 -307
View File
@@ -7,16 +7,15 @@ use crate::routes::query_support::{
resolve_effective_workspace_id,
};
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::routes::{evidence, local_folder_source, local_search_index};
use crate::routes::{local_folder_source, local_search_index};
use crate::ssr::pages::search::SearchPage;
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{EvidenceSearchMode, EvidenceSearchResult};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_QUERY_NAME: &str = "x-query-name";
@@ -183,100 +182,60 @@ pub async fn documents(
None
};
let (result, evidence_results) =
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let user_settings = resolve_local_index_user_settings(
&state,
&context,
&effective_workspace_id,
&root_path,
)?;
let effective_settings = local_search_index::effective_local_index_settings_for_root(
state.control_plane(),
&effective_workspace_id,
&root_path,
)?;
let result = local_search_index::query_local_search_index_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&effective_settings,
&user_settings,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
filters.include_ocr.unwrap_or(false),
)?;
let evidence_results = evidence::evidence_results_from_local_search(
&result,
&root_path,
root_uri,
EvidenceSearchMode::Hybrid,
&normalized_query,
);
let limit = body.limit.unwrap_or(30).max(1) as usize;
let direct_evidence_results = if !filters.title_only.unwrap_or(false) {
local_search_index::query_evidence_sqlite_results_with_mode(
&root_path,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.exact.unwrap_or(false),
)?
.unwrap_or_default()
.into_iter()
.filter(|evidence| {
let path = evidence
.source
.resource_path
.as_deref()
.unwrap_or(evidence.source.owner_document_path.as_str());
local_search_index::local_index_relative_path_is_included(path, &user_settings)
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
let (result, evidence_results) = merge_local_search_with_evidence_results(
result,
evidence_results,
direct_evidence_results,
limit,
);
(result, evidence_results)
} else {
let result = load_search_results_with_filters(
state.config(),
&context,
&effective_workspace_id,
&normalized_query,
page_id,
body.limit.unwrap_or(30),
filters,
)
.await?;
(result, Vec::new())
};
let result = if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let user_settings = resolve_local_index_user_settings(
&state,
&context,
&effective_workspace_id,
&root_path,
)?;
let effective_settings = local_search_index::effective_local_index_settings_for_root(
state.control_plane(),
&effective_workspace_id,
&root_path,
)?;
local_search_index::query_local_search_index_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&effective_settings,
&user_settings,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
filters.include_ocr.unwrap_or(false),
)?
} else {
load_search_results_with_filters(
state.config(),
&context,
&effective_workspace_id,
&normalized_query,
page_id,
body.limit.unwrap_or(30),
filters,
)
.await?
};
let results = result
.get("results")
.cloned()
.unwrap_or(Value::Array(vec![]));
let results = attach_evidence_to_search_results(results, &evidence_results);
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
@@ -285,13 +244,20 @@ pub async fn documents(
headers,
Json(json!({
"results": results,
"evidence": evidence_results,
"evidence": [],
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])),
"meta": {
"owner": "mnote-web",
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"queryName": "search.documents.query",
"boundary": {
"kind": "ordinary_local_search",
"knowledgeRag": false,
"evidenceSqliteFallback": false,
"liteParseFallback": false,
"ocrSidecarFallback": false
},
"degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)),
"degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null),
"requestId": context.trace.request_id,
@@ -475,158 +441,6 @@ pub async fn update_local_index_settings(
))
}
fn merge_local_search_with_evidence_results(
mut result: Value,
mut evidence_results: Vec<EvidenceSearchResult>,
direct_evidence_results: Vec<EvidenceSearchResult>,
limit: usize,
) -> (Value, Vec<EvidenceSearchResult>) {
if direct_evidence_results.is_empty() {
return (result, evidence_results);
}
let original_items = result
.get("results")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let original_evidence_results = std::mem::take(&mut evidence_results);
let mut result_items = Vec::new();
let mut merged_evidence_results = Vec::new();
let mut seen_result_ids = std::collections::HashSet::new();
let mut seen_evidence_ids = std::collections::HashSet::new();
let mut seen_paths = std::collections::HashSet::new();
for evidence in direct_evidence_results {
if result_items.len() >= limit || !seen_evidence_ids.insert(evidence.evidence_id.clone()) {
continue;
}
let item = search_result_from_evidence(&evidence);
if let Some(id) = item.get("id").and_then(Value::as_str) {
seen_result_ids.insert(id.to_string());
}
if let Some(path) = search_result_dedupe_path(&item) {
seen_paths.insert(path);
}
result_items.push(item);
merged_evidence_results.push(evidence);
}
for (index, item) in original_items.into_iter().enumerate() {
if result_items.len() >= limit {
break;
}
let item_id = item
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_default();
if !item_id.is_empty() && !seen_result_ids.insert(item_id) {
continue;
}
if let Some(path) = search_result_dedupe_path(&item) {
if !seen_paths.insert(path) {
continue;
}
}
if let Some(evidence) = original_evidence_results.get(index).cloned() {
merged_evidence_results.push(evidence);
}
result_items.push(item);
}
if let Some(map) = result.as_object_mut() {
map.insert("results".into(), Value::Array(result_items));
}
(result, merged_evidence_results)
}
fn search_result_dedupe_path(item: &Value) -> Option<String> {
let source_kind = item
.get("sourceKind")
.and_then(Value::as_str)
.unwrap_or_default();
if source_kind != "local_folder" {
return None;
}
item.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn search_result_from_evidence(evidence: &EvidenceSearchResult) -> Value {
let source = &evidence.source;
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
let resource_path = source
.resource_path
.as_deref()
.unwrap_or(source.owner_document_path.as_str());
let title = std::path::Path::new(resource_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(resource_path)
.to_string();
let resource_type = match source.resource_kind {
core_protocol::EvidenceResourceKind::Markdown => "markdown",
core_protocol::EvidenceResourceKind::Pdf => "pdf",
core_protocol::EvidenceResourceKind::Image => "image",
core_protocol::EvidenceResourceKind::Office => "office",
core_protocol::EvidenceResourceKind::Mindmap => "mindmap",
core_protocol::EvidenceResourceKind::RawFile => "resource",
};
json!({
"id": format!("evidence:{}", evidence.evidence_id),
"documentId": source.owner_document_id,
"title": title,
"path": source.owner_document_path,
"resourceType": resource_type,
"sourceKind": "local_folder",
"rootUri": source.root_uri,
"snippet": evidence.quote,
"score": evidence.score,
"matchInfo": evidence.match_info,
"publicPath": source.open_action.url,
"evidence": evidence_value,
"source": {
"locator": source
},
})
}
fn attach_evidence_to_search_results(
results: Value,
evidence_results: &[EvidenceSearchResult],
) -> Value {
let Value::Array(items) = results else {
return results;
};
Value::Array(
items
.into_iter()
.enumerate()
.map(|(index, item)| {
let Some(evidence) = evidence_results.get(index) else {
return item;
};
let mut item = item;
if let Some(map) = item.as_object_mut() {
if map.get("evidence").is_some() {
return item;
}
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
map.insert("evidence".into(), evidence_value);
let source = map.entry("source").or_insert_with(|| json!({}));
if let Some(source_map) = source.as_object_mut() {
source_map.insert(
"locator".into(),
serde_json::to_value(&evidence.source).unwrap_or(Value::Null),
);
}
}
item
})
.collect(),
)
}
fn resolve_local_index_user_settings(
state: &AppState,
context: &RequestContext,
@@ -940,12 +754,12 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::local_search_index;
use axum::body::{Body, to_bytes};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::fs;
use tower::util::ServiceExt;
@@ -1194,41 +1008,38 @@ mod tests {
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
assert_eq!(
home["source"]["locator"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["meta"]["boundary"]["kind"].as_str(),
Some("ordinary_local_search")
);
assert_eq!(
home["evidence"]["source"]["ownerDocumentPath"].as_str(),
Some("README.md")
payload["meta"]["boundary"]["knowledgeRag"].as_bool(),
Some(false)
);
assert!(!payload["evidence"].as_array().expect("evidence").is_empty());
assert!(
home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
);
assert!(
root.join(".mnote")
.join("index")
.join("search-index.json")
.exists()
assert_eq!(
payload["meta"]["boundary"]["evidenceSqliteFallback"].as_bool(),
Some(false)
);
assert!(payload["evidence"].as_array().expect("evidence").is_empty());
assert!(home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
assert!(root
.join(".mnote")
.join("index")
.join("search-index.json")
.exists());
let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite");
assert!(evidence_db.exists(), "evidence sqlite should be built");
let connection = rusqlite::Connection::open(&evidence_db).expect("open evidence sqlite");
@@ -1258,19 +1069,17 @@ mod tests {
locator["schema"].as_str(),
Some("mnote.evidence_locator.v1")
);
assert!(
payload["recent"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
);
assert!(payload["recent"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn search_documents_local_folder_includes_evidence_sqlite_body_hits() {
async fn search_documents_local_folder_does_not_promote_evidence_sqlite_body_hits() {
let root = std::env::temp_dir().join(format!(
"mnote-local-search-evidence-route-{}",
std::process::id()
@@ -1350,20 +1159,18 @@ mod tests {
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let results = payload["results"].as_array().expect("results");
let hit = results
assert!(!results.iter().any(|item| item["id"]
.as_str()
.unwrap_or_default()
.starts_with("evidence:")));
assert!(results
.iter()
.find(|item| {
item["snippet"]
.as_str()
.unwrap_or("")
.contains("BodyOnlyEvidenceToken")
})
.expect("evidence sqlite body hit should be promoted to search result");
assert_eq!(hit["resourceType"].as_str(), Some("markdown"));
.any(|item| item["resourceType"].as_str() == Some("markdown")));
assert_eq!(
hit["evidence"]["source"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["meta"]["boundary"]["evidenceSqliteFallback"].as_bool(),
Some(false)
);
assert!(payload["evidence"].as_array().expect("evidence").is_empty());
let _ = fs::remove_dir_all(&root);
}
@@ -1382,6 +1189,15 @@ mod tests {
.expect("manifest");
fs::write(root.join("README.md"), "# Refresh\nrefresh-token\n").expect("readme");
let root_uri = format!("file://{}", root.display());
local_search_index::write_local_index_settings(
&root,
&[String::from(".")],
None,
None,
None,
None,
)
.expect("settings");
let response = app()
.oneshot(
@@ -1582,6 +1398,15 @@ mod tests {
)
.expect("child");
let root_uri = format!("file://{}", root.display());
local_search_index::write_local_index_settings(
&root,
&[String::from(".")],
None,
None,
None,
None,
)
.expect("settings");
let encoded_root = query_escape(&root_uri);
let backlinks_response = app()
@@ -1607,13 +1432,11 @@ mod tests {
backlinks_payload["meta"]["queryName"].as_str(),
Some("search.local_index.backlinks")
);
assert!(
backlinks_payload["result"]["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
);
assert!(backlinks_payload["result"]["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
let tags_response = app()
.oneshot(
+7 -16
View File
@@ -4323,7 +4323,7 @@ mod tests {
}
#[test]
fn tree_commands_keep_documents_alias_mapping_for_runtime_plan() {
fn tree_commands_use_protocol_names_in_runtime_plan() {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/tree/commands".parse::<Uri>().expect("uri"),
@@ -4354,14 +4354,8 @@ mod tests {
let compat_create_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire)
.expect("compat create plan");
assert_eq!(
tree_create_plan.function_name,
"documents:createWithParentReference"
);
assert_eq!(
tree_create_plan.function_name,
compat_create_plan.function_name
);
assert_eq!(tree_create_plan.function_name, "tree.node.create");
assert_eq!(compat_create_plan.function_name, "documents.create");
let tree_rename_wire = create_command_wire(
&context,
@@ -4384,11 +4378,8 @@ mod tests {
let compat_rename_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire)
.expect("compat rename plan");
assert_eq!(tree_rename_plan.function_name, "documents:updateTitle");
assert_eq!(
tree_rename_plan.function_name,
compat_rename_plan.function_name
);
assert_eq!(tree_rename_plan.function_name, "tree.node.rename");
assert_eq!(compat_rename_plan.function_name, "documents.title.update");
let tree_move_wire = create_command_wire(
&context,
@@ -4411,8 +4402,8 @@ mod tests {
let compat_move_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire)
.expect("compat move plan");
assert_eq!(tree_move_plan.function_name, "documents:move");
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
assert_eq!(tree_move_plan.function_name, "tree.subtree.move");
assert_eq!(compat_move_plan.function_name, "documents.move");
}
#[test]
@@ -282,7 +282,6 @@ fn apply_preference_records(
"source_family".to_string(),
"workspace".to_string(),
"document".to_string(),
"localOcr".to_string(),
];
for preference in preferences {
if preference.scope_kind.starts_with("ai.") && !scope_kinds.contains(&preference.scope_kind)
@@ -301,7 +300,6 @@ fn apply_preference_records(
"workspace" => preference.scope_id.trim() == scope.workspace_id,
"document" => preference.scope_id.trim() == scope.document_id,
kind if kind.starts_with("ai.") => preference.scope_id.trim() == scope.workspace_id,
"localOcr" => preference.scope_id.trim() == scope.workspace_id,
_ => false,
};
if !scope_matches {
@@ -319,8 +317,8 @@ fn apply_preference_records(
continue;
}
if preference.key.starts_with("localOcr.") {
local_ocr_preferences.insert(preference.key.clone(), value);
sources.insert(preference.key.clone(), scope_kind.to_string());
local_ocr_preferences.insert(preference.key.clone(), Value::Bool(false));
sources.insert(preference.key.clone(), "retired".to_string());
continue;
}
if let Some(content_type) = page_width_content_type_for_key(&preference.key) {
@@ -381,7 +379,7 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
}
}
if trimmed.starts_with("localOcr.") {
return Some(("localOcr".to_string(), scope.workspace_id.clone()));
return None;
}
if page_width_content_type_for_key(key).is_some() {
return Some(("global".to_string(), "default".to_string()));
@@ -416,11 +414,7 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
}
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace"
|| scope_kind == "document"
|| scope_kind.starts_with("ai.")
|| scope_kind == "localOcr"
{
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
Some(workspace_id.to_string())
} else {
None
@@ -428,11 +422,7 @@ fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Optio
}
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace"
|| scope_kind == "document"
|| scope_kind.starts_with("ai.")
|| scope_kind == "localOcr"
{
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
Some(source_kind.to_string())
} else {
None
+44 -11
View File
@@ -2328,6 +2328,20 @@ pub async fn filetree_selection_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn local_folder_event_bus_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/local-folder-event-bus-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn tree_live_controller_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-live-controller.js");
Response::builder()
@@ -3632,11 +3646,11 @@ mod tests {
assert!(runtime.contains("document-resource-tab-runtime.js"));
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
assert!(resource_runtime.contains("后台任务"));
assert!(resource_runtime.contains("data-mnote-local-ocr-task-tab"));
assert!(resource_runtime.contains("data-mnote-local-ocr-task-clear-completed"));
assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-tab"));
assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-clear-completed"));
assert!(resource_runtime.contains("role=\"progressbar\""));
assert!(resource_runtime.contains("localOcrTaskCategory(job)"));
assert!(resource_runtime.contains("localOcrTaskProgress(job)"));
assert!(resource_runtime.contains("knowledgeRagTaskCategory(job)"));
assert!(resource_runtime.contains("knowledgeRagTaskProgress(job)"));
assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
assert!(runtime.contains("getOpenEditorsSnapshot"));
assert!(resource_runtime.contains("bindMainEditorTabStrip"));
@@ -3734,6 +3748,11 @@ mod tests {
assert!(session_runtime.contains("mnote.localFolder.selfChangeSuppressions.v1"));
assert!(session_runtime.contains("ensureLocalFolderSelfChangeSuppressions()"));
assert!(session_runtime.contains("markLocalFolderSelfChangeSuppression(session);"));
assert!(session_runtime.contains("data-mnote-page-body-local-compat-fallback"));
assert!(session_runtime.contains("data-mnote-page-body-hard-guard"));
assert!(session_runtime.contains("local_compat_fallback"));
assert!(runtime.contains("data-mnote-page-body-local-compat-fallback"));
assert!(runtime.contains("data-mnote-page-body-hard-guard"));
assert!(session_runtime.contains("const suppressibleSelfWrite = kind.includes('Create')"));
assert!(session_runtime.contains("|| kind.includes('Modify(Data')"));
assert!(session_runtime.contains("|| kind.includes('Modify(Any')"));
@@ -3765,8 +3784,9 @@ mod tests {
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
);
assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
assert!(resource_runtime
.contains("if (currentHref !== nextHref) void openPassiveResourceTab(entry, input);"));
assert!(resource_runtime.contains(
"if (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void openPassiveResourceTab(entry, input);"
));
assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);"));
assert!(resource_runtime.contains("data-mnote-active-resource-width-type"));
@@ -4076,14 +4096,14 @@ mod tests {
.headers()
.get("x-error-phase")
.and_then(|value| value.to_str().ok()),
Some("query_send")
Some("convex_query_retired")
);
assert_eq!(
response
.headers()
.get("x-upstream-service")
.and_then(|value| value.to_str().ok()),
Some("convex")
Some("convex-retired")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
@@ -4733,7 +4753,9 @@ mod tests {
assert!(session_runtime.contains("/api/local-folder/events"));
assert!(session_runtime.contains("localFolderEventChannelKey"));
assert!(session_runtime.contains("url.searchParams.set('documentId', session.documentId);"));
assert!(session_runtime.contains("if (!documentId) return;"));
assert!(session_runtime.contains(
"if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;"
));
assert!(session_runtime.contains("new EventSource(url.toString())"));
assert!(session_runtime.contains("localFolderEventRegistry"));
assert!(session_runtime
@@ -4939,6 +4961,18 @@ mod tests {
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("createDocumentSessionRuntime"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("mnote:local-folder:document-changed"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-folder:resource-changed"));
assert!(
DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("data-mnote-local-ocr-event-stream-retired")
);
assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("local_ocr.job.updated"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:knowledge-rag-job-updated"));
assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-ocr-job-updated"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS
.contains("data-mnote-resource-watch-ready', 'event-bus'"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document.createElement('script')"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("syncPageAggregateScript({ pageAggregateScriptId"));
@@ -5120,8 +5154,7 @@ mod tests {
assert!(runtime.contains("typeof payload.text === 'string'"));
assert!(runtime.contains("payload.type === 'hard_break'"));
assert!(runtime.contains("typeof body?.fileVersion === 'string'"));
assert!(DOCUMENT_SESSION_RUNTIME_JS
.contains("expectedFileVersion: session.conflictDetectionKey"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("expectedFileVersion: expectedFileVersion"));
assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
assert!(runtime.contains("blockType: 'mindmap'"));
assert!(runtime.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)"));