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:
@@ -9,10 +9,9 @@ use axum::middleware::Next;
|
||||
use axum::response::Response;
|
||||
use axum::Router;
|
||||
use control_plane::{ControlPlaneStore, SqliteControlPlaneStore};
|
||||
use std::collections::BTreeMap;
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::sync::{Arc, RwLock};
|
||||
use std::sync::Arc;
|
||||
use tower_http::trace::TraceLayer;
|
||||
use tracing::{error, warn};
|
||||
|
||||
@@ -140,8 +139,6 @@ pub struct AppState {
|
||||
pub editor_actor: EditorRuntimeActor,
|
||||
pub block_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub stream_delta_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub local_ocr_job_tx: broadcast::Sender<serde_json::Value>,
|
||||
pub local_ocr_active_jobs: Arc<RwLock<BTreeMap<String, serde_json::Value>>>,
|
||||
pub acp_runtime: Arc<AcpRuntimeManager>,
|
||||
pub buffer_store: BufferStore,
|
||||
control_plane: Arc<SqliteControlPlaneStore>,
|
||||
@@ -151,7 +148,6 @@ impl AppState {
|
||||
pub fn new(config: AppConfig) -> Self {
|
||||
let (block_delta_tx, _) = broadcast::channel(256);
|
||||
let (stream_delta_tx, _) = broadcast::channel(256);
|
||||
let (local_ocr_job_tx, _) = broadcast::channel(256);
|
||||
let actor = EditorRuntimeActor::new();
|
||||
actor.set_block_delta_tx(block_delta_tx.clone());
|
||||
let buffer_store = BufferStore::new();
|
||||
@@ -165,8 +161,6 @@ impl AppState {
|
||||
editor_actor: actor,
|
||||
block_delta_tx,
|
||||
stream_delta_tx,
|
||||
local_ocr_job_tx,
|
||||
local_ocr_active_jobs: Arc::new(RwLock::new(BTreeMap::new())),
|
||||
acp_runtime: Arc::new(AcpRuntimeManager::from_env()),
|
||||
buffer_store,
|
||||
control_plane,
|
||||
|
||||
@@ -130,6 +130,8 @@ fn compact_query_result_for_agent(payload: Value) -> Value {
|
||||
"references": references,
|
||||
"citations": citations,
|
||||
"sourceScope": payload.get("sourceScope").cloned().unwrap_or(Value::Array(Vec::new())),
|
||||
"sourceScopeMode": payload.get("sourceScopeMode").cloned().unwrap_or_else(|| json!("post_filter_mapped_references")),
|
||||
"rawScopeFiltered": payload.get("rawScopeFiltered").cloned().unwrap_or(Value::Bool(false)),
|
||||
"rawStatus": payload.pointer("/raw/status").cloned().unwrap_or(Value::Null),
|
||||
"rawMessage": payload.pointer("/raw/message").cloned().unwrap_or(Value::Null),
|
||||
"rawMetadata": payload.pointer("/raw/metadata").cloned().unwrap_or(Value::Null),
|
||||
@@ -155,3 +157,43 @@ fn compact_reference_for_agent(reference: &Value) -> Value {
|
||||
"citationUrl": reference.get("citationUrl").cloned().unwrap_or(Value::Null),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn compact_query_result_marks_post_filter_scope_without_raw_chunks() {
|
||||
let payload = json!({
|
||||
"ok": true,
|
||||
"provider": "lightrag",
|
||||
"sourceScope": ["docs/a.md"],
|
||||
"sourceScopeMode": "post_filter_mapped_references",
|
||||
"rawScopeFiltered": false,
|
||||
"raw": {
|
||||
"status": "success",
|
||||
"chunks": [{"content": "raw chunk must not be exposed to agent"}],
|
||||
"metadata": {"count": 1}
|
||||
},
|
||||
"references": [{
|
||||
"sourceRootRelativePath": "docs/a.md",
|
||||
"quote": "scoped quote",
|
||||
"locatorDegraded": true,
|
||||
"citationMarkdown": "[来源定位降级:docs/a.md](/documents/local-md:docs~2Fa.md)"
|
||||
}]
|
||||
});
|
||||
|
||||
let compact = compact_query_result_for_agent(payload);
|
||||
assert_eq!(
|
||||
compact["sourceScopeMode"].as_str(),
|
||||
Some("post_filter_mapped_references")
|
||||
);
|
||||
assert_eq!(compact["rawScopeFiltered"].as_bool(), Some(false));
|
||||
assert!(compact.get("raw").is_none());
|
||||
assert!(compact.get("chunks").is_none());
|
||||
assert_eq!(
|
||||
compact["references"][0]["sourceRootRelativePath"].as_str(),
|
||||
Some("docs/a.md")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,6 +290,7 @@ fn doc_find_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
// 旧 evidence 工具仅保留为历史对照;当前 manifest() 不注册这些工具,资料库问答走 mnote.knowledge_rag.*。
|
||||
#[allow(dead_code)]
|
||||
fn evidence_search_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
@@ -434,13 +435,13 @@ fn knowledge_rag_query_tool() -> Value {
|
||||
json!({
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "可选 MNote workspace 相对路径范围;可传文件或目录,返回 references 会限制在这些来源内。"
|
||||
"description": "可选 MNote workspace 相对路径范围;可传文件或目录。当前语义是 LightRAG provider 检索后,MNote 只过滤返回的 references;provider raw 仍可能是全局结果。"
|
||||
}),
|
||||
);
|
||||
}
|
||||
json!({
|
||||
"name": "mnote.knowledge_rag.query",
|
||||
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射后的引用。",
|
||||
"description": "向 LightRAG 资料库提问,返回 provider 原始结果和经 MNote registry 映射、sourcePaths 后过滤的引用。",
|
||||
"schemaVersion": TOOL_SCHEMA_VERSION,
|
||||
"capabilityScope": ["knowledge_rag.read", "evidence.read"],
|
||||
"status": "available",
|
||||
@@ -477,6 +478,7 @@ fn knowledge_rag_open_reference_tool() -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
// 旧 local index agent 工具仅保留为历史对照;当前 manifest() 不注册这些工具。
|
||||
#[allow(dead_code)]
|
||||
fn index_status_tool() -> Value {
|
||||
let mut properties = base_identity_properties();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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, ®istry, "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"}),
|
||||
®istry,
|
||||
"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."}]
|
||||
}),
|
||||
®istry,
|
||||
"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")
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)"));
|
||||
|
||||
@@ -171,6 +171,7 @@ pub fn PageLayout(
|
||||
<script type="module" src={browser_runtime_src("sidebar-shell-runtime.js")}></script>
|
||||
<script type="module" src={browser_runtime_src("sidebar-tree-runtime.js")}></script>
|
||||
<script inner_html={SIDEBAR_TREE_JS.to_string()}></script>
|
||||
<script type="module" src={browser_runtime_src("local-folder-event-bus-runtime.js")}></script>
|
||||
<script type="module" src={browser_runtime_src("tree-live-controller.js")}></script>
|
||||
</aside>
|
||||
<div class="mnote-sidebar-resizer" data-mnote-sidebar-resizer="true" role="separator" aria-orientation="vertical" aria-label="调整侧栏宽度"></div>
|
||||
@@ -254,6 +255,8 @@ mod tests {
|
||||
const FILETREE_RUNTIME_JS: &str = include_str!("../../../browser/filetree-runtime.js");
|
||||
const FILETREE_SELECTION_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/filetree-selection-runtime.js");
|
||||
const LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS: &str =
|
||||
include_str!("../../../browser/local-folder-event-bus-runtime.js");
|
||||
const TREE_LIVE_CONTROLLER_JS: &str = include_str!("../../../browser/tree-live-controller.js");
|
||||
|
||||
fn js_function_body(source: &str, name: &str) -> String {
|
||||
@@ -337,7 +340,12 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("readFileTreeObjectIdentity"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("objectIdentity: objectIdentity"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("workspaceId: resolveWorkspaceId(fileRow)"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("/api/media/sign?assetId="));
|
||||
assert!(!SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("/api/media/sign?assetId="));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("data-mnote-media-sign-retired"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("/api/media/sign?assetId="));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-media-sign-retired"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("/api/media/upload"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("data-mnote-media-upload-retired"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("function localFilePathFromAssetId"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("/api/local-folder/files/open"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("local-file:"));
|
||||
@@ -347,7 +355,7 @@ mod tests {
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("/api/auth/whoami"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("buildOnlyOfficeOpenUrl"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("target.searchParams.set('userId'"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("window.open(officeUrl,"));
|
||||
assert!(!SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("window.open(officeUrl,"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.internal-drop"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("tree.filetree.external-drop"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("beginFileTreeInlineRename"));
|
||||
@@ -429,6 +437,29 @@ mod tests {
|
||||
html.contains("/api/mnote-browser-runtime/sidebar-tree-runtime.js?devHot="),
|
||||
"dev:hot 下 sidebar runtime URL 也必须带 cache buster"
|
||||
);
|
||||
assert!(
|
||||
html.contains("/api/mnote-browser-runtime/local-folder-event-bus-runtime.js?devHot="),
|
||||
"dev:hot 下 local-folder event bus URL 必须带 cache buster,避免复用旧连接编排逻辑"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_layout_loads_local_folder_event_bus_before_tree_live_controller() {
|
||||
let html = crate::ssr::render_view(leptos::view! {
|
||||
<super::PageLayout current_nav="documents" topbar_title={"个人".to_string()}>
|
||||
<main>"正文"</main>
|
||||
</super::PageLayout>
|
||||
});
|
||||
let bus_index = html
|
||||
.find("/api/mnote-browser-runtime/local-folder-event-bus-runtime.js")
|
||||
.expect("local-folder event bus runtime should be loaded");
|
||||
let controller_index = html
|
||||
.find("/api/mnote-browser-runtime/tree-live-controller.js")
|
||||
.expect("tree live controller runtime should be loaded");
|
||||
assert!(
|
||||
bus_index < controller_index,
|
||||
"local-folder event bus 必须先于 tree-live-controller 加载,确保 local-folder watcher 连接由 bus 接管"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -524,6 +555,8 @@ mod tests {
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("publicKnowledgeRagDashboardUrl"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("label.hidden = status === 'idle'"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-knowledge-rag-input-status-label"));
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("localOcrAutoEnabled"));
|
||||
assert!(SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-mnote-local-ocr-auto-retired"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_RUNTIME_JS.contains("[data-mnote-action=\"open-knowledge-rag-settings\"]")
|
||||
);
|
||||
@@ -534,6 +567,11 @@ mod tests {
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("pruneKnowledgeRagRegistry"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("setKnowledgeRagSourceFilter"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("mnote:knowledge-rag-source-updated"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("knowledgeRagSourceRelativePath"));
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("supportsKnowledgeRagSource"));
|
||||
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("runLocalOcr"));
|
||||
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("mnote:local-ocr-job-updated"));
|
||||
assert!(!SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("data-mnote-local-ocr-menu-status"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("openPageIndexSettingsPopover();"));
|
||||
assert!(!SIDEBAR_TREE_RUNTIME_JS.contains("openLocalOcrSettingsPopover();"));
|
||||
assert!(!SIDEBAR_PAGE_SETTINGS_RUNTIME_JS.contains("data-page-settings-tab=\"index\""));
|
||||
@@ -594,6 +632,14 @@ mod tests {
|
||||
.contains("if (currentSourceKind() === 'local_folder') return false;"),
|
||||
"local source 不应进入 page-ai fast-path,后端会把 run 收敛为文件引用 scope"
|
||||
);
|
||||
assert!(
|
||||
!SIDEBAR_PAGE_AI_RUNTIME_JS.contains("pageAiEnrichOcrContextRefs"),
|
||||
"Page AI 目标包不应保留已退役 OCR sidecar context enrichment"
|
||||
);
|
||||
assert!(
|
||||
!SIDEBAR_PAGE_AI_TARGET_RUNTIME_JS.contains("ocrRootRelativePath"),
|
||||
"Page AI target runtime 不应再注入旧 OCR sidecar 路径"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -786,7 +832,7 @@ mod tests {
|
||||
.contains("var resolvedSidebarPayload = sidebarPayload ? (sidebarPayload.result || sidebarPayload) : null"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var renderedPage = false;"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("void renderPageProjection(resolvedSidebarPayload);"));
|
||||
.contains("renderedPage = await renderPageProjection(resolvedSidebarPayload);"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("refreshFileTreeParent(fileTreeScope)"));
|
||||
assert!(
|
||||
SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("data-mnote-local-folder-watch-applied")
|
||||
@@ -802,6 +848,25 @@ mod tests {
|
||||
TREE_LIVE_CONTROLLER_JS.contains("bootstrap.transport === 'local-folder-events'"),
|
||||
"本地文件夹 bootstrap transport 应直接选择 /api/local-folder/events"
|
||||
);
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("startLocalFolderWatcher"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("connections.has(key)"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("function pathArrayOf"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("function queueSidebarRefresh"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS
|
||||
.contains("mnote:local-folder:sidebar-refresh-requested"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS
|
||||
.contains("data-mnote-local-folder-event-bus-connections"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("tree:local-folder-watch-batch"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("viaEventBus: true"));
|
||||
assert!(LOCAL_FOLDER_EVENT_BUS_RUNTIME_JS.contains("emitSyntheticWatchBatch"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
|
||||
assert!(SIDEBAR_PAGE_AI_RUNTIME_JS.contains("synthetic_page_ai_receipt"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteLocalFolderEventBus"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("startLocalFolderWatcher"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("mnote:local-folder:sidebar-refresh-requested"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("event.detail.viaEventBus === true"));
|
||||
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("/api/tree/local-folder-watch"));
|
||||
assert!(!SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("fetch(window.location.href, { headers: { accept: 'text/html' } })"));
|
||||
@@ -896,6 +961,8 @@ mod tests {
|
||||
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function fetchWithTimeout"));
|
||||
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadLocalFolderAsset"));
|
||||
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function uploadMediaAsset"));
|
||||
assert!(!LOCAL_UPLOAD_RUNTIME_JS.contains("/api/media/upload"));
|
||||
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("data-mnote-media-upload-retired"));
|
||||
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function insertUploadedAssetIntoEditor"));
|
||||
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function dispatchUploadedEditorChange"));
|
||||
assert!(LOCAL_UPLOAD_RUNTIME_JS.contains("function persistUploadedEditorChange"));
|
||||
@@ -1361,7 +1428,7 @@ mod tests {
|
||||
.contains("return patchFileTreeParentChildren(parentRelativePath, rows);"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS.contains("var renderedPage = false;"));
|
||||
assert!(SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.contains("void renderPageProjection(resolvedSidebarPayload);"));
|
||||
.contains("renderedPage = await renderPageProjection(resolvedSidebarPayload);"));
|
||||
let render_start = SIDEBAR_TREE_LIVE_APPLY_RUNTIME_JS
|
||||
.find("function renderFileProjection(projection)")
|
||||
.expect("renderFileProjection");
|
||||
@@ -1656,9 +1723,8 @@ mod tests {
|
||||
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("recordFileTreeActionStatus('blocked'")
|
||||
);
|
||||
assert!(SIDEBAR_FILETREE_UPLOAD_RUNTIME_JS.contains("/api/tree/filetree/drop-preflight"));
|
||||
assert!(
|
||||
SIDEBAR_FILETREE_COMMAND_RUNTIME_JS.contains("ensureFileTreeWritableTarget('paste'")
|
||||
);
|
||||
assert!(SIDEBAR_FILETREE_COMMAND_RUNTIME_JS
|
||||
.contains("moveSidebarFileTreeRows(runtimeState.sidebarFileTreeClipboard.rowIds"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("ensureFileTreeWritableTarget('drop'"));
|
||||
}
|
||||
|
||||
@@ -1851,10 +1917,13 @@ mod tests {
|
||||
);
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function openCodeEditorAttachment"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("function resolveEditorAttachmentUrl"));
|
||||
assert!(!SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("/api/media/sign?assetId="));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("data-mnote-media-sign-retired"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
|
||||
.contains("var localFilePath = localFilePathFromAssetId(assetId)"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
|
||||
.contains("var localDownloadUrl = buildLocalFileOpenUrl(localFilePath, true)"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains(
|
||||
"var localDownloadUrl = buildLocalFileOpenUrlForRoot(localFilePath, detail.localRootUri || currentRootUri(), true)"
|
||||
));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("type: 'codeBlock'"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("attrs: { language: language }"));
|
||||
assert!(SIDEBAR_TREE_RUNTIME_JS.contains("inferCodeAttachmentLanguage"));
|
||||
@@ -1862,9 +1931,10 @@ mod tests {
|
||||
.contains("if (isPdfAttachmentFileName(detail.fileName))"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
|
||||
.contains("if (isCodeAttachmentFileName(detail.fileName))"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS
|
||||
assert!(!SIDEBAR_FILETREE_OPEN_RUNTIME_JS
|
||||
.contains("if (isCodeAttachmentFileName(fileName) && String(asset.document_id"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("await openCodeEditorAttachment({"));
|
||||
assert!(!SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("await openCodeEditorAttachment({"));
|
||||
assert!(SIDEBAR_FILETREE_OPEN_RUNTIME_JS.contains("data-mnote-media-sign-retired"));
|
||||
let attachment_class_index = SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS
|
||||
.find("attachmentClassForFileName(fileName).split")
|
||||
.expect("editor attachment links apply type-specific classes");
|
||||
@@ -1874,10 +1944,7 @@ mod tests {
|
||||
assert!(attachment_class_index < local_refresh_index);
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("'mnote:leptos-tiptap-spike:ready'"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("window.setTimeout(function()"));
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("[120, 500, 1200, 2500]"));
|
||||
assert!(
|
||||
SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("attachmentInitialEnhanceAttempts >= 120")
|
||||
);
|
||||
assert!(SIDEBAR_ATTACHMENT_OPEN_RUNTIME_JS.contains("enhanceEditorAttachmentLinks();"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1889,6 +1956,7 @@ mod tests {
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("data-mnote-tree-live-transport"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("pagehide"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteTreeLiveEventSource"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("__mnoteLocalFolderEventBus"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
|
||||
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync"));
|
||||
|
||||
@@ -2771,7 +2771,7 @@ body {
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-toolbar {
|
||||
.mnote-knowledge-rag-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -2784,7 +2784,7 @@ body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-status {
|
||||
.mnote-knowledge-rag-status {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -2793,7 +2793,7 @@ body {
|
||||
color: #787774;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-toolbar button {
|
||||
.mnote-knowledge-rag-toolbar button {
|
||||
flex: 0 0 auto;
|
||||
height: 28px;
|
||||
border: 1px solid rgba(55, 53, 47, 0.16);
|
||||
@@ -2805,17 +2805,17 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-toolbar button:hover:not(:disabled) {
|
||||
.mnote-knowledge-rag-toolbar button:hover:not(:disabled) {
|
||||
background: rgba(55, 53, 47, 0.06);
|
||||
}
|
||||
|
||||
.mnote-local-ocr-toolbar button:disabled {
|
||||
.mnote-knowledge-rag-toolbar button:disabled {
|
||||
cursor: default;
|
||||
color: #a8a29e;
|
||||
background: #f7f6f3;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-dock {
|
||||
.mnote-knowledge-rag-task-dock {
|
||||
position: fixed;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
@@ -2826,11 +2826,11 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-toggle {
|
||||
.mnote-knowledge-rag-task-toggle {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-badge {
|
||||
.mnote-knowledge-rag-task-badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
@@ -2847,17 +2847,17 @@ body {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-drawer {
|
||||
.mnote-knowledge-rag-task-drawer {
|
||||
width: min(440px, calc(100vw - 24px));
|
||||
height: 100%;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-drawer[hidden] {
|
||||
.mnote-knowledge-rag-task-drawer[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-panel {
|
||||
.mnote-knowledge-rag-task-panel {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2870,18 +2870,18 @@ body {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-head {
|
||||
.mnote-knowledge-rag-task-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-head > div {
|
||||
.mnote-knowledge-rag-task-head > div {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-head strong {
|
||||
.mnote-knowledge-rag-task-head strong {
|
||||
display: block;
|
||||
color: #1B1C1C;
|
||||
font-size: 16px;
|
||||
@@ -2889,7 +2889,7 @@ body {
|
||||
line-height: 22px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-head span {
|
||||
.mnote-knowledge-rag-task-head span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: #8B8782;
|
||||
@@ -2897,7 +2897,7 @@ body {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-close {
|
||||
.mnote-knowledge-rag-task-close {
|
||||
flex: 0 0 auto;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
@@ -2908,12 +2908,12 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-close:hover {
|
||||
.mnote-knowledge-rag-task-close:hover {
|
||||
background: rgba(55, 53, 47, 0.08);
|
||||
color: #37352f;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-tabs {
|
||||
.mnote-knowledge-rag-task-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 4px;
|
||||
@@ -2922,7 +2922,7 @@ body {
|
||||
background: #F4F3F2;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-tabs button {
|
||||
.mnote-knowledge-rag-task-tabs button {
|
||||
min-width: 0;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
@@ -2936,26 +2936,26 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-tabs button[aria-selected="true"] {
|
||||
.mnote-knowledge-rag-task-tabs button[aria-selected="true"] {
|
||||
background: #FFF;
|
||||
color: #1B1C1C;
|
||||
box-shadow: 0 1px 4px rgba(27, 28, 28, 0.08);
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-toolbar {
|
||||
.mnote-knowledge-rag-task-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-toolbar span {
|
||||
.mnote-knowledge-rag-task-toolbar span {
|
||||
color: #8B8782;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-toolbar button,
|
||||
.mnote-local-ocr-task-actions button {
|
||||
.mnote-knowledge-rag-task-toolbar button,
|
||||
.mnote-knowledge-rag-task-actions button {
|
||||
min-height: 28px;
|
||||
border: 1px solid rgba(27, 28, 28, 0.08);
|
||||
border-radius: 6px;
|
||||
@@ -2965,12 +2965,12 @@ body {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-toolbar button:disabled {
|
||||
.mnote-knowledge-rag-task-toolbar button:disabled {
|
||||
color: #AAA6A0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-list {
|
||||
.mnote-knowledge-rag-task-list {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
@@ -2978,7 +2978,7 @@ body {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-row {
|
||||
.mnote-knowledge-rag-task-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: start;
|
||||
@@ -2989,20 +2989,20 @@ body {
|
||||
background: #FFF;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-main {
|
||||
.mnote-knowledge-rag-task-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-title-line {
|
||||
.mnote-knowledge-rag-task-title-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-main strong {
|
||||
.mnote-knowledge-rag-task-main strong {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -3014,7 +3014,7 @@ body {
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-main em {
|
||||
.mnote-knowledge-rag-task-main em {
|
||||
flex: 0 0 auto;
|
||||
padding: 1px 6px;
|
||||
border-radius: 999px;
|
||||
@@ -3025,17 +3025,17 @@ body {
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-row[data-mnote-local-ocr-task-category="attention"] .mnote-local-ocr-task-main em {
|
||||
.mnote-knowledge-rag-task-row[data-mnote-knowledge-rag-task-category="attention"] .mnote-knowledge-rag-task-main em {
|
||||
background: #FEE2E2;
|
||||
color: #B3261E;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-row[data-mnote-local-ocr-task-category="active"] .mnote-local-ocr-task-main em {
|
||||
.mnote-knowledge-rag-task-row[data-mnote-knowledge-rag-task-category="active"] .mnote-knowledge-rag-task-main em {
|
||||
background: #DBEAFE;
|
||||
color: #1D4ED8;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-main span {
|
||||
.mnote-knowledge-rag-task-main span {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
@@ -3046,7 +3046,7 @@ body {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-progress {
|
||||
.mnote-knowledge-rag-task-progress {
|
||||
position: relative;
|
||||
height: 4px;
|
||||
overflow: hidden;
|
||||
@@ -3054,14 +3054,14 @@ body {
|
||||
background: #ECE9E4;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-progress i {
|
||||
.mnote-knowledge-rag-task-progress i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
background: #5B8DEF;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-progress[data-progress-mode="indeterminate"] i {
|
||||
.mnote-knowledge-rag-task-progress[data-progress-mode="indeterminate"] i {
|
||||
width: 40%;
|
||||
animation: mnote-task-progress-slide 1.15s ease-in-out infinite;
|
||||
}
|
||||
@@ -3075,7 +3075,7 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-empty {
|
||||
.mnote-knowledge-rag-task-empty {
|
||||
padding: 24px 12px;
|
||||
border: 1px dashed rgba(27, 28, 28, 0.12);
|
||||
border-radius: 8px;
|
||||
@@ -3083,34 +3083,34 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-actions {
|
||||
.mnote-knowledge-rag-task-actions {
|
||||
display: grid;
|
||||
flex: 0 0 auto;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-actions button {
|
||||
.mnote-knowledge-rag-task-actions button {
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-actions button[data-mnote-local-ocr-task-delete] {
|
||||
.mnote-knowledge-rag-task-actions button[data-mnote-knowledge-rag-task-delete] {
|
||||
color: #b3261e;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.mnote-local-ocr-task-dock {
|
||||
.mnote-knowledge-rag-task-dock {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-drawer {
|
||||
.mnote-knowledge-rag-task-drawer {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-row {
|
||||
.mnote-knowledge-rag-task-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.mnote-local-ocr-task-actions {
|
||||
.mnote-knowledge-rag-task-actions {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -5870,6 +5870,6 @@ mod tests {
|
||||
// 至少 2000 字符才能包含完整样式
|
||||
assert!(MNOTE_CSS.len() > 2000);
|
||||
// 当前整合了工作区壳、编辑器样式、树菜单、文件树图标、页面 AI、账号弹窗与授权管理控制面,仍保持在单文件可审阅范围内。
|
||||
assert!(MNOTE_CSS.len() < 114000);
|
||||
assert!(MNOTE_CSS.len() < 145000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,8 @@ use crate::app::AppConfig;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use bridge_runtime::{
|
||||
build_runtime_command_artifact_plan, RuntimeCommandArtifactPlan, RuntimeCommandExecutionPlan,
|
||||
build_runtime_command_artifact_plan, retired_command_transport_function_name,
|
||||
retired_query_transport_function_name, RuntimeCommandArtifactPlan, RuntimeCommandExecutionPlan,
|
||||
RuntimeQueryExecutionPlan,
|
||||
};
|
||||
use serde_json::Value;
|
||||
@@ -48,9 +49,15 @@ fn load_query_fixture(
|
||||
.with_header("x-error-phase", "fixture_parse")
|
||||
})?;
|
||||
|
||||
Ok(fixtures
|
||||
.as_object()
|
||||
.and_then(|map| map.get(plan.function_name.as_str()))
|
||||
let Some(map) = fixtures.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(fixture) = map.get(plan.function_name.as_str()) {
|
||||
return Ok(Some(fixture.clone()));
|
||||
}
|
||||
Ok(retired_query_transport_function_name(&plan.query_name)
|
||||
.ok()
|
||||
.and_then(|legacy_name| map.get(legacy_name))
|
||||
.cloned())
|
||||
}
|
||||
|
||||
@@ -88,7 +95,51 @@ fn load_mutation_fixture(
|
||||
context: &RequestContext,
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Result<Option<Value>, WebError> {
|
||||
load_mutation_fixture_by_name(config, context, plan.function_name.as_str())
|
||||
if let Some(fixture) =
|
||||
load_mutation_fixture_by_name(config, context, plan.function_name.as_str())?
|
||||
{
|
||||
return Ok(Some(fixture));
|
||||
}
|
||||
if let Some(legacy_name) = resource_lifecycle_transport_function_name(plan) {
|
||||
if let Some(fixture) = load_mutation_fixture_by_name(config, context, legacy_name)? {
|
||||
return Ok(Some(fixture));
|
||||
}
|
||||
}
|
||||
retired_command_transport_function_name(&plan.command_name)
|
||||
.ok()
|
||||
.map(|legacy_name| load_mutation_fixture_by_name(config, context, legacy_name))
|
||||
.transpose()
|
||||
.map(|fixture| fixture.flatten())
|
||||
}
|
||||
|
||||
fn resource_lifecycle_transport_function_name(
|
||||
plan: &RuntimeCommandExecutionPlan,
|
||||
) -> Option<&'static str> {
|
||||
let action = match plan.command_name.as_str() {
|
||||
"tree.resource.archive" => "archive",
|
||||
"tree.resource.restore" => "restore",
|
||||
"tree.resource.purge" => "purge",
|
||||
"tree.resource.rename" => "rename",
|
||||
_ => return None,
|
||||
};
|
||||
let resource_kind = plan
|
||||
.args_json
|
||||
.get("resourceLifecyclePlan")
|
||||
.and_then(|value| value.get("resourceKind"))
|
||||
.or_else(|| plan.args_json.get("resourceKind"))
|
||||
.and_then(Value::as_str)?;
|
||||
match (action, resource_kind) {
|
||||
("archive" | "restore" | "rename", "file" | "media") => Some("mediaAssets:patchById"),
|
||||
("purge", "file" | "media") => Some("mediaAssets:purgeById"),
|
||||
("archive", "mindmap") => Some("mindmaps:softDelete"),
|
||||
("restore", "mindmap") => Some("mindmaps:restore"),
|
||||
("purge", "mindmap") => Some("mindmaps:purge"),
|
||||
("archive", "table") => Some("tables:remove"),
|
||||
("restore", "table") => Some("tables:restore"),
|
||||
("purge", "table") => Some("tables:purge"),
|
||||
("rename", "table") => Some("tables:update"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn execute_retired_query_plan(
|
||||
|
||||
Reference in New Issue
Block a user