feat(rag): harden post-LightRAG runtime

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

Validation: cargo test -p mnote-web -- --test-threads=1; cargo test --workspace -- --test-threads=1; git diff --check; codegraph sync .; codegraph_status.
This commit is contained in:
lix-2026
2026-06-07 10:35:21 +08:00
parent 22a92edcda
commit 9551d4c1dc
59 changed files with 4053 additions and 5249 deletions
+98 -66
View File
@@ -143,9 +143,9 @@ pub fn build_query_request<T>(
context: &BridgeContext,
query: &QueryEnvelope<T>,
) -> BridgeResult<RetiredQueryRequest> {
let function_name = legacy_query_function_name(&query.name)?;
legacy_query_function_name(&query.name)?;
Ok(RetiredQueryRequest {
function_name: function_name.to_string(),
function_name: query.name.clone(),
deployment_id: context.deployment_id.clone(),
project_id: context.project_id.clone(),
workspace_id: context.workspace_id.clone(),
@@ -160,9 +160,9 @@ pub fn build_write_request<T>(
context: &BridgeContext,
command: &CommandEnvelope<T>,
) -> BridgeResult<RetiredMutationRequest> {
let function_name = legacy_command_function_name(&command.name)?;
legacy_command_function_name(&command.name)?;
Ok(RetiredMutationRequest {
function_name: function_name.to_string(),
function_name: command.name.clone(),
deployment_id: context.deployment_id.clone(),
project_id: context.project_id.clone(),
workspace_id: context.workspace_id.clone(),
@@ -182,26 +182,55 @@ fn legacy_query_function_name(name: &str) -> BridgeResult<&'static str> {
match name {
"documents.content.get" => Ok("documents:getContent"),
"documents.meta.get" => Ok("documents:getMeta"),
"blocks.get" => Ok("blocks:getById"),
"mindmaps.get" => Ok("mindmaps:get"),
"search.documents" => Ok("documents:listSearchDataByWorkspace"),
"search.documents" => Ok("search:documents"),
"search_blocks" => Ok("documents:searchBlocks"),
"sidebar.dataset.list" => Ok("sidebar:datasetList"),
"bridge.request.get" => Ok("bridgeLogs:listByRequest"),
"bridge.trace.get" => Ok("bridgeLogs:listByTrace"),
"bridge.command.get" => Ok("bridgeLogs:listByCommand"),
"bridge.workspace.overview" => Ok("bridgeLogs:listWorkspaceOverview"),
_ => Err(retired_bridge_error()),
}
}
pub fn retired_query_transport_function_name(name: &str) -> BridgeResult<&'static str> {
legacy_query_function_name(name)
}
fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> {
match name {
"documents.title.update" => Ok("documents:updateTitle"),
"page.head.updateTitle" | "tree.node.rename" => Ok("documents:updateTitle"),
"documents.save" => Ok("documents:updateContent"),
"documents.create" => Ok("documents:createWithParentReference"),
"documents.move" => Ok("documents:move"),
"documents.delete" => Ok("documents:softDelete"),
"documents.restore" => Ok("documents:restore"),
"insert_block" | "blocks.patch" | "blocks.move" | "blocks.embed" => {
Ok("documents:updateContent")
"page.body.save" => Ok("documents:updateContent"),
"documents.options.update" | "page.layout.updateOptions" => Ok("documents:updateOptions"),
"documents.stats.update" => Ok("documents:updateStats"),
"documents.create" | "tree.node.create" => Ok("documents:createWithParentReference"),
"documents.move" | "tree.node.move" | "tree.subtree.move" => Ok("documents:move"),
"documents.delete" | "tree.node.archive" => Ok("documents:softDelete"),
"documents.restore" | "tree.node.restore" => Ok("documents:restore"),
"documents.purge" | "tree.node.purge" => Ok("documents:purge"),
"documents.copy_tree" | "tree.subtree.copy" => Ok("documents:copyTree"),
"documents.duplicate" => Ok("documents:duplicateWithMindmaps"),
"documents.embed" | "tree.node.embed" => Ok("documents:updateContent"),
"documents.emptyTrashByWorkspace" | "tree.trash.emptyWorkspace" => {
Ok("documents:emptyTrashByWorkspace")
}
"insert_block" | "blocks.patch" => Ok("documents:updateContent"),
"blocks.move" => Ok("blocks:move"),
"blocks.embed" => Ok("blocks:insert"),
"mindmaps.put" => Ok("mindmaps:put"),
"mindmap.command.apply" => Ok("mindmaps:applyCommand"),
"media.assets.replace_storage" => Ok("mediaAssets:replaceStorageFromUpload"),
"tree.filetree.drop.preflight" => Ok("tree:fileTreeDropPreflight"),
"tree.filetree.delete.preflight" => Ok("tree:fileTreeDeletePreflight"),
"tree.filetree.paste.preflight" => Ok("tree:fileTreePastePreflight"),
"tree.filetree.upload-target.preflight" => Ok("tree:fileTreeUploadTargetPreflight"),
"tree.resource.copy" => Ok("mediaAssets:batchCopy"),
"tree.resource.move" => Ok("mediaAssets:batchMove"),
"tree.resource.upload" => Ok("mediaAssets:createWithStorage"),
"tree.resource.archive" => Ok("treeResource:archive"),
"tree.resource.restore" => Ok("treeResource:restore"),
"tree.resource.purge" => Ok("treeResource:purge"),
@@ -210,6 +239,10 @@ fn legacy_command_function_name(name: &str) -> BridgeResult<&'static str> {
}
}
pub fn retired_command_transport_function_name(name: &str) -> BridgeResult<&'static str> {
legacy_command_function_name(name)
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase", tag = "kind")]
pub enum RuntimeInput {
@@ -1857,7 +1890,7 @@ fn build_resource_lifecycle_plan(
Ok(plan)
}
fn resource_lifecycle_convex_function(
fn retired_resource_lifecycle_transport_function(
action: &str,
resource_kind: &str,
) -> Result<&'static str, BridgeError> {
@@ -4639,7 +4672,7 @@ fn build_tool_plan_steps(
steps.push(RuntimeToolPlanStep {
kind: "write".into(),
name: "media.assets.replace_storage".into(),
function_name: Some("mediaAssets:replaceStorageFromUpload".into()),
function_name: Some("media.assets.replace_storage".into()),
description:
"callback 下载并上传文件后,会继续通过统一 bridge 命令写回附件 storage 绑定"
.into(),
@@ -4754,7 +4787,7 @@ fn build_tool_plan_steps(
steps.push(RuntimeToolPlanStep {
kind: "write".into(),
name: "mindmaps.put".into(),
function_name: Some("mindmaps:put".into()),
function_name: Some("mindmaps.put".into()),
description: "用完整思维导图树覆盖当前导图".into(),
args_json: tool_wire.args_json.clone(),
});
@@ -11384,8 +11417,7 @@ fn execute_command(
validate_only: command_wire.validate_only,
};
let request = build_write_request(&context, &command)?;
let function_name =
resource_lifecycle_convex_function(action, &lifecycle_plan.resource_kind)?;
retired_resource_lifecycle_transport_function(action, &lifecycle_plan.resource_kind)?;
let stream_delta_hint = resource_lifecycle_stream_delta_hint(&lifecycle_plan);
let event_type = resource_lifecycle_event_type(action);
@@ -11448,7 +11480,7 @@ fn execute_command(
Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan {
command_name: command.name,
command_id: command.command_id,
function_name: function_name.into(),
function_name: request.function_name,
workspace_id: request.workspace_id,
request_id: request.request_id,
trace_id: request.trace_id,
@@ -12568,7 +12600,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.function_name, "blocks:getById");
assert_eq!(plan.function_name, "blocks.get");
assert_eq!(
plan.args_json,
json!({
@@ -12628,7 +12660,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "blocks.patch");
assert_eq!(plan.command_name, "blocks.patch");
assert_eq!(
plan.args_json,
@@ -12965,7 +12997,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.function_name, "sidebar:datasetList");
assert_eq!(plan.function_name, "sidebar.dataset.list");
assert_eq!(plan.args_json, json!({ "workspaceId": "ws_1" }));
}
RuntimeExecutionPlan::Command(_) => panic!("expected query plan"),
@@ -12999,7 +13031,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.function_name, "search:documents");
assert_eq!(plan.function_name, "search.documents");
assert_eq!(
plan.args_json,
json!({
@@ -13969,7 +14001,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.query_name, "mindmaps.get");
assert_eq!(plan.function_name, "mindmaps:get");
assert_eq!(plan.function_name, "mindmaps.get");
assert_eq!(plan.args_json["docId"], json!("doc_1"));
assert_eq!(plan.args_json["mindmapId"], json!("mind_1"));
}
@@ -14022,7 +14054,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "mindmap.command.apply");
assert_eq!(plan.function_name, "mindmaps:applyCommand");
assert_eq!(plan.function_name, "mindmap.command.apply");
assert_eq!(
plan.args_json["canonicalCommand"],
json!("mindmap.command.apply")
@@ -14301,7 +14333,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.function_name, "mindmaps:get");
assert_eq!(plan.function_name, "mindmaps.get");
assert_eq!(
plan.args_json,
json!({
@@ -14360,7 +14392,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "mindmaps:put");
assert_eq!(plan.function_name, "mindmaps.put");
assert_eq!(plan.args_json["docId"], json!("doc_1"));
assert_eq!(plan.args_json["mindmapId"], json!("mind_1"));
assert_eq!(
@@ -14963,7 +14995,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "documents.save");
assert_eq!(plan.args_json.get("id"), Some(&json!("doc_1")));
assert_eq!(
plan.args_json.pointer("/editorDocument/rootBlockIds/0"),
@@ -15507,7 +15539,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.command_name, "page.body.save");
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "page.body.save");
assert_eq!(plan.args_json["expectedRevision"], json!(1));
assert_eq!(plan.args_json["conflictDetectionKey"], json!("doc_1:1"));
}
@@ -15551,7 +15583,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.command_name, "page.head.updateTitle");
assert_eq!(plan.function_name, "documents:updateTitle");
assert_eq!(plan.function_name, "page.head.updateTitle");
}
#[test]
@@ -15594,7 +15626,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.command_name, "page.layout.updateOptions");
assert_eq!(plan.function_name, "documents:updateOptions");
assert_eq!(plan.function_name, "page.layout.updateOptions");
}
#[test]
@@ -15892,7 +15924,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "documents.save");
assert_eq!(
plan.args_json.pointer("/content/0/content"),
Some(&json!("来自 editorDocument"))
@@ -15960,7 +15992,7 @@ mod tests {
panic!("expected command plan");
};
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "page.body.save");
assert_eq!(
plan.args_json.pointer("/content/0/id"),
Some(&json!("legacy_content_1"))
@@ -16276,7 +16308,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "blocks:move");
assert_eq!(plan.function_name, "blocks.move");
assert_eq!(plan.command_name, "blocks.move");
assert_eq!(
plan.args_json,
@@ -16372,7 +16404,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "blocks:insert");
assert_eq!(plan.function_name, "blocks.embed");
assert_eq!(plan.command_name, "blocks.embed");
assert_eq!(
plan.args_json,
@@ -16631,7 +16663,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "documents.embed");
assert_eq!(plan.command_name, "documents.embed");
assert_eq!(
plan.args_json,
@@ -16677,7 +16709,7 @@ mod tests {
let cases = [
(
"tree.node.archive",
"documents:softDelete",
"tree.node.archive",
json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
@@ -16719,7 +16751,7 @@ mod tests {
),
(
"tree.node.restore",
"documents:restore",
"tree.node.restore",
json!({
"documentId": "doc_1",
"workspaceId": "ws_1",
@@ -16761,7 +16793,7 @@ mod tests {
),
(
"tree.node.purge",
"documents:purge",
"tree.node.purge",
json!({
"documentId": "doc_1",
}),
@@ -17305,7 +17337,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.subtree.move");
assert_eq!(plan.function_name, "documents:move");
assert_eq!(plan.function_name, "tree.subtree.move");
assert_eq!(plan.args_json["sortOrder"], json!(-2));
assert_eq!(
plan.args_json["commandProtocol"],
@@ -17793,7 +17825,7 @@ mod tests {
match create_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "tree.node.create");
assert_eq!(plan.function_name, "documents:createWithParentReference");
assert_eq!(plan.function_name, "tree.node.create");
assert_eq!(
plan.args_json["streamDeltaHint"],
json!({
@@ -17872,7 +17904,7 @@ mod tests {
match rename_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "tree.node.rename");
assert_eq!(plan.function_name, "documents:updateTitle");
assert_eq!(plan.function_name, "tree.node.rename");
assert_eq!(
plan.args_json["streamDeltaHint"],
json!({
@@ -17978,7 +18010,7 @@ mod tests {
match embed_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:updateContent");
assert_eq!(plan.function_name, "tree.node.embed");
assert_eq!(plan.command_name, "tree.node.embed");
assert_eq!(
plan.args_json,
@@ -18105,7 +18137,7 @@ mod tests {
match copy_plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:copyTree");
assert_eq!(plan.function_name, "tree.subtree.copy");
assert_eq!(plan.command_name, "tree.subtree.copy");
assert_eq!(
plan.args_json,
@@ -18199,7 +18231,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "documents.emptyTrashByWorkspace");
assert_eq!(plan.function_name, "documents:emptyTrashByWorkspace");
assert_eq!(plan.function_name, "documents.emptyTrashByWorkspace");
assert_eq!(plan.args_json["workspaceId"], json!("ws_1"));
assert_eq!(
plan.args_json["streamDeltaHint"],
@@ -18284,7 +18316,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.function_name, "documents:duplicateWithMindmaps");
assert_eq!(plan.function_name, "documents.duplicate");
assert_eq!(plan.command_name, "documents.duplicate");
assert_eq!(
plan.args_json["streamDeltaHint"],
@@ -18361,7 +18393,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "documents.options.update");
assert_eq!(plan.function_name, "documents:updateOptions");
assert_eq!(plan.function_name, "documents.options.update");
assert_eq!(plan.args_json["id"], json!("doc_1"));
assert_eq!(
plan.args_json["options"],
@@ -18453,7 +18485,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "documents.stats.update");
assert_eq!(plan.function_name, "documents:updateStats");
assert_eq!(plan.function_name, "documents.stats.update");
assert_eq!(
plan.args_json,
json!({
@@ -18517,7 +18549,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Command(plan) => {
assert_eq!(plan.command_name, "media.assets.replace_storage");
assert_eq!(plan.function_name, "mediaAssets:replaceStorageFromUpload");
assert_eq!(plan.function_name, "media.assets.replace_storage");
assert_eq!(
plan.args_json,
json!({
@@ -18539,13 +18571,13 @@ mod tests {
let cases = [
(
"tree.resource.copy",
"mediaAssets:batchCopy",
"tree.resource.copy",
"copy",
"tree.resource.copied",
),
(
"tree.resource.move",
"mediaAssets:batchMove",
"tree.resource.move",
"move",
"tree.resource.moved",
),
@@ -18642,7 +18674,7 @@ mod tests {
let cases = [
(
"tree.resource.archive",
"mediaAssets:patchById",
"tree.resource.archive",
"archive",
"tree.resource.archived",
json!({
@@ -18690,7 +18722,7 @@ mod tests {
),
(
"tree.resource.restore",
"mediaAssets:patchById",
"tree.resource.restore",
"restore",
"tree.resource.restored",
json!({
@@ -18738,7 +18770,7 @@ mod tests {
),
(
"tree.resource.purge",
"mediaAssets:purgeById",
"tree.resource.purge",
"purge",
"tree.resource.purged",
json!({
@@ -18786,7 +18818,7 @@ mod tests {
),
(
"tree.resource.rename",
"mediaAssets:patchById",
"tree.resource.rename",
"rename",
"tree.resource.renamed",
json!({
@@ -18891,7 +18923,7 @@ mod tests {
(
"tree.resource.archive",
"mindmap",
"mindmaps:softDelete",
"tree.resource.archive",
json!({
"resourceKind": "mindmap",
"documentId": "doc_1",
@@ -18915,7 +18947,7 @@ mod tests {
(
"tree.resource.restore",
"mindmap",
"mindmaps:restore",
"tree.resource.restore",
json!({
"resourceKind": "mindmap",
"documentId": "doc_1",
@@ -18939,7 +18971,7 @@ mod tests {
(
"tree.resource.purge",
"mindmap",
"mindmaps:purge",
"tree.resource.purge",
json!({
"resourceKind": "mindmap",
"documentId": "doc_1",
@@ -18963,7 +18995,7 @@ mod tests {
(
"tree.resource.archive",
"table",
"tables:remove",
"tree.resource.archive",
json!({
"resourceKind": "table",
"tableId": "table_1",
@@ -18986,7 +19018,7 @@ mod tests {
(
"tree.resource.restore",
"table",
"tables:restore",
"tree.resource.restore",
json!({
"resourceKind": "table",
"tableId": "table_1",
@@ -19009,7 +19041,7 @@ mod tests {
(
"tree.resource.purge",
"table",
"tables:purge",
"tree.resource.purge",
json!({
"resourceKind": "table",
"tableId": "table_1",
@@ -19263,7 +19295,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.filetree.drop.preflight");
assert_eq!(plan.function_name, "tree:fileTreeDropPreflight");
assert_eq!(plan.function_name, "tree.filetree.drop.preflight");
assert_eq!(
plan.args_json["fileTreeDropPlan"],
json!({
@@ -19677,7 +19709,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.filetree.delete.preflight");
assert_eq!(plan.function_name, "tree:fileTreeDeletePreflight");
assert_eq!(plan.function_name, "tree.filetree.delete.preflight");
assert_eq!(
plan.args_json["fileTreeDeletePlan"],
json!({
@@ -19773,7 +19805,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.filetree.paste.preflight");
assert_eq!(plan.function_name, "tree:fileTreePastePreflight");
assert_eq!(plan.function_name, "tree.filetree.paste.preflight");
assert_eq!(
plan.args_json["fileTreePastePlan"],
json!({
@@ -19864,7 +19896,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.filetree.upload-target.preflight");
assert_eq!(plan.function_name, "tree:fileTreeUploadTargetPreflight");
assert_eq!(plan.function_name, "tree.filetree.upload-target.preflight");
assert_eq!(
plan.args_json["fileTreeUploadTargetPlan"],
json!({
@@ -19926,7 +19958,7 @@ mod tests {
};
assert_eq!(plan.command_name, "tree.resource.upload");
assert_eq!(plan.function_name, "mediaAssets:createWithStorage");
assert_eq!(plan.function_name, "tree.resource.upload");
assert_eq!(
plan.args_json,
json!({
@@ -20093,7 +20125,7 @@ mod tests {
match plan {
RuntimeExecutionPlan::Query(plan) => {
assert_eq!(plan.function_name, "bridgeLogs:listByRequest");
assert_eq!(plan.function_name, "bridge.request.get");
assert_eq!(plan.args_json["workspaceId"], json!("ws_1"));
assert_eq!(plan.args_json["requestId"], json!("req_lookup_1"));
assert_eq!(plan.args_json["commandId"], json!("cmd_lookup_1"));
@@ -20129,7 +20161,7 @@ mod tests {
assert_eq!(plan.steps[0].name, "bridge.trace.get");
assert_eq!(
plan.steps[0].function_name.as_deref(),
Some("bridgeLogs:listByTrace")
Some("bridge.trace.get")
);
}
_ => panic!("expected tool plan"),
+26 -25
View File
@@ -571,7 +571,7 @@ pub fn plan_page_get(
"workspaceId": workspace_id,
}),
CliTransportPlan {
kind: "convex_query".into(),
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -622,7 +622,7 @@ pub fn plan_page_title(
"title": title,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -682,7 +682,7 @@ pub fn plan_page_save(
"conflictDetectionKey": conflict_detection_key,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -737,7 +737,7 @@ pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResul
"content": content_value,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -790,7 +790,7 @@ pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult<Cl
"sortOrder": args.sort_order,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -836,7 +836,7 @@ pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResul
"workspaceId": args.workspace_id,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -880,7 +880,7 @@ pub fn plan_page_restore(ctx: &CliContext, args: &PageRestoreArgs<'_>) -> CliRes
"workspaceId": args.workspace_id,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -940,7 +940,7 @@ pub fn plan_block_insert(
"prevBlockId": prev_block_id,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1006,7 +1006,7 @@ pub fn plan_block_patch(
"conflictDetectionKey": conflict_detection_key,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1058,7 +1058,7 @@ pub fn plan_block_move(ctx: &CliContext, args: &BlockMoveArgs<'_>) -> CliResult<
"targetDocumentId": args.target_document_id,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1109,7 +1109,7 @@ pub fn plan_block_embed(ctx: &CliContext, args: &BlockEmbedArgs<'_>) -> CliResul
"targetBlockId": args.target_block_id,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1174,7 +1174,7 @@ pub fn plan_search_documents(
},
}),
CliTransportPlan {
kind: "convex_query".into(),
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1230,7 +1230,7 @@ pub fn plan_search_blocks(
},
}),
CliTransportPlan {
kind: "convex_query".into(),
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1262,7 +1262,7 @@ pub fn plan_sidebar_dataset(ctx: &CliContext, workspace_id: &str) -> CliResult<C
"workspaceId": workspace_id,
}),
CliTransportPlan {
kind: "convex_query".into(),
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1306,7 +1306,7 @@ pub fn plan_mindmap_get(
"mindmapId": mindmap_id,
}),
CliTransportPlan {
kind: "convex_query".into(),
kind: "runtime_query_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -1375,7 +1375,7 @@ pub fn plan_mindmap_put(
"data": data,
}),
CliTransportPlan {
kind: "convex_mutation".into(),
kind: "runtime_command_plan".into(),
function_name: request.function_name,
payload_json: request.payload_json,
args_json: json!({
@@ -2118,7 +2118,8 @@ mod tests {
} => {
assert_eq!(name, "documents.save");
assert_eq!(command_id, "cmd_page_save_page_1");
assert_eq!(transport.function_name, "documents:updateContent");
assert_eq!(transport.kind, "runtime_command_plan");
assert_eq!(transport.function_name, "documents.save");
assert_eq!(
transport.args_json,
json!({
@@ -2165,10 +2166,8 @@ mod tests {
name, transport, ..
} => {
assert_eq!(name, "documents.create");
assert_eq!(
transport.function_name,
"documents:createWithParentReference"
);
assert_eq!(transport.kind, "runtime_command_plan");
assert_eq!(transport.function_name, "documents.create");
assert_eq!(transport.args_json["workspaceId"], json!("ws_1"));
assert_eq!(transport.args_json["parentId"], json!("parent_1"));
}
@@ -2194,7 +2193,8 @@ mod tests {
name, transport, ..
} => {
assert_eq!(name, "documents.move");
assert_eq!(transport.function_name, "documents:move");
assert_eq!(transport.kind, "runtime_command_plan");
assert_eq!(transport.function_name, "documents.move");
assert_eq!(transport.args_json["sortOrder"], json!(3));
}
_ => panic!("expected command output"),
@@ -2211,7 +2211,8 @@ mod tests {
name, transport, ..
} => {
assert_eq!(name, "sidebar.dataset.list");
assert_eq!(transport.function_name, "sidebar:datasetList");
assert_eq!(transport.kind, "runtime_query_plan");
assert_eq!(transport.function_name, "sidebar.dataset.list");
assert_eq!(transport.args_json, json!({ "workspaceId": "ws_1" }));
}
_ => panic!("expected query output"),
@@ -2292,7 +2293,7 @@ mod tests {
name, transport, ..
} => {
assert_eq!(name, "mindmaps.get");
assert_eq!(transport.function_name, "mindmaps:get");
assert_eq!(transport.function_name, "mindmaps.get");
assert_eq!(
transport.args_json,
json!({
@@ -2322,7 +2323,7 @@ mod tests {
name, transport, ..
} => {
assert_eq!(name, "mindmaps.put");
assert_eq!(transport.function_name, "mindmaps:put");
assert_eq!(transport.function_name, "mindmaps.put");
assert_eq!(
transport.args_json,
json!({
@@ -842,6 +842,8 @@ import {
setStatus(runtimeDescriptor, 'ready');
}
if (session.pageBodySource) runtimeDescriptor.root.setAttribute('data-mnote-page-body-source', session.pageBodySource);
runtimeDescriptor.root.setAttribute('data-mnote-page-body-local-compat-fallback', session.pageBodyLocalCompatFallback ? 'true' : 'false');
if (session.pageBodyHardGuard) runtimeDescriptor.root.setAttribute('data-mnote-page-body-hard-guard', session.pageBodyHardGuard);
if (session.projectionSource) runtimeDescriptor.root.setAttribute('data-mnote-projection-source', session.projectionSource);
if (session.blockProjectionVersion) runtimeDescriptor.root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion);
enhanceEditorAttachmentLinksSoon();
File diff suppressed because it is too large Load Diff
@@ -125,6 +125,8 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
return `${String(session?.rootUri || '').trim()}#${scope}`;
};
const localFolderEventBusChannelKey = (session) => `${String(session?.rootUri || '').trim()}#event-bus`;
const localMarkdownRelativePathFromDocumentId = (documentId) => {
const value = String(documentId || '').trim();
if (!value.startsWith('local-md:')) return '';
@@ -136,6 +138,25 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
|| localMarkdownRelativePathFromDocumentId(session?.documentId)
);
const localFolderEventItems = (payload) => {
if (!payload || typeof payload !== 'object') return [];
const changedPaths = Array.isArray(payload.changedPaths)
? payload.changedPaths
: (Array.isArray(payload.changed_paths) ? payload.changed_paths : []);
if (changedPaths.length > 0) {
return changedPaths.map((item) => {
if (typeof item === 'string') return { relativePath: item };
if (!item || typeof item !== 'object') return null;
return {
relativePath: String(item.relativePath || item.relative_path || item.path || '').trim(),
documentId: String(item.documentId || item.document_id || '').trim(),
eventKind: String(item.eventKind || item.event_kind || item.changeType || item.change_type || '').trim(),
};
}).filter(Boolean);
}
return [payload];
};
const sessionBufferStateUrl = (session) => {
if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;
const url = new URL('/api/documents/buffer-state', window.location.origin);
@@ -206,10 +227,14 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
if (!channel) return;
channel.sessions.delete(session.key);
if (channel.sessions.size === 0) {
try {
channel.eventSource.close();
} catch (_) {
// noop
if (typeof channel.unsubscribe === 'function') {
channel.unsubscribe();
} else if (channel.eventSource && typeof channel.eventSource.close === 'function') {
try {
channel.eventSource.close();
} catch (_) {
// noop
}
}
localFolderEventRegistry.delete(channel.key);
}
@@ -385,7 +410,53 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
}, source || 'mnote-web-document-session-meta');
};
const pageBodySourceMeta = (sourceKind, pageBody) => {
const pageBodySource = pageBodyTiptapDocumentSource(pageBody || {}, '');
const projectionSource = String(pageBody?.projectionSource || pageBody?.projection_source || '').trim();
const blockProjectionVersion = String(pageBody?.blockProjectionVersion || pageBody?.block_projection_version || '').trim();
const localCompatFallback = sourceKind === 'local_folder' && pageBodySource === 'compat.legacy_content';
return {
pageBodySource,
projectionSource,
blockProjectionVersion,
localCompatFallback,
hardGuard: localCompatFallback
? 'local_compat_fallback'
: (sourceKind === 'local_folder' ? 'local_ok' : 'compat_allowed'),
};
};
const applyPageBodySourceMetaToSession = (session, pageBody) => {
const meta = pageBodySourceMeta(session.sourceKind, pageBody || {});
session.pageBodySource = meta.pageBodySource;
session.projectionSource = meta.projectionSource;
session.blockProjectionVersion = meta.blockProjectionVersion;
session.pageBodyLocalCompatFallback = meta.localCompatFallback;
session.pageBodyHardGuard = meta.hardGuard;
};
const syncPageBodySourceDiagnosticsToViews = (session) => {
sessionViews(session).forEach((view) => {
const root = view?.runtimeDescriptor?.root;
if (!(root instanceof HTMLElement)) return;
root.setAttribute('data-mnote-page-body-source', session.pageBodySource || '');
root.setAttribute('data-mnote-page-body-local-compat-fallback', session.pageBodyLocalCompatFallback ? 'true' : 'false');
root.setAttribute('data-mnote-page-body-hard-guard', session.pageBodyHardGuard || '');
if (session.projectionSource) {
root.setAttribute('data-mnote-projection-source', session.projectionSource);
} else {
root.removeAttribute('data-mnote-projection-source');
}
if (session.blockProjectionVersion) {
root.setAttribute('data-mnote-block-projection-version', session.blockProjectionVersion);
} else {
root.removeAttribute('data-mnote-block-projection-version');
}
});
};
const syncSessionMetaToViews = (session) => {
syncPageBodySourceDiagnosticsToViews(session);
sessionViews(session).forEach((view) => {
if (view.mountId != null) dispatchSessionMetaToView(session, view);
});
@@ -543,6 +614,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
applyPageBodySourceMetaToSession(session, nextBody);
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
@@ -1168,15 +1240,22 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextPermissions = nextAggregate?.head?.permissions || {};
const nextConflictKey = conflictDetectionKeyFromBody(nextBody);
const nextTiptapDocument = pageBodyTiptapDocument(nextBody, '', session);
const nextPageBodyMeta = pageBodySourceMeta(session.sourceKind, nextBody);
const nextSerialized = JSON.stringify(nextTiptapDocument);
const contentChanged = nextSerialized !== session.currentSerialized;
session.externalChangePending = false;
session.bufferDirtyState = 'Clean';
if (!nextConflictKey || !session.lastExternalConflictDetectionKey) {
session.lastExternalConflictDetectionKey = nextConflictKey || session.lastExternalConflictDetectionKey;
if (!contentChanged) return;
if (!contentChanged) {
applyPageBodySourceMetaToSession(session, nextBody);
syncPageBodySourceDiagnosticsToViews(session);
return;
}
}
if (nextConflictKey === session.lastExternalConflictDetectionKey) {
applyPageBodySourceMetaToSession(session, nextBody);
syncPageBodySourceDiagnosticsToViews(session);
if (nextConflictKey && session.conflictDetectionKey !== nextConflictKey) {
session.conflictDetectionKey = nextConflictKey;
session.fileVersion = nextConflictKey;
@@ -1187,6 +1266,11 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
session.latestAggregate = nextAggregate;
syncPageAggregateScript(session, nextAggregate);
session.pageBodySource = nextPageBodyMeta.pageBodySource;
session.projectionSource = nextPageBodyMeta.projectionSource;
session.blockProjectionVersion = nextPageBodyMeta.blockProjectionVersion;
session.pageBodyLocalCompatFallback = nextPageBodyMeta.localCompatFallback;
session.pageBodyHardGuard = nextPageBodyMeta.hardGuard;
session.title = nextAggregate?.head?.title || session.title;
session.currentTiptapDocument = nextTiptapDocument;
session.currentSerialized = nextSerialized;
@@ -1284,10 +1368,94 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
await refreshSessionFromExternalChange(session, 'mnote-web-local-folder-watch');
};
const handleLocalFolderEventPayloadForSessions = (channel, payload) => {
if (!payload) return;
localFolderEventItems(payload).forEach((item) => {
Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof item.documentId === 'string' ? item.documentId.trim() : '';
const relativePath = typeof item.relativePath === 'string' ? item.relativePath.trim() : '';
if (targetSession.sessionKind === 'resource') {
if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return;
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch');
return;
}
const eventKind = String(item.eventKind || '');
const relativeTarget = sessionRelativePath(targetSession);
const targetsCurrentDocument = Boolean(
(documentId && documentId === targetSession.documentId)
|| (!documentId && relativePath && relativeTarget && relativePath === relativeTarget)
);
if (!targetsCurrentDocument) return;
if (shouldSuppressLocalFolderSelfChange(targetSession.documentId, eventKind)) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
});
};
const ensureLocalFolderEventChannel = (session) => {
if (session.sourceKind !== 'local_folder' || !session.rootUri || typeof window.EventSource !== 'function') {
if (session.sourceKind !== 'local_folder' || !session.rootUri) {
return;
}
const eventBus = window.__mnoteLocalFolderEventBus;
if (eventBus && typeof eventBus.startLocalFolderWatcher === 'function') {
const channelKey = localFolderEventBusChannelKey(session);
let channel = localFolderEventRegistry.get(channelKey);
if (!channel) {
const rootUri = String(session.rootUri || '').trim();
const workspaceId = String(session.workspaceId || '').trim();
const handler = (event) => {
const detail = event?.detail || {};
const eventRootUri = String(detail.rootUri || detail.payload?.rootUri || '').trim();
if (eventRootUri && eventRootUri !== rootUri) return;
handleLocalFolderEventPayloadForSessions(channel, detail.payload || detail);
};
const handle = eventBus.startLocalFolderWatcher({
rootUri,
workspaceId,
bootstrap: {
schema: 'mnote.document_session.local_folder_event_bus.v1',
transport: 'local-folder-events',
workspaceId,
},
});
channel = {
key: channelKey,
rootUri,
documentId: '',
resourcePath: '',
eventSource: handle,
sessions: new Map(),
unsubscribe: () => window.removeEventListener('mnote:local-folder:document-changed', handler),
};
window.addEventListener('mnote:local-folder:document-changed', handler);
localFolderEventRegistry.set(channelKey, channel);
}
channel.sessions.set(session.key, session);
session.localFolderChannel = channel;
return;
}
if (typeof window.EventSource !== 'function') return;
const channelKey = localFolderEventChannelKey(session);
let channel = localFolderEventRegistry.get(channelKey);
if (!channel) {
@@ -1308,42 +1476,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
sessions: new Map(),
};
eventSource.addEventListener('change', (event) => {
const payload = parseLocalFolderEventPayload(event);
if (!payload) return;
Array.from(channel.sessions.values()).forEach((targetSession) => {
if (!targetSession || targetSession.views.size === 0) return;
const documentId = typeof payload.documentId === 'string' ? payload.documentId.trim() : '';
const relativePath = typeof payload.relativePath === 'string' ? payload.relativePath.trim() : '';
if (targetSession.sessionKind === 'resource') {
if (!relativePath || relativePath !== resourceWatchPath(targetSession)) return;
if (targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-resource-watch');
return;
}
if (!documentId) return;
const eventKind = String(payload.eventKind || '');
const targetsCurrentDocument = Boolean(documentId && documentId === targetSession.documentId);
if (documentId && !targetsCurrentDocument) return;
if (targetsCurrentDocument && shouldSuppressLocalFolderSelfChange(documentId, eventKind)) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
if (targetsCurrentDocument && targetSession.saving) {
targetSession.externalChangePending = false;
targetSession.lastSelfSaveSignalAt = Date.now();
return;
}
targetSession.lastExternalChangeSignalAt = Date.now();
targetSession.externalChangePending = true;
scheduleSessionExternalRefresh(targetSession, 'mnote-web-local-folder-watch');
});
handleLocalFolderEventPayloadForSessions(channel, parseLocalFolderEventPayload(event));
});
eventSource.onerror = () => {
console.warn('mnote local folder 外部更新事件流中断,将等待浏览器自动重连');
@@ -1609,7 +1742,7 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
const keyRevision = revisionFromConflictKey(conflictDetectionKey);
const sourceKind = normalizeSessionSourceKind(runtimeDescriptor.bootstrap);
const tiptapDocument = pageBodyTiptapDocument(pageBody, '', runtimeDescriptor.bootstrap);
const pageBodySource = pageBodyTiptapDocumentSource(pageBody, '');
const pageBodyMeta = pageBodySourceMeta(sourceKind, pageBody);
const session = {
key: buildDocumentSessionKey(runtimeDescriptor.bootstrap),
documentId: runtimeDescriptor.bootstrap.documentId,
@@ -1620,9 +1753,11 @@ export const createDocumentSessionRuntime = (dependencies = {}) => {
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
latestAggregate: runtimeDescriptor.aggregate,
title: runtimeDescriptor.aggregate.head?.title || '无标题',
pageBodySource,
projectionSource: String(pageBody.projectionSource || pageBody.projection_source || '').trim(),
blockProjectionVersion: String(pageBody.blockProjectionVersion || pageBody.block_projection_version || '').trim(),
pageBodySource: pageBodyMeta.pageBodySource,
projectionSource: pageBodyMeta.projectionSource,
blockProjectionVersion: pageBodyMeta.blockProjectionVersion,
pageBodyLocalCompatFallback: pageBodyMeta.localCompatFallback,
pageBodyHardGuard: pageBodyMeta.hardGuard,
currentTiptapDocument: tiptapDocument,
currentSerialized: JSON.stringify(tiptapDocument),
lastPersistedSerialized: JSON.stringify(tiptapDocument),
@@ -0,0 +1,355 @@
(function(){
'use strict';
if (window.__mnoteLocalFolderEventBus) {
return;
}
var connections = new Map();
var sidebarRefreshQueues = new Map();
var lastSource = '';
var lastReason = '';
function root() {
return document.documentElement;
}
function setDiagnostics(source, reason) {
lastSource = source || lastSource || '';
lastReason = reason || lastReason || '';
root().setAttribute('data-mnote-local-folder-event-bus', 'ready');
root().setAttribute('data-mnote-local-folder-event-bus-connections', String(connections.size));
root().setAttribute('data-mnote-local-folder-event-bus-last-source', lastSource);
root().setAttribute('data-mnote-local-folder-event-bus-last-reason', lastReason);
}
function normalizeRootUri(rootUri) {
return String(rootUri || '').trim();
}
function normalizeWorkspaceId(workspaceId) {
return String(workspaceId || 'default').trim() || 'default';
}
function emit(name, detail) {
window.dispatchEvent(new CustomEvent(name, { detail: detail || {} }));
}
function parseEventPayload(event) {
try {
return JSON.parse((event && event.data) || '{}') || {};
} catch (_) {
return {};
}
}
function revisionOf(payload, event) {
return payload.revision || payload.cursor || (event && event.lastEventId) || null;
}
function arrayOf(value) {
if (!value) return [];
if (Array.isArray(value)) return value.filter(Boolean).map(String);
return [String(value)].filter(Boolean);
}
function pathArrayOf(value) {
if (!value) return [];
var list = Array.isArray(value) ? value : [value];
return list.map(function(item) {
if (typeof item === 'string') return item.trim();
if (!item || typeof item !== 'object') return '';
return String(item.relativePath || item.relative_path || item.path || item.sourcePath || item.source_path || '').trim();
}).filter(Boolean);
}
function collectChangedPaths(payload) {
var paths = []
.concat(pathArrayOf(payload.changedPaths))
.concat(pathArrayOf(payload.changed_paths))
.concat(pathArrayOf(payload.paths));
var events = Array.isArray(payload.events) ? payload.events : [];
events.forEach(function(item) {
if (!item || typeof item !== 'object') return;
paths = paths
.concat(arrayOf(item.path))
.concat(arrayOf(item.relativePath))
.concat(arrayOf(item.relative_path))
.concat(arrayOf(item.sourcePath))
.concat(arrayOf(item.source_path));
});
return Array.from(new Set(paths));
}
function collectChangedPathItems(payload, reason) {
var raw = []
.concat(Array.isArray(payload.changedPaths) ? payload.changedPaths : [])
.concat(Array.isArray(payload.changed_paths) ? payload.changed_paths : [])
.concat(Array.isArray(payload.paths) ? payload.paths : [])
.concat(Array.isArray(payload.events) ? payload.events : []);
var seen = new Set();
var items = [];
raw.forEach(function(item) {
var relativePath = typeof item === 'string'
? item.trim()
: String(item && (item.relativePath || item.relative_path || item.path || item.sourcePath || item.source_path || '') || '').trim();
if (!relativePath || seen.has(relativePath)) return;
seen.add(relativePath);
items.push({
relativePath: relativePath,
reason: reason || 'event-bus',
kind: typeof item === 'object' && item ? String(item.kind || '') : '',
eventKind: typeof item === 'object' && item ? String(item.eventKind || item.event_kind || item.changeType || item.change_type || '') : ''
});
});
if (items.length) return items;
return pathItems(collectChangedPaths(payload), reason);
}
function collectAffectedParents(payload) {
var parents = []
.concat(pathArrayOf(payload.affectedParents))
.concat(pathArrayOf(payload.affected_parents))
.concat(pathArrayOf(payload.parentRelativePaths))
.concat(pathArrayOf(payload.parent_relative_paths));
return Array.from(new Set(parents));
}
function pathItems(paths, reason) {
return (paths || []).map(function(relativePath) {
return {
relativePath: String(relativePath || '').trim(),
reason: reason || 'event-bus'
};
}).filter(function(item) { return item.relativePath || item.relativePath === ''; });
}
function queueSidebarRefresh(detail) {
var key = String(detail.rootUri || '').trim();
if (!key) return;
var queue = sidebarRefreshQueues.get(key);
if (!queue) {
queue = {
rootUri: detail.rootUri,
workspaceId: detail.workspaceId,
revisions: new Set(),
reasons: new Set(),
changedPaths: new Map(),
affectedParents: new Set(),
resyncRequired: false,
timer: 0
};
sidebarRefreshQueues.set(key, queue);
}
if (detail.revision) queue.revisions.add(String(detail.revision));
if (detail.reason) queue.reasons.add(String(detail.reason));
(detail.changedPaths || []).forEach(function(item) {
var path = typeof item === 'string' ? item : String(item && (item.relativePath || item.relative_path || '') || '');
if (!path && path !== '') return;
var existing = queue.changedPaths.get(path) || { relativePath: path };
queue.changedPaths.set(path, {
relativePath: path,
reason: String((item && item.reason) || existing.reason || detail.reason || 'event-bus-orchestrated'),
kind: String((item && item.kind) || existing.kind || ''),
eventKind: String((item && item.eventKind) || (item && item.event_kind) || existing.eventKind || '')
});
});
(detail.affectedParents || []).forEach(function(item) {
var parent = typeof item === 'string' ? item : String(item && (item.relativePath || item.relative_path || '') || '');
if (parent || parent === '') queue.affectedParents.add(parent);
});
queue.resyncRequired = queue.resyncRequired || detail.resyncRequired === true;
if (!queue.timer) {
queue.timer = window.setTimeout(function() {
flushSidebarRefresh(key);
}, 0);
}
root().setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-pending', String(queue.affectedParents.size));
}
function flushSidebarRefresh(key) {
var queue = sidebarRefreshQueues.get(key);
if (!queue) return;
sidebarRefreshQueues.delete(key);
var reasons = Array.from(queue.reasons);
var detail = {
schema: 'mnote.local_folder.event_bus.sidebar_refresh.v1',
source: 'event_bus_orchestrator',
reason: reasons.join(',') || 'watch_batch',
rootUri: queue.rootUri,
workspaceId: queue.workspaceId,
revision: Array.from(queue.revisions).pop() || null,
reasons: reasons,
changedPaths: Array.from(queue.changedPaths.values()),
affectedParents: pathItems(Array.from(queue.affectedParents), 'event-bus-orchestrated'),
resyncRequired: queue.resyncRequired,
viaEventBus: true
};
root().setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-applied', detail.reason);
root().setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-parents', String(detail.affectedParents.length));
emit('mnote:local-folder:sidebar-refresh-requested', detail);
}
function dispatchWatchBatch(entry, event, payload, meta) {
var revision = revisionOf(payload, event);
var changedPaths = collectChangedPaths(payload);
var affectedParents = collectAffectedParents(payload);
var source = String((meta && meta.source) || payload.source || 'watcher_sse').trim() || 'watcher_sse';
var reason = String((meta && meta.reason) || payload.reason || 'watch_batch').trim() || 'watch_batch';
var resyncRequired = payload.fallbackResync === true
|| payload.requiresResync === true
|| payload.resyncRequired === true
|| payload.resync_required === true;
var detail = {
schema: 'mnote.local_folder.event_bus.watch_batch.v1',
source: source,
reason: reason,
rootUri: entry.rootUri,
workspaceId: entry.workspaceId,
revision: revision,
payload: payload,
bootstrap: entry.bootstrap || null,
changedPaths: collectChangedPathItems(payload, reason),
affectedParents: pathItems(affectedParents, reason),
resyncRequired: resyncRequired,
viaEventBus: true
};
setDiagnostics(source, reason);
root().setAttribute('data-mnote-tree-live-revision', String(revision || ''));
emit('mnote:local-folder:watch-batch', detail);
emit('tree:local-folder-watch-batch', detail);
if (affectedParents.length > 0) {
emit('mnote:local-folder:filetree-parent-changed', detail);
}
if (changedPaths.length > 0) {
emit('mnote:local-folder:document-changed', detail);
emit('mnote:local-folder:resource-changed', detail);
emit('mnote:local-folder:knowledge-rag-source-updated', detail);
}
queueSidebarRefresh(detail);
if (resyncRequired) {
emit('mnote:local-folder:resync-required', detail);
}
}
function closeEntry(entry) {
if (!entry || !entry.source || typeof entry.source.close !== 'function') return;
entry.source.close();
}
function startLocalFolderWatcher(options) {
var rootUri = normalizeRootUri(options && options.rootUri);
if (!rootUri || typeof window.EventSource !== 'function') return null;
var workspaceId = normalizeWorkspaceId(options && options.workspaceId);
var key = rootUri;
if (connections.has(key)) {
var existing = connections.get(key);
if ((!existing.workspaceId || existing.workspaceId === 'default') && workspaceId && workspaceId !== 'default') {
existing.workspaceId = workspaceId;
if (existing.handle) existing.handle.workspaceId = workspaceId;
}
setDiagnostics(existing.lastSource || 'watcher_sse', 'reuse_connection');
return existing.handle;
}
var url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', rootUri);
url.searchParams.set('treeLive', 'true');
var eventSource = new EventSource(url.toString());
var entry = {
key: key,
rootUri: rootUri,
workspaceId: workspaceId,
bootstrap: (options && options.bootstrap) || null,
source: eventSource,
lastSource: 'watcher_sse',
close: function() {
connections.delete(key);
closeEntry(entry);
setDiagnostics('watcher_sse', 'closed');
}
};
entry.handle = {
key: key,
rootUri: rootUri,
workspaceId: workspaceId,
source: eventSource,
close: entry.close
};
connections.set(key, entry);
setDiagnostics('watcher_sse', 'connect');
emit('mnote:local-folder:event-bus-ready', {
schema: 'mnote.local_folder.event_bus.ready.v1',
source: 'watcher_sse',
reason: 'connect',
rootUri: rootUri,
workspaceId: workspaceId,
connections: connections.size
});
eventSource.addEventListener('open', function() {
setDiagnostics('watcher_sse', 'open');
});
eventSource.addEventListener('watch_batch', function(event) {
dispatchWatchBatch(entry, event, parseEventPayload(event), {
source: 'watcher_sse',
reason: 'watch_batch'
});
});
eventSource.addEventListener('tree_error', function(event) {
var payload = parseEventPayload(event);
setDiagnostics('watcher_sse', 'tree_error');
emit('tree:error', { payload: payload, bootstrap: entry.bootstrap || null, viaEventBus: true });
});
eventSource.onerror = function() {
setDiagnostics('watcher_sse', 'error');
};
return entry.handle;
}
function emitSyntheticWatchBatch(detail) {
var payload = detail && detail.payload ? detail.payload : (detail || {});
var rootUri = normalizeRootUri(detail && detail.rootUri);
var workspaceId = normalizeWorkspaceId(detail && detail.workspaceId);
var entry = {
rootUri: rootUri,
workspaceId: workspaceId,
bootstrap: (detail && detail.bootstrap) || null
};
dispatchWatchBatch(entry, { lastEventId: detail && detail.revision }, payload, {
source: (detail && detail.source) || 'synthetic',
reason: (detail && detail.reason) || payload.source || 'synthetic_watch_batch'
});
}
function closeAll() {
Array.from(connections.values()).forEach(function(entry) {
closeEntry(entry);
});
connections.clear();
setDiagnostics('watcher_sse', 'closed');
}
window.__mnoteLocalFolderEventBus = {
startLocalFolderWatcher: startLocalFolderWatcher,
emitSyntheticWatchBatch: emitSyntheticWatchBatch,
closeAll: closeAll,
flushSidebarRefresh: function(rootUri) {
flushSidebarRefresh(normalizeRootUri(rootUri));
},
connectionCount: function() { return connections.size; },
diagnostics: function() {
return {
connections: connections.size,
lastSource: lastSource,
lastReason: lastReason
};
}
};
setDiagnostics('', 'ready');
})();
@@ -413,23 +413,8 @@ async function uploadLocalFolderAsset(file, plan, context) {
}
async function uploadMediaAsset(file, plan, context) {
var form = new FormData();
form.append('file', file);
form.append('workspaceId', plan && plan.workspaceId || '');
form.append('documentId', plan && plan.targetDocumentId || '');
if (plan && plan.targetMindmapId) form.append('mindmapId', plan.targetMindmapId);
var response = await fetchWithTimeout('/api/media/upload', {
method: 'POST',
credentials: 'include',
body: form
}, Number(context && context.timeoutMs) || 15000, '上传');
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload || !payload.asset) {
throw new Error(payload && payload.error ? payload.error : '上传失败');
}
return {
asset: payload.asset
};
document.documentElement.setAttribute('data-mnote-media-upload-retired', 'true');
throw new Error('旧 Convex Files 上传链已退役;local-first 附件请使用本地文件夹上传目标。');
}
async function insertUploadedAssetIntoEditor(asset, targetRoot, deps) {
@@ -749,23 +749,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
};
}
}
if (assetId) {
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
method: 'GET',
credentials: 'include',
cache: 'no-store'
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload) {
throw new Error(payload && payload.error ? payload.error : '生成签名链接失败');
}
var signedUrl = String(payload && payload.signedUrl || '').trim();
if (!signedUrl) throw new Error('附件链接不可用');
return {
url: signedUrl,
asset: payload.asset && typeof payload.asset === 'object' ? payload.asset : {}
};
}
if (assetId) document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
var url = String(detail && (detail.fileUrl || detail.href) || '').trim();
if (!url) throw new Error('附件链接不可用');
return { url: url, asset: {} };
@@ -855,20 +839,7 @@ export const createSidebarAttachmentOpenRuntime = (dependencies = {}) => {
return;
}
}
if (detail.assetId) {
try {
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(detail.assetId), {
method: 'GET',
credentials: 'include'
});
var payload = await response.json().catch(function() { return null; });
var signedUrl = String(payload && payload.signedUrl || '').trim();
if (response.ok && signedUrl) {
window.open(signedUrl, '_blank', 'noopener,noreferrer');
return;
}
} catch (_) {}
}
if (detail.assetId) document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
var localRelativePath = String(detail.localRelativePath || '').trim();
if (localRelativePath) {
var localPathDownloadUrl = buildLocalFileOpenUrl(localRelativePath, true);
@@ -380,11 +380,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
callback();
}
function isLocalOcrSourceFileName(fileName) {
return /\.(png|jpe?g|webp|gif|bmp|tiff?|pdf)$/i.test(String(fileName || '').trim());
}
function localOcrSourceRelativePath(detail) {
function knowledgeRagSourceRelativePath(detail) {
var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null;
var relativePath = String(
detail && detail.localRelativePath
@@ -395,7 +391,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return relativePath.replace(/^\/+/, '');
}
function localOcrRootUri(detail, trigger) {
function knowledgeRagRootUri(detail, trigger) {
var workspacePath = detail && detail.workspacePath && typeof detail.workspacePath === 'object' ? detail.workspacePath : null;
var rootUri = String(
detail && (detail.localRootUri || detail.rootUri)
@@ -409,39 +405,15 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
return rootUri || currentRootUri() || '';
}
function supportsLocalOcr(detail) {
var path = localOcrSourceRelativePath(detail);
var title = String(detail && (detail.title || detail.fileName) || '').trim() || path.split('/').pop() || '';
return Boolean(path && localOcrRootUri(detail, null) && isLocalOcrSourceFileName(title || path));
}
function localOcrProvider() {
var override = String(window.__MNOTE_LOCAL_OCR_PROVIDER || '').trim().toLowerCase();
return override === 'mock' ? 'mock' : 'mineru';
}
async function runLocalOcrForDetail(detail, trigger) {
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'running');
var payload = await ingestKnowledgeRagForDetail(detail, trigger).catch(function(error) {
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', 'failed');
throw error;
});
var sourceRootRelativePath = localOcrSourceRelativePath(detail)
|| String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim();
var rootUri = localOcrRootUri(detail, trigger);
var status = payload && payload.retryRequired ? 'retry' : 'done';
document.documentElement.setAttribute('data-mnote-local-ocr-menu-status', status);
document.documentElement.setAttribute('data-mnote-local-ocr-menu-path', sourceRootRelativePath);
window.dispatchEvent(new CustomEvent('mnote:local-ocr-job-updated', {
detail: { status: status, job: payload, rootUri: rootUri, sourceRootRelativePath: sourceRootRelativePath }
}));
return payload;
function supportsKnowledgeRagSource(detail) {
var path = knowledgeRagSourceRelativePath(detail);
return Boolean(path && knowledgeRagRootUri(detail, null));
}
async function ingestKnowledgeRagForDetail(detail, trigger) {
var sourceRootRelativePath = localOcrSourceRelativePath(detail)
var sourceRootRelativePath = knowledgeRagSourceRelativePath(detail)
|| String(detail && (detail.localRelativePath || detail.path || detail.title) || '').trim();
var rootUri = localOcrRootUri(detail, trigger);
var rootUri = knowledgeRagRootUri(detail, trigger);
if (!sourceRootRelativePath || !rootUri) {
throw new Error('缺少资料库来源或 rootUri');
}
@@ -473,17 +445,6 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
function handleTreeContextMenuAction(action, detail, trigger) {
closeTreeContextMenu();
detail = detail || {};
if (action === 'local-ocr') {
recordFileTreeAction('knowledge-rag-index', detail);
recordFileTreeActionStatus('pending', detail);
void runLocalOcrForDetail(detail, trigger).then(function(job) {
recordFileTreeActionStatus(job && job.retryRequired ? 'retry' : 'done', detail);
}).catch(function(error) {
recordFileTreeActionStatus('failed', Object.assign({}, detail, { fallback: 'alert' }));
window.alert(error && error.message ? error.message : '资料库索引失败');
});
return;
}
if (action === 'knowledge-rag-index') {
recordFileTreeAction('knowledge-rag-index', detail);
recordFileTreeActionStatus('pending', detail);
@@ -954,7 +915,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
menu.setAttribute('data-command-context-target-kind', String(ctx['tree.targetResourceKind'] || ''));
menu.setAttribute('data-command-context-editor-dirty', String(ctx['editor.dirty'] === true));
menu.setAttribute('data-command-context-ai-can-write', String(ctx['ai.canWrite'] === true));
var localOcrSupported = supportsLocalOcr(detail);
var knowledgeRagSupported = supportsKnowledgeRagSource(detail);
var items = isAttachment ? [
{ action: 'duplicate', icon: 'file_copy', label: '拷贝副本', shortcut: 'Ctrl + D' },
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly' },
@@ -1017,7 +978,7 @@ export const createSidebarFileTreeCommandRuntime = (dependencies = {}) => {
{ separator: true },
{ action: 'delete-trash', icon: 'delete', label: '删除', shortcut: 'Del', danger: true, destructive: true, requiresApproval: true, when: '!workspace.readonly && !editor.dirty' }
];
if (currentSourceKind() === 'local_folder' && detail.rowKind !== 'index') {
if (currentSourceKind() === 'local_folder' && detail.rowKind !== 'index' && knowledgeRagSupported) {
var ragItem = { action: 'knowledge-rag-index', icon: 'travel_explore', label: '加入资料库索引', when: '!workspace.readonly' };
var insertAt = isAttachment ? 11 : isAsset ? 3 : 3;
if (insertAt >= 0) items.splice(insertAt, 0, ragItem);
@@ -479,92 +479,8 @@ export const createSidebarFileTreeOpenRuntime = (dependencies = {}) => {
navigateToMindmapObject(documentId, assetId, String(detail.workspaceId || '').trim());
return;
}
try {
var response = await fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
method: 'GET',
credentials: 'include'
});
var payload = await response.json().catch(function() { return null; });
if (!response.ok) {
throw new Error(payload && payload.error ? payload.error : '生成签名链接失败');
}
var asset = payload && payload.asset && typeof payload.asset === 'object' ? payload.asset : {};
var fileUrl = String(payload && payload.signedUrl || asset.file_url || asset.signed_url || '').trim();
if (!fileUrl) throw new Error('附件链接不可用');
var fileName = String(asset.file_name || detail.fileName || '未命名资源').trim() || '未命名资源';
var fileType = inferOnlyOfficeFileType(fileName, asset.mime_type);
if (fileType) {
var userId = await fetchCurrentOnlyOfficeUserId();
var officeUrl = buildOnlyOfficeOpenUrl({
fileUrl: fileUrl,
fileName: fileName,
fileType: fileType,
assetId: assetId,
documentId: String(asset.document_id || detail.documentId || '').trim(),
userId: userId,
mode: forceEditMode ? 'edit' : 'view'
});
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
var didOpen = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: resourceObjectIdentityFromWorkspacePath({
workspacePath: detailWorkspacePath,
objectKind: 'only_office',
documentId: String(asset.document_id || detail.documentId || '').trim(),
assetId: assetId
}),
assetId: assetId,
title: fileName,
fileName: fileName,
kind: 'office',
officeUrl: officeUrl,
documentId: String(asset.document_id || detail.documentId || '').trim(),
workspaceId: String(detail.workspaceId || '').trim(),
workspacePath: detailWorkspacePath
});
if (didOpen) return;
}
window.open(officeUrl, '_blank', 'noopener,noreferrer');
return;
}
if (isPdfAttachmentFileName(fileName)) {
var pdfPreviewUrl = buildPdfPreviewOpenUrl(fileUrl, fileName);
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
var didOpenPdf = await window.__mnoteDocumentPaneRuntime.openResourceInActiveTab({
objectIdentity: resourceObjectIdentityFromWorkspacePath({
workspacePath: detailWorkspacePath,
objectKind: 'pdf',
documentId: String(asset.document_id || detail.documentId || '').trim(),
assetId: assetId
}),
assetId: assetId,
title: fileName,
fileName: fileName,
kind: 'pdf',
href: pdfPreviewUrl,
documentId: String(asset.document_id || detail.documentId || '').trim(),
workspaceId: String(detail.workspaceId || '').trim(),
workspacePath: detailWorkspacePath
});
if (didOpenPdf) return;
}
window.open(pdfPreviewUrl || fileUrl, '_blank', 'noopener,noreferrer');
return;
}
if (isCodeAttachmentFileName(fileName) && String(asset.document_id || detail.documentId || '').trim() === currentDocumentId()) {
await openCodeEditorAttachment({
href: fileUrl,
fileUrl: fileUrl,
fileName: fileName,
assetId: assetId,
documentId: String(asset.document_id || detail.documentId || '').trim(),
fileSize: uploadedFileSize(asset)
});
return;
}
window.open(fileUrl, '_blank', 'noopener,noreferrer');
} catch (error) {
window.alert(error && error.message ? error.message : '打开附件失败');
}
document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
window.alert('旧 Convex Files 附件签名链已退役;local-first 附件请通过本地文件夹资源打开。');
}
window.addEventListener('tree.asset.open', function(event) {
@@ -469,7 +469,6 @@ export function createSidebarPageAiRuntime(context) {
const pageAiWorkspacePathForTarget = (...args) => pageAiTargetRuntime.pageAiWorkspacePathForTarget(...args);
const pageAiBuildAllowedRoots = (...args) => pageAiTargetRuntime.pageAiBuildAllowedRoots(...args);
const pageAiBuildContextRefs = (...args) => pageAiTargetRuntime.pageAiBuildContextRefs(...args);
const pageAiEnrichOcrContextRefs = (...args) => pageAiTargetRuntime.pageAiEnrichOcrContextRefs(...args);
const pageAiBuildRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiBuildRunTargetSnapshot(...args);
const pageAiPageContextForRefs = (...args) => pageAiTargetRuntime.pageAiPageContextForRefs(...args);
const pageAiSetRunTargetSnapshot = (...args) => pageAiTargetRuntime.pageAiSetRunTargetSnapshot(...args);
@@ -1085,19 +1084,29 @@ export function createSidebarPageAiRuntime(context) {
return index === list.findIndex(function(candidate) { return candidate.relativePath === item.relativePath; });
});
if (changedPaths.length) {
var rootUri = String((receipt && receipt.rootUri) || (fallbackAudit && fallbackAudit.rootUri) || currentRootUri() || '');
var syntheticPayload = {
schema: 'mnote.local_folder.watch_batch.v1',
source: 'agent_run_receipt',
runId: runId,
rootUri: rootUri,
changedPaths: changedPaths,
affectedParents: affectedParents
};
document.documentElement.setAttribute('data-mnote-page-ai-receipt-filetree-refresh', 'true');
window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', {
detail: {
payload: {
schema: 'mnote.local_folder.watch_batch.v1',
source: 'agent_run_receipt',
runId: runId,
rootUri: String((receipt && receipt.rootUri) || (fallbackAudit && fallbackAudit.rootUri) || currentRootUri() || ''),
changedPaths: changedPaths,
affectedParents: affectedParents
}
}
}));
if (window.__mnoteLocalFolderEventBus && typeof window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch === 'function') {
window.__mnoteLocalFolderEventBus.emitSyntheticWatchBatch({
source: 'synthetic_page_ai_receipt',
reason: 'agent_run_receipt',
rootUri: rootUri,
workspaceId: resolveWorkspaceId(document.body),
payload: syntheticPayload
});
} else {
window.dispatchEvent(new CustomEvent('tree:local-folder-watch-batch', {
detail: { payload: syntheticPayload }
}));
}
}
}
if (refresh.touchesCurrentFile === true) {
@@ -1975,11 +1984,6 @@ export function createSidebarPageAiRuntime(context) {
var allowedRoots = pageAiBuildAllowedRoots();
var agentTargetPackage = pageAiBuildAgentTargetPackage(scopedContext, runTargetSnapshot);
assertPageAiLocalWritePermission(prompt, agentTargetPackage);
if (typeof pageAiEnrichOcrContextRefs === 'function') {
var ocrContext = await pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, scopedContext.editorTarget);
contextRefs = ocrContext.contextRefs || contextRefs;
agentTargetPackage = ocrContext.agentTargetPackage || agentTargetPackage;
}
if (scopedContext.pageContext && scopedContext.pageContext.aiContext) {
scopedContext.pageContext.aiContext.runTargetSnapshot = runTargetSnapshot;
scopedContext.pageContext.aiContext.agentTargetPackage = agentTargetPackage;
@@ -427,48 +427,6 @@ export function createSidebarPageAiTargetRuntime(context) {
});
}
function pageAiOcrEligibleResourceKind(value) {
var normalized = String(value || '').trim().toLowerCase();
return normalized === 'image' || normalized === 'pdf' || normalized === 'attachment' || normalized === 'resource';
}
function pageAiOcrEligiblePath(value) {
var path = String(value || '').trim().toLowerCase();
return /\.(png|jpg|jpeg|webp|bmp|tif|tiff|pdf)$/.test(path);
}
function pageAiOcrBodyPreview(markdown) {
var body = String(markdown || '').replace(/^---\n[\s\S]*?\n---\n?/, '').trim();
return body.slice(0, 1600);
}
async function fetchPageAiOcrSidecarContext(editorTarget) {
return null;
}
async function pageAiEnrichOcrContextRefs(contextRefs, agentTargetPackage, editorTarget) {
var ocrContext = await fetchPageAiOcrSidecarContext(editorTarget);
if (!ocrContext) return { contextRefs, agentTargetPackage };
var nextRefs = pageAiNormalizeArray(contextRefs).map(function(ref) {
if (ref && ref.kind === 'active_editor') return Object.assign({}, ref, { ocrContext: ocrContext });
return ref;
});
var nextPackage = agentTargetPackage && typeof agentTargetPackage === 'object'
? Object.assign({}, agentTargetPackage, { ocrContext: ocrContext })
: agentTargetPackage;
if (nextPackage && nextPackage.currentFile && typeof nextPackage.currentFile === 'object') {
nextPackage.currentFile = Object.assign({}, nextPackage.currentFile, { ocrRootRelativePath: ocrContext.ocrRootRelativePath });
}
if (nextPackage && Array.isArray(nextPackage.targets)) {
nextPackage.targets = nextPackage.targets.map(function(target, index) {
return index === 0 && target && typeof target === 'object'
? Object.assign({}, target, { ocrContext: ocrContext })
: target;
});
}
return { contextRefs: nextRefs, agentTargetPackage: nextPackage };
}
function pageAiBuildAllowedRoots() {
return pageAiNormalizeArray(pageUiState.pageAiAllowedRoots).map(function(root) {
return {
@@ -790,7 +748,6 @@ export function createSidebarPageAiTargetRuntime(context) {
pageAiBuildAgentTargetPackage,
pageAiBuildAllowedRoots,
pageAiBuildContextRefs,
pageAiEnrichOcrContextRefs,
pageAiBuildRunTargetSnapshot,
pageAiCloneJson,
pageAiContextKindsFromRefs,
@@ -67,10 +67,6 @@ export function createSidebarPageSettingsRuntime(context) {
return Object.assign({}, DEFAULT_PAGE_WIDTH_PREFERENCES, pageUiState.pageWidthPreferences || {});
}
function currentLocalOcrPreferences() {
return Object.assign({ 'localOcr.autoEnabled': false }, pageUiState.localOcrPreferences || {});
}
function currentKnowledgeRagSummary() {
return pageUiState.knowledgeRagSummary || {};
}
@@ -340,12 +336,6 @@ export function createSidebarPageSettingsRuntime(context) {
indexTrigger.setAttribute('data-state', indexOpen ? 'open' : 'closed');
indexTrigger.setAttribute('aria-expanded', indexOpen ? 'true' : 'false');
}
var ocrTrigger = document.querySelector('[data-testid="mnote-local-ocr-task-toggle"]');
var ocrOpen = isLocalOcrSettingsOpen();
if (ocrTrigger instanceof HTMLElement) {
ocrTrigger.setAttribute('data-state', ocrOpen ? 'open' : 'closed');
ocrTrigger.setAttribute('aria-expanded', ocrOpen ? 'true' : 'false');
}
var ragTrigger = document.querySelector('[data-testid="mnote-knowledge-rag-settings-toggle"]');
var ragOpen = isKnowledgeRagSettingsOpen();
if (ragTrigger instanceof HTMLElement) {
@@ -391,17 +381,6 @@ export function createSidebarPageSettingsRuntime(context) {
'</label>';
}
function createLocalOcrAutoRow() {
return '' +
'<label class="wolai-page-setting-row" data-page-setting-row="localOcrAutoEnabled">' +
'<span class="wolai-page-setting-copy">' +
'<span class="wolai-page-setting-label">资料库自动索引</span>' +
'<span class="wolai-page-setting-hint">LiteParse/OCR sidecar 已退役;图片、PDF、Office 统一由 LightRAG 资料库处理</span>' +
'</span>' +
'<input type="checkbox" class="wolai-page-setting-checkbox" data-local-ocr-option-checkbox="autoEnabled" />' +
'</label>';
}
function createPageWidthSelectRow(type) {
var options = type === 'default'
? [
@@ -456,11 +435,8 @@ export function createSidebarPageSettingsRuntime(context) {
});
}
function renderLocalOcrOptions(popover) {
var localOcrPreferences = currentLocalOcrPreferences();
popover.querySelectorAll('[data-local-ocr-option-checkbox="autoEnabled"]').forEach(function(input) {
input.checked = localOcrPreferences['localOcr.autoEnabled'] === true;
});
function markRetiredLocalOcrPreferenceSurface() {
document.documentElement.setAttribute('data-mnote-local-ocr-auto-retired', 'true');
}
function createPageFontRow() {
@@ -673,10 +649,6 @@ export function createSidebarPageSettingsRuntime(context) {
return popover;
}
function ensureLocalOcrSettingsPopover() {
return ensureKnowledgeRagSettingsPopover();
}
function createKnowledgeRagSettingsPanelHtml() {
return '' +
'<div class="wolai-page-settings-panel mnote-settings-panel mnote-knowledge-rag-settings-panel" role="dialog" aria-modal="false" aria-label="资料库问答设置">' +
@@ -1809,51 +1781,12 @@ export function createSidebarPageSettingsRuntime(context) {
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) return;
pageUiState.pageWidthPreferences = normalizePageWidthPreferences(payload.result && payload.result.pageWidthPreferences);
pageUiState.localOcrPreferences = Object.assign({ 'localOcr.autoEnabled': false }, payload.result && payload.result.localOcrPreferences || {});
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, pageUiState.localOcrPreferences);
document.documentElement.setAttribute('data-mnote-local-ocr-auto-enabled', String(pageUiState.localOcrPreferences['localOcr.autoEnabled'] === true));
markRetiredLocalOcrPreferenceSurface();
applyPageOptionsToShell();
if (isPageSettingsOpen()) renderPageSettingsPopover();
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
} catch (_) {}
}
async function persistLocalOcrAutoPreference(enabled) {
var previous = currentLocalOcrPreferences();
var next = Object.assign({}, previous, { 'localOcr.autoEnabled': Boolean(enabled) });
pageUiState.localOcrPreferences = next;
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, next);
if (isPageSettingsOpen()) renderPageSettingsPopover();
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
try {
var response = await fetch('/api/ui/preferences', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
documentId: currentDocumentId(),
workspaceId: resolveWorkspaceId(document.body),
...currentWorkspaceSourcePayload(),
updates: { 'localOcr.autoEnabled': Boolean(enabled) }
})
});
var payload = await response.json().catch(function(){ return null; });
if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload && payload.error && payload.error.message ? payload.error.message : 'local_ocr_preference_save_failed_' + response.status);
}
pageUiState.localOcrPreferences = Object.assign({ 'localOcr.autoEnabled': false }, payload.result && payload.result.localOcrPreferences || {});
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, pageUiState.localOcrPreferences);
document.documentElement.setAttribute('data-mnote-local-ocr-auto-enabled', String(pageUiState.localOcrPreferences['localOcr.autoEnabled'] === true));
if (isPageSettingsOpen()) renderPageSettingsPopover();
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
} catch (error) {
pageUiState.localOcrPreferences = previous;
window.__MNOTE_LOCAL_OCR_PREFERENCES = Object.assign({}, previous);
if (isPageSettingsOpen()) renderPageSettingsPopover();
if (isLocalOcrSettingsOpen()) renderLocalOcrOptions(ensureLocalOcrSettingsPopover());
document.documentElement.setAttribute('data-mnote-local-ocr-preference-error', error instanceof Error ? error.message : String(error));
}
}
async function persistPageWidthPreference(type, mode) {
if (PAGE_WIDTH_TYPES.indexOf(type) < 0) return;
var previous = pageUiState.pageWidthPreferences;
@@ -1908,24 +1841,18 @@ export function createSidebarPageSettingsRuntime(context) {
return popover instanceof HTMLElement && !popover.hidden;
}
function isLocalOcrSettingsOpen() {
var popover = document.querySelector('[data-testid="mnote-local-ocr-settings-popover"]');
return popover instanceof HTMLElement && !popover.hidden;
}
function isKnowledgeRagSettingsOpen() {
var popover = document.querySelector('[data-testid="mnote-knowledge-rag-settings-popover"]');
return popover instanceof HTMLElement && !popover.hidden;
}
function isAnySettingsOpen() {
return isPageSettingsOpen() || isLocalIndexSettingsOpen() || isLocalOcrSettingsOpen() || isKnowledgeRagSettingsOpen();
return isPageSettingsOpen() || isLocalIndexSettingsOpen() || isKnowledgeRagSettingsOpen();
}
function openPageSettingsPopover(initialTab) {
if (!currentDocumentId()) return;
closeLocalIndexSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
var popover = ensurePageSettingsPopover();
renderPageSettingsPopover();
@@ -1937,7 +1864,6 @@ export function createSidebarPageSettingsRuntime(context) {
function openPageIndexSettingsPopover() {
closePageSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
var popover = ensureLocalIndexSettingsPopover();
renderPageSettingsLocalIndex(popover);
@@ -1946,14 +1872,9 @@ export function createSidebarPageSettingsRuntime(context) {
updateStandaloneSettingsTriggerState();
}
function openLocalOcrSettingsPopover() {
openKnowledgeRagSettingsPopover();
}
function openKnowledgeRagSettingsPopover() {
closePageSettingsPopover();
closeLocalIndexSettingsPopover();
closeLocalOcrSettingsPopover();
var popover = ensureKnowledgeRagSettingsPopover();
renderKnowledgeRagSettings(popover);
popover.hidden = false;
@@ -1978,12 +1899,6 @@ export function createSidebarPageSettingsRuntime(context) {
updateStandaloneSettingsTriggerState();
}
function closeLocalOcrSettingsPopover() {
var popover = document.querySelector('[data-testid="mnote-local-ocr-settings-popover"]');
if (popover instanceof HTMLElement) popover.hidden = true;
updateStandaloneSettingsTriggerState();
}
function closeKnowledgeRagSettingsPopover() {
var popover = document.querySelector('[data-testid="mnote-knowledge-rag-settings-popover"]');
if (popover instanceof HTMLElement) popover.hidden = true;
@@ -1993,7 +1908,6 @@ export function createSidebarPageSettingsRuntime(context) {
function closeAllSettingsPopovers() {
closePageSettingsPopover();
closeLocalIndexSettingsPopover();
closeLocalOcrSettingsPopover();
closeKnowledgeRagSettingsPopover();
}
@@ -2006,10 +1920,6 @@ export function createSidebarPageSettingsRuntime(context) {
applyPageOptionsToShell();
});
window.addEventListener('mnote:open-local-ocr-settings', function() {
openLocalOcrSettingsPopover();
});
window.addEventListener('mnote:knowledge-rag-source-updated', function() {
if (isKnowledgeRagSettingsOpen()) void loadKnowledgeRagStatus(true);
});
@@ -2021,32 +1931,26 @@ export function createSidebarPageSettingsRuntime(context) {
closeKnowledgeRagSettingsPopover,
closePageHistoryDrawer,
closeLocalIndexSettingsPopover,
closeLocalOcrSettingsPopover,
closePageSettingsPopover,
closePageShareDialog,
currentLocalOcrPreferences,
currentKnowledgeRagSummary,
currentPageOptions,
ensureHistorySnapshotsSeeded,
ensureKnowledgeRagSettingsPopover,
ensureLocalIndexSettingsPopover,
ensureLocalOcrSettingsPopover,
ensurePageHistoryDrawer,
ensurePageSettingsPopover,
ensurePageShareDialog,
isAnySettingsOpen,
isKnowledgeRagSettingsOpen,
isLocalIndexSettingsOpen,
isLocalOcrSettingsOpen,
isPageSettingsOpen,
openKnowledgeRagSettingsPopover,
openPageHistoryDrawer,
openLocalOcrSettingsPopover,
openPageSettingsPopover,
openPageIndexSettingsPopover,
openPageShareDialog,
pageOptionIsSupported,
persistLocalOcrAutoPreference,
persistLocalIndexSettings,
persistPageOptionsPatch,
persistPageWidthPreference,
@@ -2004,7 +2004,17 @@ export const createSidebarTreeLiveApplyRuntime = (dependencies = {}) => {
setTreeLiveApplyError('tree_resync_missing_projection_payload');
});
window.addEventListener('mnote:local-folder:sidebar-refresh-requested', function(event) {
var detail = event.detail || {};
document.documentElement.setAttribute('data-mnote-local-folder-event-bus-sidebar-refresh-received', String(detail.reason || 'event-bus'));
applyLocalFolderWatchBatch(detail);
});
window.addEventListener('tree:local-folder-watch-batch', function(event) {
if (event.detail && event.detail.viaEventBus === true) {
document.documentElement.setAttribute('data-mnote-local-folder-watch-batch-skipped', 'event-bus-orchestrated');
return;
}
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail;
applyLocalFolderWatchBatch(payload);
});
@@ -209,7 +209,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const isPageSettingsOpen = (...args) => sidebarPageSettings.isPageSettingsOpen(...args);
const openPageHistoryDrawer = (...args) => sidebarPageSettings.openPageHistoryDrawer(...args);
const openPageIndexSettingsPopover = (...args) => sidebarPageSettings.openPageIndexSettingsPopover(...args);
const openLocalOcrSettingsPopover = (...args) => sidebarPageSettings.openLocalOcrSettingsPopover(...args);
const openKnowledgeRagSettingsPopover = (...args) => sidebarPageSettings.openKnowledgeRagSettingsPopover(...args);
const openPageShareDialog = (...args) => sidebarPageSettings.openPageShareDialog(...args);
const pageOptionIsSupported = (...args) => sidebarPageSettings.pageOptionIsSupported(...args);
@@ -223,7 +222,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
const pruneKnowledgeRagRegistry = (...args) => sidebarPageSettings.pruneKnowledgeRagRegistry(...args);
const setKnowledgeRagSourceFilter = (...args) => sidebarPageSettings.setKnowledgeRagSourceFilter(...args);
const useKnowledgeRagFileTreeSelection = (...args) => sidebarPageSettings.useKnowledgeRagFileTreeSelection(...args);
const persistLocalOcrAutoPreference = (...args) => sidebarPageSettings.persistLocalOcrAutoPreference(...args);
const persistLocalIndexSettings = (...args) => sidebarPageSettings.persistLocalIndexSettings(...args);
const persistPageOptionsPatch = (...args) => sidebarPageSettings.persistPageOptionsPatch(...args);
const persistPageWidthPreference = (...args) => sidebarPageSettings.persistPageWidthPreference(...args);
@@ -477,7 +475,7 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return title && title.textContent ? title.textContent.trim() : (fallback || '文件夹');
}
function isLocalOcrMarkdownPath(relativePath) {
function isRetiredOcrSidecarMarkdownPath(relativePath) {
var normalized = String(relativePath || '').trim().replace(/\\/g, '/').replace(/^\/+/, '');
return /(^|\/)[^/]+\.ocr\/[^/]+\.ocr\.md$/i.test(normalized);
}
@@ -1298,24 +1296,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
applyEditorAttachmentMeta(link, attachmentMetaCache[assetId]);
return;
}
attachmentMetaPending[assetId] = fetch('/api/media/sign?assetId=' + encodeURIComponent(assetId), {
method: 'GET',
credentials: 'include',
cache: 'no-store'
}).then(function(response) {
return response.json().catch(function() { return null; }).then(function(payload) {
if (!response.ok || !payload) return null;
var asset = payload.asset && typeof payload.asset === 'object' ? payload.asset : {};
var meta = {
assetId: assetId,
fileSize: uploadedFileSize(asset)
};
attachmentMetaCache[assetId] = meta;
return meta;
});
}).catch(function() {
return null;
}).finally(function() {
document.documentElement.setAttribute('data-mnote-media-sign-retired', 'true');
attachmentMetaPending[assetId] = Promise.resolve(null).finally(function() {
delete attachmentMetaPending[assetId];
});
try {
@@ -1899,20 +1881,8 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
throw new Error('上传失败');
}
} else {
var form = new FormData();
form.append('file', file);
form.append('workspaceId', plan.workspaceId);
form.append('documentId', plan.targetDocumentId);
if (plan.targetMindmapId) form.append('mindmapId', plan.targetMindmapId);
var response = await fetchWithTimeout('/api/media/upload', {
method: 'POST',
credentials: 'include',
body: form
}, 15000, '上传');
payload = await response.json().catch(function() { return null; });
if (!response.ok || !payload || !payload.asset) {
throw new Error(payload && payload.error ? payload.error : '上传失败');
}
document.documentElement.setAttribute('data-mnote-media-upload-retired', 'true');
throw new Error('旧 Convex Files 上传链已退役;local-first 附件请使用本地文件夹上传目标。');
}
appendUploadedAssetRow(payload.asset, plan.targetDocumentId);
if (options && options.insertIntoEditor) {
@@ -2695,20 +2665,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
return;
}
var ocrTaskTrigger = closestAction(e.target, '[data-mnote-action="toggle-ocr-tasks"]');
if (ocrTaskTrigger) {
e.preventDefault();
openKnowledgeRagSettingsPopover();
return;
}
var ocrSettingsTrigger = closestAction(e.target, '[data-mnote-action="open-ocr-settings"]');
if (ocrSettingsTrigger) {
e.preventDefault();
openKnowledgeRagSettingsPopover();
return;
}
var settingsClose = closestAction(e.target, '[data-settings-action="close"]');
if (settingsClose) {
e.preventDefault();
@@ -2750,17 +2706,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
}
}
var localOcrSettingsAction = closestAction(e.target, '[data-local-ocr-settings-action]');
if (localOcrSettingsAction) {
e.preventDefault();
var localOcrSettingsActionName = localOcrSettingsAction.getAttribute('data-local-ocr-settings-action') || '';
closeAllSettingsPopovers();
window.dispatchEvent(new CustomEvent('mnote:local-ocr-settings-action', {
detail: { action: localOcrSettingsActionName }
}));
return;
}
var knowledgeRagAction = closestAction(e.target, '[data-knowledge-rag-action]');
if (knowledgeRagAction) {
e.preventDefault();
@@ -3042,14 +2987,14 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
if (e.shiftKey || e.ctrlKey || e.metaKey) {
return;
}
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && isLocalOcrMarkdownPath(localRelativePath)) {
document.documentElement.setAttribute('data-mnote-local-ocr-filetree-open', 'resource-tab');
var ocrResourceInput = {
if ((rowKind === 'document' || rowKind === 'index' || rowKind === 'markdown') && isRetiredOcrSidecarMarkdownPath(localRelativePath)) {
document.documentElement.setAttribute('data-mnote-retired-ocr-sidecar-filetree-open', 'resource-tab');
var retiredOcrSidecarResourceInput = {
path: localRelativePath,
title: fileTreeRowTitleForShortcut(fileRow, localRelativePath),
kind: 'markdown',
objectIdentity: 'local-ocr:' + localRelativePath,
assetId: 'local-ocr:' + localRelativePath,
objectIdentity: 'retired-ocr-sidecar:' + localRelativePath,
assetId: 'retired-ocr-sidecar:' + localRelativePath,
documentId: documentId || ownerDocumentId || null,
workspaceId: resolveWorkspaceId(fileRow),
sourceKind: 'local_folder',
@@ -3059,9 +3004,9 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
paneRole: 'primary'
};
if (typeof window.__mnoteDocumentPaneRuntime?.openResourceInActiveTab === 'function') {
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab(ocrResourceInput);
void window.__mnoteDocumentPaneRuntime.openResourceInActiveTab(retiredOcrSidecarResourceInput);
} else {
void openLocalResourceInActiveTab(ocrResourceInput);
void openLocalResourceInActiveTab(retiredOcrSidecarResourceInput);
}
return;
}
@@ -3219,11 +3164,6 @@ import { createSidebarPageSettingsRuntime } from './sidebar-page-settings-runtim
renderPageSettingsPopover();
return;
}
var localOcrCheckbox = closestAction(event.target, '[data-local-ocr-option-checkbox="autoEnabled"]');
if (localOcrCheckbox instanceof HTMLInputElement) {
void persistLocalOcrAutoPreference(localOcrCheckbox.checked);
return;
}
var checkbox = closestAction(event.target, '[data-page-option-checkbox]');
if (checkbox instanceof HTMLInputElement) {
var key = checkbox.getAttribute('data-page-option-checkbox') || '';
@@ -194,6 +194,19 @@
var localRootUri = (params.get('rootUri') || '').trim()
|| (document.body instanceof HTMLElement ? (document.body.getAttribute('data-mnote-root-uri') || '').trim() : '');
if (localRootUri && 'EventSource' in window) {
var localBus = window.__mnoteLocalFolderEventBus;
if (localBus && typeof localBus.startLocalFolderWatcher === 'function') {
var localHandle = localBus.startLocalFolderWatcher({
rootUri: localRootUri,
workspaceId: bootstrap.workspaceId || resolveWorkspaceId(),
bootstrap: bootstrap
});
if (localHandle) {
window.__mnoteTreeLiveEventSource = localHandle;
applyStatus('connected');
return;
}
}
var url = new URL('/api/local-folder/events', window.location.origin);
url.searchParams.set('rootUri', localRootUri);
url.searchParams.set('treeLive', 'true');
+1 -7
View File
@@ -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 只过滤返回 referencesprovider 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();
+5 -12
View File
@@ -1087,7 +1087,7 @@ mod tests {
}
#[tokio::test]
async fn evidence_search_route_prefers_sqlite_index() {
async fn evidence_search_route_returns_retired_guard() {
let root = std::env::temp_dir().join(format!(
"mnote-evidence-route-sqlite-{}-{}",
std::process::id(),
@@ -1182,22 +1182,15 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::GONE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let first = payload["results"]
.as_array()
.and_then(|items| items.first())
.expect("sqlite evidence result");
assert_eq!(payload["ok"], false);
assert_eq!(
first["source"]["ownerDocumentPath"].as_str(),
Some("README.md")
);
assert_eq!(
first["source"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["code"].as_str(),
Some("mnote_evidence_search_retired")
);
fs::remove_dir_all(&root).ok();
}
+1 -1
View File
@@ -3646,7 +3646,7 @@ mod tests {
assert!(!html.contains(r#"data-testid="mnote-workspace-empty-state""#));
assert!(!html.contains("当前还没有可显示的本地工作区"));
assert!(!html.contains(r#"data-testid="mnote-empty-create-page""#));
assert!(html.contains(r#""transport":"disabled""#));
assert!(html.contains(r#""transport":"tree-live-ws""#));
let _ = std::fs::remove_dir_all(&base);
}
@@ -10608,7 +10608,7 @@ mod tests {
}
#[tokio::test]
async fn page_ai_capabilities_expose_local_index_and_toggle_tools() {
async fn page_ai_capabilities_expose_knowledge_rag_and_toggle_tools() {
let _env_guard = env_lock().lock().expect("env lock");
let hermes_home = std::env::temp_dir().join(format!(
"mnote-web-ai-capability-policy-{}",
@@ -13223,20 +13223,16 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["sessions"][0]["sessionId"], "sess_1");
assert_eq!(payload["persistence"], "convex_acp_runtime_store");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
let query_body = captured_body.lock().expect("captured convex body").clone();
assert_eq!(query_body["path"], "aiSessions:listRuntimeRuns");
assert_eq!(query_body["args"]["userId"], "user_1");
assert_eq!(query_body["args"]["workspaceId"], "ws_1");
assert_eq!(query_body["args"]["documentId"], "doc_1");
assert_eq!(query_body, Value::Null);
}
#[tokio::test]
@@ -13317,28 +13313,16 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["persistence"], "convex_acp_runtime_store");
assert_eq!(payload["session"]["sessionId"], "sess_1");
assert_eq!(payload["session"]["runs"][0]["runId"], "run_1");
assert_eq!(payload["runtime"]["runId"], "run_1");
assert_eq!(payload["events"][0]["eventType"], "message.delta");
assert_eq!(
payload["session"]["messages"].as_array().map(Vec::len),
Some(0)
);
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
let bodies = captured_bodies.lock().expect("captured convex bodies");
assert_eq!(bodies[0]["path"], "aiSessions:listRuntimeRuns");
assert_eq!(bodies[0]["args"]["userId"], "user_1");
assert_eq!(bodies[0]["args"]["sessionId"], "sess_1");
assert_eq!(bodies[1]["path"], "aiSessions:listRuntimeEvents");
assert_eq!(bodies[1]["args"]["runId"], "run_1");
assert!(bodies.is_empty());
}
#[tokio::test]
@@ -13403,15 +13387,13 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["resumed"], true);
assert_eq!(payload["resumeSource"], "convex_acp_runtime_store");
assert_eq!(payload["session"]["runs"][0]["runId"], "run_1");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
}
#[tokio::test]
@@ -13486,7 +13468,7 @@ mod tests {
}
#[tokio::test]
async fn hermes_client_acp_session_search_returns_convex_snippets() {
async fn hermes_client_acp_session_search_legacy_convex_returns_retired_guard() {
let captured_body = Arc::new(Mutex::new(Value::Null));
let captured_for_route = Arc::clone(&captured_body);
let mock = axum::Router::new().route(
@@ -13548,21 +13530,16 @@ mod tests {
)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["ok"], true);
assert_eq!(payload["results"][0]["sessionId"], "sess_1");
assert_eq!(payload["results"][0]["snippet"], "帮我总结化学页面");
assert_eq!(payload["ok"], false);
assert_eq!(payload["code"], "convex_retired");
let query_body = captured_body.lock().expect("captured convex body").clone();
assert_eq!(query_body["path"], "aiSessions:searchRuntimeSessions");
assert_eq!(query_body["args"]["userId"], "user_1");
assert_eq!(query_body["args"]["workspaceId"], "ws_1");
assert_eq!(query_body["args"]["q"], "化学");
assert_eq!(query_body["args"]["limit"], 5);
assert_eq!(query_body, Value::Null);
}
#[tokio::test]
@@ -14520,13 +14497,40 @@ mod tests {
&"/api/hermes/client/runs".parse().expect("uri"),
&headers,
);
let root = std::env::temp_dir().join(format!(
"mnote-local-agent-run-receipt-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&root).expect("create root");
std::fs::write(root.join("README.md"), "# Readme\nold\n").expect("write readme");
std::fs::write(root.join("Other.md"), "# Other\nold\n").expect("write other");
let root_uri = format!("file://{}", root.display());
let payload = json!({
"workspaceId": "local-workspace-1",
"documentId": "local-md:README.md",
"sessionId": "sess_local_1",
"rootUri": "file:///tmp/mnote-agent-run-receipt",
"actorId": "user_1"
"rootUri": root_uri,
"actorId": "user_1",
"contextRefs": [{
"kind": "folder",
"rootUri": root_uri,
"relativePath": ""
}],
"targetPackage": {
"schema": "mnote.agent_target_package.v1",
"allowedFiles": ["README.md"],
"currentFile": {
"relativePath": "README.md"
}
}
});
let before = local_agent_audit_collect_snapshot_for_payload(&payload, None)
.expect("before allowed-files snapshot");
std::fs::write(root.join("README.md"), "# Readme\nnew\n").expect("modify readme");
std::fs::write(root.join("Other.md"), "# Other\nnew\n").expect("modify other");
let after = local_agent_audit_collect_snapshot_for_payload(&payload, Some(&before))
.expect("after allowed-files snapshot");
let changed_files = json!([
{
"path": "README.md",
@@ -14541,8 +14545,8 @@ mod tests {
"reasonix",
"completed",
changed_files,
None,
None,
Some(&before),
Some(&after),
false,
);
let receipt = &event["agentRunReceipt"];
@@ -14553,6 +14557,11 @@ mod tests {
assert_eq!(receipt["status"], "completed");
assert_eq!(receipt["changedFiles"][0]["path"], "README.md");
assert_eq!(receipt["refresh"]["touchesCurrentFile"], true);
assert_eq!(event["auditScope"]["scope"], "allowed_files");
assert_eq!(event["auditScope"]["fileCount"], 1);
assert_eq!(receipt["auditScope"]["scope"], "allowed_files");
assert_eq!(receipt["auditScope"]["fileCount"], 1);
let _ = std::fs::remove_dir_all(root);
}
#[test]
@@ -15066,7 +15075,7 @@ mod tests {
"/api/hermes/client/profile-memory?profile=chemist",
None,
),
("GET", "/api/hermes/client/skills?profile=chemist", None),
("GET", "/api/hermes/client/skills", None),
(
"PUT",
"/api/hermes/client/profiles/active",
@@ -15107,7 +15116,7 @@ mod tests {
);
let request = Request::builder()
.method("GET")
.uri("/api/hermes/client/skills?profile=chemist")
.uri("/api/hermes/client/skills")
.header("x-mnote-actor-id", "user_1")
.body(Body::empty())
.expect("request");
@@ -3099,7 +3099,7 @@ mod tests {
assert!(markdown_edit["description"]
.as_str()
.expect("description")
.contains("兼容"));
.contains("compat"));
assert!(markdown_edit["description"]
.as_str()
.expect("description")
@@ -6432,7 +6432,7 @@ mod tests {
"toolCallId": "call_1",
"traceId": "trace_1",
"idempotencyKey": "idem_markdown_normalized_1",
"dryRun": false,
"dryRun": true,
"args": {
"operations": [{"search": "第二 段", "replace": "测试123"}]
}
@@ -6444,25 +6444,23 @@ mod tests {
.await
.expect("response");
assert_eq!(response.status(), StatusCode::OK);
let status = response.status();
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body));
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["result"]["operationsApplied"], 1);
// 7-27: 新路径 changedBlocks 格式验证
let changed = payload["result"]["applyResult"]["changedBlocks"]
let changed = payload["result"]["applyResult"]["diff"]
.as_array()
.expect("changedBlocks");
assert!(!changed.is_empty(), "changedBlocks should not be empty");
.expect("diff");
assert!(!changed.is_empty(), "diff should not be empty");
assert_eq!(
payload["result"]["applyResult"]["changedBlocks"][0]["blockId"],
payload["result"]["applyResult"]["diff"][0]["blockId"],
"p_2"
);
assert_eq!(
payload["result"]["applyResult"]["changedBlocks"][0]["op"],
"replace"
);
assert_eq!(payload["result"]["applyResult"]["diff"][0]["op"], "replace");
}
#[tokio::test]
+274 -27
View File
@@ -24,6 +24,7 @@ const DEFAULT_LIGHTRAG_ENDPOINT: &str = "http://127.0.0.1:9621";
const DEFAULT_LIGHTRAG_INPUT_DIR: &str = "/mnt/Data1T/Mnote_data/lightrag/inputs";
const DEFAULT_LIGHTRAG_WORKING_DIR: &str = "/mnt/Data1T/Mnote_data/lightrag/rag_storage";
const MAX_INGEST_SOURCES_PER_REQUEST: usize = 200;
const SOURCE_SCOPE_MODE_POST_FILTER: &str = "post_filter_mapped_references";
const KNOWLEDGE_RAG_SOURCE_EXTENSIONS: &[&str] = &[
"md", "markdown", "txt", "pdf", "doc", "docx", "ppt", "pptx", "xls", "xlsx", "csv", "png",
"jpg", "jpeg", "webp", "gif", "bmp", "tif", "tiff",
@@ -154,6 +155,8 @@ pub(crate) fn knowledge_rag_source_statuses(
}
if provider_status == "failed" {
statuses.failed_paths.insert(path.to_string());
} else if provider_status == "delete_retry_required" {
statuses.failed_paths.insert(path.to_string());
} else if provider_status == "delete_submitted"
|| (entry.deleted_at_ms.is_some() && entry.light_rag_doc_id.is_some())
{
@@ -473,6 +476,8 @@ pub async fn query_rag(
"schema": "mnote.knowledge_rag.query_result.v1",
"provider": "lightrag",
"sourceScope": source_scope,
"sourceScopeMode": SOURCE_SCOPE_MODE_POST_FILTER,
"rawScopeFiltered": false,
"raw": raw,
"references": references,
})))
@@ -735,7 +740,13 @@ pub async fn prune_registry(
}
fn knowledge_rag_registry_entry_prunable(entry: &KnowledgeRagSourceRegistryEntry) -> bool {
if entry.light_rag_status.as_deref() == Some("delete_submitted") {
if matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
) {
return false;
}
if entry.light_rag_doc_id.is_some() && (entry.deleted_at_ms.is_some() || entry.stale) {
return false;
}
entry.deleted_at_ms.is_some()
@@ -746,6 +757,23 @@ fn knowledge_rag_registry_entry_prunable(entry: &KnowledgeRagSourceRegistryEntry
)
}
fn knowledge_rag_provider_delete_confirmed(entry: &KnowledgeRagSourceRegistryEntry) -> bool {
entry.light_rag_doc_id.is_some()
&& (entry.deleted_at_ms.is_some()
|| entry.stale
|| matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
))
}
fn mark_registry_entry_delete_completed(entry: &mut KnowledgeRagSourceRegistryEntry, now: u128) {
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_completed".into());
entry.updated_at_ms = now;
}
async fn sync_registry_with_documents(
root_path: &Path,
registry: &mut KnowledgeRagSourceRegistry,
@@ -758,22 +786,28 @@ async fn sync_registry_with_documents(
let by_file_path = lightrag_documents_by_file_path(&docs);
let now = now_ms();
let mut changed = false;
let mut retry_doc_ids = Vec::new();
for entry in &mut registry.entries {
if let Some(doc) = document_for_registry_entry(&by_file_path, entry) {
if let Some(id) = doc.get("id").and_then(Value::as_str) {
entry.light_rag_doc_id = Some(id.to_string());
}
if let Some(status) = doc.get("status").and_then(Value::as_str) {
entry.light_rag_status = Some(
if entry.deleted_at_ms.is_some() {
"delete_submitted"
} else {
status
}
.to_string(),
);
let delete_pending = matches!(
entry.light_rag_status.as_deref(),
Some("delete_submitted" | "delete_retry_required")
) || entry.deleted_at_ms.is_some();
if delete_pending {
if let Some(doc_id) = entry.light_rag_doc_id.clone() {
retry_doc_ids.push(doc_id);
}
if entry.light_rag_status.is_none() {
entry.light_rag_status = Some("delete_submitted".into());
}
} else if let Some(status) = doc.get("status").and_then(Value::as_str) {
entry.light_rag_status = Some(status.to_string());
}
if entry.deleted_at_ms.is_none()
if !entry.stale
&& entry.deleted_at_ms.is_none()
&& doc.get("status").and_then(Value::as_str) == Some("processed")
{
entry.indexed_at_ms.get_or_insert(now);
@@ -781,11 +815,8 @@ async fn sync_registry_with_documents(
}
entry.updated_at_ms = now;
changed = true;
} else if entry.deleted_at_ms.is_some() && entry.light_rag_doc_id.is_some() {
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_completed".into());
entry.updated_at_ms = now;
} else if knowledge_rag_provider_delete_confirmed(entry) {
mark_registry_entry_delete_completed(entry, now);
changed = true;
} else if entry.deleted_at_ms.is_some()
&& entry.light_rag_doc_id.is_none()
@@ -801,9 +832,12 @@ async fn sync_registry_with_documents(
changed = true;
}
}
let stale_doc_ids = sync_registry_source_state(registry, now)?;
let mut stale_doc_ids = sync_registry_source_state(registry, now)?;
stale_doc_ids.extend(retry_doc_ids);
stale_doc_ids.sort();
stale_doc_ids.dedup();
if !stale_doc_ids.is_empty() {
let _ = lightrag_json(
let delete_result = lightrag_json(
reqwest::Method::DELETE,
"/documents/delete_document",
Some(json!({
@@ -815,6 +849,23 @@ async fn sync_registry_with_documents(
context,
)
.await;
for entry in &mut registry.entries {
if entry
.light_rag_doc_id
.as_deref()
.is_some_and(|doc_id| stale_doc_ids.iter().any(|item| item == doc_id))
{
entry.light_rag_status = Some(
if delete_result.is_err() {
"delete_retry_required"
} else {
"delete_submitted"
}
.into(),
);
entry.updated_at_ms = now;
}
}
changed = true;
}
if changed {
@@ -897,8 +948,8 @@ fn sync_registry_source_state(
if !source_path.exists() {
entry.stale = true;
entry.deleted_at_ms = Some(now);
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_submitted".into());
entry.updated_at_ms = now;
stale_doc_ids.push(doc_id);
continue;
@@ -907,8 +958,8 @@ fn sync_registry_source_state(
if current_hash != entry.source_hash {
entry.stale = true;
entry.source_hash = current_hash;
entry.light_rag_doc_id = None;
entry.indexed_at_ms = None;
entry.light_rag_status = Some("delete_submitted".into());
entry.updated_at_ms = now;
stale_doc_ids.push(doc_id);
}
@@ -989,6 +1040,10 @@ fn mapped_references(
.get("deleted")
.and_then(Value::as_bool)
.unwrap_or(false)
&& !reference
.get("unmapped")
.and_then(Value::as_bool)
.unwrap_or(false)
})
.collect()
}
@@ -1127,6 +1182,7 @@ fn map_reference_plan(
"sourceId": entry.map(|entry| entry.source_id.clone()),
"sourcePath": source_path,
"sourceRootRelativePath": source_root_relative_path,
"unmapped": entry.is_none(),
"stale": entry.is_some_and(|entry| entry.stale),
"deleted": entry.is_some_and(|entry| entry.deleted_at_ms.is_some()),
"locatorDegraded": locator_degraded,
@@ -1346,15 +1402,11 @@ fn find_lightrag_sidecar_block(
}
let path = sidecar_blocks_path(entry)?;
let content = fs::read_to_string(path).ok()?;
let mut first_positioned_block = None;
for line in content.lines() {
let block = serde_json::from_str::<Value>(line).ok()?;
if block.get("positions").and_then(Value::as_array).is_none() {
continue;
}
if first_positioned_block.is_none() {
first_positioned_block = Some(block.clone());
}
let block_text = block
.get("content")
.and_then(Value::as_str)
@@ -1369,7 +1421,7 @@ fn find_lightrag_sidecar_block(
return Some(block);
}
}
first_positioned_block
None
}
fn normalize_text_for_match(value: &str) -> String {
@@ -2234,17 +2286,85 @@ mod tests {
let stale_doc_ids = sync_registry_source_state(&mut registry, 42).expect("sync");
assert_eq!(stale_doc_ids, vec!["doc-changed", "doc-missing"]);
assert_eq!(registry.entries[0].deleted_at_ms, Some(42));
assert_eq!(registry.entries[0].light_rag_doc_id, None);
assert_eq!(
registry.entries[0].light_rag_doc_id.as_deref(),
Some("doc-missing")
);
assert_eq!(
registry.entries[0].light_rag_status.as_deref(),
Some("delete_submitted")
);
assert_eq!(registry.entries[0].indexed_at_ms, None);
assert!(registry.entries[0].stale);
assert_eq!(registry.entries[1].deleted_at_ms, None);
assert_eq!(registry.entries[1].light_rag_doc_id, None);
assert_eq!(
registry.entries[1].light_rag_doc_id.as_deref(),
Some("doc-changed")
);
assert_eq!(
registry.entries[1].light_rag_status.as_deref(),
Some("delete_submitted")
);
assert_eq!(registry.entries[1].indexed_at_ms, None);
assert!(registry.entries[1].stale);
assert_ne!(registry.entries[1].source_hash, "mnote-fnv64:old");
let _ = fs::remove_dir_all(root);
}
#[test]
fn provider_delete_confirmation_clears_doc_id_for_deleted_or_stale_entries() {
let root = temp_root("mnote-knowledge-rag-delete-confirmed");
let mut deleted = test_registry_entry(
&root,
"deleted.pdf",
Some("doc-deleted"),
Some("delete_submitted"),
Some(2),
Some(3),
true,
);
let mut changed = test_registry_entry(
&root,
"changed.pdf",
Some("doc-changed"),
Some("delete_submitted"),
Some(2),
None,
true,
);
let active = test_registry_entry(
&root,
"active.pdf",
Some("doc-active"),
Some("processed"),
Some(2),
None,
false,
);
assert!(knowledge_rag_provider_delete_confirmed(&deleted));
mark_registry_entry_delete_completed(&mut deleted, 42);
assert_eq!(deleted.light_rag_doc_id, None);
assert_eq!(deleted.indexed_at_ms, None);
assert_eq!(
deleted.light_rag_status.as_deref(),
Some("delete_completed")
);
assert!(knowledge_rag_provider_delete_confirmed(&changed));
mark_registry_entry_delete_completed(&mut changed, 43);
assert_eq!(changed.light_rag_doc_id, None);
assert_eq!(
changed.light_rag_status.as_deref(),
Some("delete_completed")
);
assert!(!knowledge_rag_provider_delete_confirmed(&active));
assert_eq!(active.light_rag_doc_id.as_deref(), Some("doc-active"));
let _ = fs::remove_dir_all(root);
}
#[test]
fn source_statuses_distinguish_indexed_processing_failed_and_deleted() {
let root = temp_root("mnote-knowledge-rag-source-statuses");
@@ -2372,9 +2492,29 @@ mod tests {
);
let failed =
test_registry_entry(&root, "failed.pdf", None, Some("failed"), None, None, false);
let retry = test_registry_entry(
&root,
"retry.pdf",
Some("doc-retry"),
Some("delete_retry_required"),
Some(2),
Some(3),
true,
);
let stale_with_doc = test_registry_entry(
&root,
"stale.pdf",
Some("doc-stale"),
Some("processed"),
Some(2),
None,
true,
);
assert!(!knowledge_rag_registry_entry_prunable(&active));
assert!(!knowledge_rag_registry_entry_prunable(&deleting));
assert!(!knowledge_rag_registry_entry_prunable(&retry));
assert!(!knowledge_rag_registry_entry_prunable(&stale_with_doc));
assert!(knowledge_rag_registry_entry_prunable(&removed));
assert!(knowledge_rag_registry_entry_prunable(&failed));
@@ -2466,6 +2606,42 @@ mod tests {
);
}
#[test]
fn mapped_references_filters_unmapped_provider_references() {
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
entries: vec![],
};
let raw = json!({
"data": {
"references": [{"reference_id":"1","file_path":"orphan.pdf"}],
"chunks": [{
"reference_id":"1",
"chunk_id":"orphan-chunk",
"file_path":"orphan.pdf",
"content":"orphan provider chunk"
}]
}
});
let mapped = mapped_references(&raw, &registry, "file:///tmp/root", Path::new("/tmp/root"));
assert!(
mapped.is_empty(),
"unmapped provider references must not become MNote citations"
);
let plan = map_reference_plan(
&json!({"file_path":"orphan.pdf","chunk_id":"orphan-chunk"}),
&registry,
"file:///tmp/root",
Path::new("/tmp/root"),
);
assert_eq!(plan["unmapped"], true);
assert_eq!(plan["locatorDegraded"], true);
}
#[test]
fn query_ranking_prefers_exact_source_and_quote_match() {
let mut references = vec![
@@ -2597,6 +2773,77 @@ mod tests {
let _ = fs::remove_dir_all(root);
}
#[test]
fn locator_degrades_when_sidecar_quote_does_not_match() {
let _guard = env_lock().lock().expect("env lock");
let root = temp_root("mnote-knowledge-rag-sidecar-no-match");
fs::create_dir_all(root.join("docs")).expect("docs");
fs::write(root.join("docs").join("Host.md"), "# Host\n").expect("host");
fs::write(root.join("docs").join("scan.pdf"), b"pdf").expect("pdf");
let input_dir = root.join("inputs");
let parsed_dir = input_dir
.join("__parsed__")
.join("mnote-hash-scan.pdf.parsed");
fs::create_dir_all(&parsed_dir).expect("parsed dir");
fs::write(
parsed_dir.join("mnote-hash-scan.blocks.jsonl"),
[
r#"{"type":"meta","blocks":1}"#,
r##"{"type":"content","blockid":"block1","content":"This block is not the returned quote.","positions":[{"type":"bbox","anchor":"9","range":[1.0,2.0,3.0,4.0]}]}"##,
]
.join("\n"),
)
.expect("blocks");
std::env::set_var("MNOTE_LIGHTRAG_INPUT_DIR", &input_dir);
let registry = KnowledgeRagSourceRegistry {
schema: REGISTRY_SCHEMA.to_string(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
updated_at_ms: 1,
entries: vec![KnowledgeRagSourceRegistryEntry {
source_id: "src1".into(),
workspace_id: "ws".into(),
root_uri: "file:///tmp/root".into(),
source_path: root.join("docs").join("scan.pdf").display().to_string(),
source_root_relative_path: "docs/scan.pdf".into(),
source_hash: "mnote-fnv64:1".into(),
light_rag_doc_id: Some("doc1".into()),
light_rag_status: Some("processed".into()),
light_rag_file_path: "mnote-hash-scan.pdf".into(),
symlink_path: input_dir.join("mnote-hash-scan.pdf").display().to_string(),
parser_hint: None,
indexed_at_ms: Some(2),
deleted_at_ms: None,
stale: false,
updated_at_ms: 2,
}],
};
let mapped = map_reference_plan(
&json!({
"file_path": "mnote-hash-scan.pdf",
"chunk_id": "doc1-chunk-000",
"chunks": [{"chunk_id": "doc1-chunk-000", "content": "A different quote should not get page or bbox."}]
}),
&registry,
"file:///tmp/root",
&root,
);
assert!(mapped["locator"].is_null());
assert_eq!(mapped["locatorDegraded"], true);
assert!(mapped["citationUrl"]
.as_str()
.is_some_and(|url| url.contains("resourceTab=")));
assert!(mapped["citationMarkdown"]
.as_str()
.unwrap()
.contains("来源定位降级"));
std::env::remove_var("MNOTE_LIGHTRAG_INPUT_DIR");
let _ = fs::remove_dir_all(root);
}
#[test]
fn lightrag_paths_prefer_source_env_over_legacy_process_env() {
let _guard = env_lock().lock().expect("env lock");
@@ -23,7 +23,7 @@ use std::hash::{Hash, Hasher};
use std::path::PathBuf;
use std::pin::Pin;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use tokio::sync::broadcast::{error::RecvError, Receiver};
use tokio::sync::broadcast::error::RecvError;
use tokio::time::timeout;
type BoxedEventStream =
@@ -85,7 +85,6 @@ async fn build_document_events_stream(
.local_folder_watcher_registry()
.subscribe(&canonical_root)
.map_err(|error| WebError::internal(error).with_context(&context))?;
let local_ocr_job_rx = state.local_ocr_job_tx.subscribe();
let initial = json!({
"sourceKind": "local_folder",
@@ -95,79 +94,29 @@ async fn build_document_events_stream(
"revision": system_time_ms(SystemTime::now()),
});
let stream = stream::unfold(
(
Some(initial),
subscription,
document_relative_path,
query.root_uri.clone(),
local_ocr_job_rx,
),
|(
initial,
mut subscription,
document_relative_path,
root_uri,
mut local_ocr_job_rx,
)| async move {
(Some(initial), subscription, document_relative_path),
|(initial, mut subscription, document_relative_path)| async move {
if let Some(payload) = initial {
return Some((
Ok(stream_event("ready", &payload)),
(
None,
subscription,
document_relative_path,
root_uri,
local_ocr_job_rx,
),
(None, subscription, document_relative_path),
));
}
loop {
tokio::select! {
watcher_result = subscription.receiver.recv() => {
match watcher_result {
Ok(payload) => {
if let Some(expected) = document_relative_path.as_deref() {
if !document_event_targets_relative_path(&payload, expected) {
continue;
}
}
return Some((
Ok(stream_event("change", &payload)),
(
None,
subscription,
document_relative_path,
root_uri,
local_ocr_job_rx,
),
));
match subscription.receiver.recv().await {
Ok(payload) => {
if let Some(expected) = document_relative_path.as_deref() {
if !document_event_targets_relative_path(&payload, expected) {
continue;
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
ocr_result = recv_matching_ocr_event(&mut local_ocr_job_rx, &root_uri) => {
match ocr_result {
Some(payload) => {
if let Some(expected) = document_relative_path.as_deref() {
if !document_event_targets_relative_path(&payload, expected) {
continue;
}
}
return Some((
Ok(stream_event("local_ocr.job.updated", &payload)),
(
None,
subscription,
document_relative_path,
root_uri,
local_ocr_job_rx,
),
));
}
None => continue,
}
return Some((
Ok(stream_event("change", &payload)),
(None, subscription, document_relative_path),
));
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
},
@@ -177,20 +126,6 @@ async fn build_document_events_stream(
Ok((HeaderMap::new(), stream))
}
async fn recv_matching_ocr_event(rx: &mut Receiver<Value>, root_uri: &str) -> Option<Value> {
loop {
match rx.recv().await {
Ok(payload) => {
if payload.get("rootUri").and_then(Value::as_str) == Some(root_uri) {
return Some(payload);
}
}
Err(RecvError::Lagged(_)) => continue,
Err(RecvError::Closed) => return None,
}
}
}
/// Build the tree live stream: emits `snapshot` (initial) and `resync` (on watcher change)
/// with full sidebar + file tree projections.
///
File diff suppressed because it is too large Load Diff
@@ -1217,11 +1217,8 @@ pub(crate) fn refresh_local_search_index_for_path_with_settings(
if local_ocr::is_local_ocr_sidecar_relative_path(&relative_path) {
index.built_at = now_ms();
write_local_search_index_json(root_path, &index)?;
if included {
refresh_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
} else {
remove_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
}
let _ = included;
remove_evidence_sqlite_index_for_path(root_path, &index, &relative_path)?;
return Ok(json!({
"version": index.version,
"rootUri": index.root_uri,
@@ -1948,13 +1945,6 @@ fn index_relative_path_is_included(relative_path: &str, include_paths: &[String]
})
}
pub(crate) fn local_index_relative_path_is_included(
relative_path: &str,
settings: &LocalIndexSettings,
) -> bool {
index_relative_path_is_included(relative_path, &settings.include_paths)
}
fn collect_markdown_documents(
root_path: &Path,
current: &Path,
@@ -2613,84 +2603,6 @@ fn parsed_resource_id(resource: &LocalSearchResource) -> String {
format!("{}#parse", resource.resource_id)
}
#[allow(dead_code)]
fn insert_ocr_evidence(
connection: &Connection,
root_path: &Path,
index: &LocalSearchIndex,
entry: &local_ocr::OcrIndexEntry,
) -> Result<(), WebError> {
if entry.status != "done" {
return Ok(());
}
let ocr_path = root_path.join(&entry.ocr_root_relative_path);
let Ok(markdown) = fs::read_to_string(&ocr_path) else {
return Ok(());
};
let body = local_ocr::strip_ocr_frontmatter(&markdown);
let source_map = path_source_map_path(&entry.ocr_root_relative_path).unwrap_or_default();
let resource_id = format!(
"{}#ocr:{}",
entry.owner_document_id, entry.source_root_relative_path
);
let artifact = ocr_parsed_artifact(entry, &source_map);
if !source_map.is_empty() {
let source_map_path = root_path.join(&source_map);
if let Ok(source_map_content) = fs::read_to_string(&source_map_path) {
if let Ok(resource_source_map) =
serde_json::from_str::<ResourceSourceMap>(&source_map_content)
{
return insert_source_map_artifact_evidence(
connection,
index,
&resource_id,
&artifact,
&resource_source_map,
body,
);
}
}
}
insert_evidence_resource_from_artifact(connection, &resource_id, &artifact)?;
let locator = json!({
"schema": "mnote.evidence_locator.v1",
"rootUri": index.root_uri,
"ownerDocumentId": entry.owner_document_id,
"ownerDocumentPath": entry.owner_document_path,
"resourcePath": entry.source_root_relative_path,
"resourceKind": evidence_resource_kind_for_path(&entry.source_root_relative_path),
"sourceMapPath": source_map,
"openAction": {
"actionType": "mnote.open_resource_locator",
"url": format!("/documents/{}?sourceKind=local_folder&rootUri={}", entry.owner_document_id, encode_query_component(&index.root_uri)),
"params": {
"resourcePath": entry.source_root_relative_path,
"sourceMapPath": source_map
}
}
});
insert_evidence_block(connection, &resource_id, &resource_id, body, locator)
}
#[allow(dead_code)]
fn ocr_parsed_artifact(
entry: &local_ocr::OcrIndexEntry,
source_map_root_relative_path: &str,
) -> ParsedResourceArtifact {
ParsedResourceArtifact {
schema: PARSED_RESOURCE_ARTIFACT_SCHEMA.into(),
provider: entry.provider.clone(),
model_version: Some(entry.model_version.clone()),
owner_document_id: entry.owner_document_id.clone(),
owner_document_path: entry.owner_document_path.clone(),
source_root_relative_path: entry.source_root_relative_path.clone(),
source_hash: format!("size:{}:mtime:{}", entry.source_size, entry.source_mtime_ms),
artifact_root_relative_path: entry.ocr_root_relative_path.clone(),
source_map_root_relative_path: source_map_root_relative_path.to_string(),
updated_at_ms: entry.updated_at_ms as u64,
}
}
fn insert_source_map_artifact_evidence(
connection: &Connection,
index: &LocalSearchIndex,
@@ -3479,35 +3391,6 @@ fn local_search_resource_matches(
}
}
#[allow(dead_code)]
fn local_search_ocr_matches(
entry: &local_ocr::OcrIndexEntry,
body: &str,
query: &str,
title_only: bool,
exact: bool,
) -> bool {
if query.is_empty() {
return false;
}
let haystack = if title_only {
normalize_search_text(&format!(
"{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path
))
} else {
normalize_search_text(&format!(
"{}\n{}\n{}",
entry.source_root_relative_path, entry.ocr_root_relative_path, body
))
};
if exact {
haystack.contains(query)
} else {
token_search_match(&haystack, query)
}
}
fn local_search_document_projection(
document: &LocalSearchDocument,
root_uri: &str,
@@ -3557,43 +3440,6 @@ fn local_search_resource_projection(resource: &LocalSearchResource, root_uri: &s
})
}
#[allow(dead_code)]
fn local_search_ocr_projection(
entry: &local_ocr::OcrIndexEntry,
body: &str,
root_uri: &str,
query: &str,
) -> Value {
let title = Path::new(&entry.owner_document_path)
.file_stem()
.and_then(|value| value.to_str())
.unwrap_or("OCR")
.to_string();
json!({
"id": format!("{}#ocr:{}", entry.owner_document_id, entry.source_root_relative_path),
"documentId": entry.owner_document_id,
"title": title,
"path": entry.owner_document_path,
"resourceType": "markdown",
"sourceKind": "local_folder",
"rootUri": root_uri,
"hasOcr": true,
"snippet": ocr_search_snippet(body, query),
"ocrEvidence": {
"sourceRootRelativePath": entry.source_root_relative_path,
"ocrRootRelativePath": entry.ocr_root_relative_path,
"provider": entry.provider,
"status": entry.status,
},
"updatedAt": entry.updated_at_ms,
"publicPath": format!(
"/documents/{}?sourceKind=local_folder&rootUri={}",
entry.owner_document_id,
encode_query_component(root_uri),
)
})
}
fn encode_query_component(value: &str) -> String {
let mut encoded = String::with_capacity(value.len());
for byte in value.as_bytes() {
@@ -5143,6 +4989,10 @@ mod tests {
.documents
.iter()
.any(|document| document.path == "docs/Page.ocr/photo.png-704905.ocr.md"));
let refreshed_evidence = query_evidence_sqlite_results(&root, "OCR-hash-token", None, 10)
.expect("query refreshed evidence")
.unwrap_or_default();
assert!(refreshed_evidence.is_empty());
let _ = fs::remove_dir_all(&root);
}
@@ -5157,6 +5007,8 @@ mod tests {
"---\ntitle: Child\n---\n# Child\nOriginalToken body.\n",
)
.expect("write child");
write_local_index_settings(&root, &[String::from(".")], None, None, None, None)
.expect("settings");
let first_projection = query_local_search_index(
&root,
@@ -5421,7 +5273,7 @@ mod tests {
#[test]
#[cfg(unix)]
fn evidence_index_parses_resource_body_with_liteparse_sidecar() {
fn evidence_index_does_not_parse_resource_body_with_retired_liteparse_sidecar() {
use std::os::unix::fs::PermissionsExt;
let _guard = env_lock().lock().expect("env lock");
@@ -5479,23 +5331,11 @@ JSON
let results = query_evidence_sqlite_results(&root, "ResourceBodyToken", None, 10)
.expect("sqlite query")
.expect("sqlite exists");
let hit = results
.iter()
.find(|result| result.quote.contains("ResourceBodyToken"))
.expect("parsed resource body hit");
assert_eq!(
hit.source.owner_document_id, "local-md:docs~2FPage.md",
"资源正文证据应归属引用它的 owner Markdown"
);
assert_eq!(
hit.source.resource_path.as_deref(),
Some("docs/Page.assets/spec.pdf")
);
assert_eq!(hit.source.page, Some(2));
assert!(hit.source.bbox.is_some());
assert_eq!(
hit.source.source_map_path.as_deref(),
Some("docs/Page.ocr/spec.pdf.source-map.json")
assert!(
!results
.iter()
.any(|result| result.quote.contains("ResourceBodyToken")),
"LiteParse resource body fallback is retired from active evidence indexing"
);
let resource_scoped = query_evidence_sqlite_results_with_mode(
&root,
@@ -5506,18 +5346,16 @@ JSON
)
.expect("resource scoped sqlite query")
.expect("sqlite exists");
assert_eq!(resource_scoped.len(), 1);
assert_eq!(
resource_scoped[0].source.resource_path.as_deref(),
Some("docs/Page.assets/spec.pdf"),
"页面内搜索打开资源时应只限制到当前资源,而不是它的 owner Markdown"
assert!(
resource_scoped.is_empty(),
"retired LiteParse sidecar must not create resource-scoped evidence hits"
);
assert!(root
assert!(!root
.join("docs")
.join("Page.ocr")
.join("spec.pdf.parse.md")
.exists());
assert!(root
assert!(!root
.join("docs")
.join("Page.ocr")
.join("spec.pdf.source-map.json")
+33 -430
View File
@@ -1,28 +1,11 @@
use crate::app::AppState;
use crate::context::RequestContext;
use crate::error::WebError;
use crate::routes::command_support::{build_runtime_command_plan, runtime_context};
use crate::transport::convex::{
execute_retired_mutation_by_name, execute_retired_query_by_name,
persist_runtime_command_artifacts,
};
use axum::extract::{Multipart, Query, State};
use axum::http::{header, HeaderMap};
use axum::extract::Query;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use axum::{Extension, Json};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use bridge_runtime::{
build_runtime_command_artifact_plan, RuntimeActorWire, RuntimeCommandEnvelopeWire,
RuntimeSourceWire, RuntimeTargetWire,
};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
static UPLOAD_COUNTER: AtomicU64 = AtomicU64::new(1);
use serde_json::json;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -69,242 +52,6 @@ pub struct FileTreeUploadTargetPlan {
target_sub_path: Option<String>,
}
#[derive(Debug)]
struct UploadFile {
name: String,
content_type: String,
bytes: Vec<u8>,
}
async fn current_user_id(state: &AppState, context: &RequestContext) -> String {
let actor_id = context.auth.actor_id.trim();
if !actor_id.is_empty() && actor_id != "anonymous" {
return actor_id.to_string();
}
if let Ok(user) = execute_retired_query_by_name(
state.config(),
context,
"users:currentUser",
json!({}),
context.workspace.workspace_id.as_deref(),
"media_current_user",
)
.await
{
for key in ["_id", "id"] {
if let Some(user_id) = user
.get(key)
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
{
return user_id.to_string();
}
}
}
state.config().dev_user_id.clone()
}
fn now_millis() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
fn new_asset_id() -> String {
format!(
"asset_{}_{}",
now_millis(),
UPLOAD_COUNTER.fetch_add(1, Ordering::Relaxed)
)
}
fn now_iso_like() -> String {
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".into())
}
fn asset_type(mime: &str) -> &'static str {
if mime.starts_with("image/") {
"image"
} else if mime.starts_with("video/") {
"video"
} else if mime.starts_with("audio/") {
"audio"
} else {
"file"
}
}
async fn record_upload_artifacts(
state: &AppState,
context: &RequestContext,
user_id: &str,
workspace_id: &str,
document_id: &str,
asset_id: &str,
file: &UploadFile,
asset_kind: &str,
target_sub_path: Option<&str>,
created: &Value,
) -> Result<(), WebError> {
let command = RuntimeCommandEnvelopeWire {
name: "tree.resource.upload".into(),
command_id: format!("resource_upload_{}", context.trace.request_id),
idempotency_key: context.source.idempotency_key.clone(),
actor: RuntimeActorWire {
actor_type: context.auth.actor_type.clone(),
actor_id: user_id.to_string(),
session_id: context.auth.session_id.clone(),
},
source: RuntimeSourceWire {
channel: context.source.channel.clone(),
client: context.source.client.clone(),
source_kind: None,
root_uri: None,
workspace_id: Some(workspace_id.to_string()),
capabilities: Vec::new(),
},
target: Some(RuntimeTargetWire {
workspace_id: Some(workspace_id.to_string()),
page_id: Some(document_id.to_string()),
block_id: Some(asset_id.to_string()),
}),
payload: json!({
"assetId": asset_id,
"workspaceId": workspace_id,
"targetDocumentId": document_id,
"targetSubPath": target_sub_path,
"fileName": file.name,
"fileSize": file.bytes.len(),
"mimeType": file.content_type,
"assetType": asset_kind,
}),
preflight_data: None,
reason: Some("mnote-web media upload tree.resource.upload".into()),
refs: vec!["file-tree-resource-upload".into()],
dry_run: false,
validate_only: false,
};
let runtime_context = runtime_context(context, Some(workspace_id));
let plan = build_runtime_command_plan(context, Some(workspace_id), command.clone())?;
let artifact_result = json!({
"items": [created.clone()],
});
if let Some(artifacts) = build_runtime_command_artifact_plan(
&runtime_context,
&command,
&plan,
&artifact_result,
&now_iso_like(),
) {
persist_runtime_command_artifacts(state.config(), context, &artifacts).await?;
}
Ok(())
}
async fn read_upload_multipart(
mut multipart: Multipart,
) -> Result<(UploadFile, String, String, Option<String>), WebError> {
let mut file: Option<UploadFile> = None;
let mut workspace_id = String::new();
let mut document_id = String::new();
let mut mindmap_id: Option<String> = None;
while let Some(field) = multipart.next_field().await.map_err(|error| {
WebError::bad_request_code(
"media_upload_bad_multipart",
format!("上传表单解析失败: {error}"),
)
})? {
let name = field.name().unwrap_or_default().to_string();
if name == "file" {
let file_name = field
.file_name()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("附件")
.to_string();
let content_type = field
.content_type()
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("application/octet-stream")
.to_string();
let bytes = field
.bytes()
.await
.map_err(|error| {
WebError::bad_request_code(
"media_upload_file_read_failed",
format!("读取上传文件失败: {error}"),
)
})?
.to_vec();
file = Some(UploadFile {
name: file_name,
content_type,
bytes,
});
continue;
}
let value = field.text().await.map_err(|error| {
WebError::bad_request_code(
"media_upload_field_read_failed",
format!("读取上传字段失败: {error}"),
)
})?;
match name.as_str() {
"workspaceId" => workspace_id = value.trim().to_string(),
"documentId" => document_id = value.trim().to_string(),
"mindmapId" => {
let trimmed = value.trim();
if !trimmed.is_empty() {
mindmap_id = Some(trimmed.to_string());
}
}
_ => {}
}
}
let file =
file.ok_or_else(|| WebError::bad_request_code("media_upload_file_missing", "缺少 file"))?;
if file.bytes.is_empty() || workspace_id.is_empty() || document_id.is_empty() {
return Err(WebError::bad_request_code(
"media_upload_required_missing",
"缺少必要参数",
));
}
Ok((file, workspace_id, document_id, mindmap_id))
}
fn absolute_origin(headers: &HeaderMap) -> String {
let proto = headers
.get("x-forwarded-proto")
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("http");
let host = headers
.get(header::HOST)
.and_then(|value| value.to_str().ok())
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or("127.0.0.1:3000");
format!("{proto}://{host}")
}
fn proxied_file_url(headers: &HeaderMap, raw: &str) -> String {
let encoded = URL_SAFE_NO_PAD.encode(raw.as_bytes());
format!(
"{}/api/onlyoffice/proxy?u={encoded}",
absolute_origin(headers)
)
}
fn trim_string(value: Option<&String>) -> Option<String> {
value
.map(String::as_str)
@@ -353,128 +100,8 @@ fn document_for_target_row(
None
}
pub async fn upload(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
multipart: Multipart,
) -> Result<Response, WebError> {
let (file, workspace_id, document_id, mindmap_id) = read_upload_multipart(multipart).await?;
let user_id = current_user_id(&state, &context).await;
let upload_url = execute_retired_mutation_by_name(
state.config(),
&context,
"mediaAssets:generateUploadUrl",
json!({ "userId": user_id }),
Some(&workspace_id),
None,
"media_upload_generate_url",
)
.await?;
let upload_url = upload_url.as_str().ok_or_else(|| {
WebError::bad_gateway_code("media_upload_bad_upload_url", "Convex 未返回上传 URL")
})?;
let client = reqwest::Client::new();
let upload_response = client
.post(upload_url)
.header(header::CONTENT_TYPE, file.content_type.as_str())
.body(file.bytes.clone())
.send()
.await
.map_err(|error| {
WebError::bad_gateway_code(
"media_upload_storage_failed",
format!("上传到 Convex Files 失败: {error}"),
)
})?;
let upload_status = upload_response.status();
let upload_json: Value = upload_response.json().await.map_err(|error| {
WebError::bad_gateway_code(
"media_upload_storage_bad_response",
format!("Convex Files 响应解析失败: {error}"),
)
.with_header("x-upstream-status", upload_status.as_u16().to_string())
})?;
if !upload_status.is_success() {
return Err(WebError::bad_gateway_code(
"media_upload_storage_status",
format!("上传到 Convex Files 失败: {upload_json}"),
)
.with_header("x-upstream-status", upload_status.as_u16().to_string()));
}
let storage_id = upload_json
.get("storageId")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_gateway_code(
"media_upload_storage_id_missing",
"Convex Files 缺少 storageId",
)
})?;
let target_sub_path = mindmap_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(|value| format!("mindmaps/{value}"));
let id = new_asset_id();
let kind = asset_type(&file.content_type);
let created = execute_retired_mutation_by_name(
state.config(),
&context,
"mediaAssets:createWithStorage",
json!({
"userId": user_id,
"storageId": storage_id,
"targetSubPath": target_sub_path,
"asset": {
"id": id,
"workspace_id": workspace_id,
"document_id": document_id,
"asset_type": kind,
"file_name": file.name,
"file_size": file.bytes.len(),
"mime_type": file.content_type,
}
}),
Some(&workspace_id),
None,
"media_upload_create_asset",
)
.await?;
let asset_id = created
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if let Err(error) = record_upload_artifacts(
&state,
&context,
&user_id,
&workspace_id,
&document_id,
&asset_id,
&file,
&kind,
target_sub_path.as_deref(),
&created,
)
.await
{
tracing::warn!(
error = %error.message(),
asset_id = %asset_id,
"media upload tree.resource.upload artifacts 记录失败,主上传结果继续返回"
);
}
Ok(Json(json!({
"asset": created,
"mindmapUrl": format!("asset:{asset_id}"),
}))
.into_response())
pub async fn upload(Extension(context): Extension<RequestContext>) -> Response {
retired_convex_media_response(&context, "upload", None)
}
pub async fn filetree_upload_target_preflight(
@@ -529,63 +156,39 @@ pub async fn filetree_upload_target_preflight(
}
pub async fn sign(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<MediaSignQuery>,
headers: HeaderMap,
) -> Result<Response, WebError> {
) -> Response {
let asset_id = query
.asset_id
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WebError::bad_request_code("media_sign_asset_missing", "缺少 assetId"))?;
let user_id = current_user_id(&state, &context).await;
let asset = execute_retired_query_by_name(
state.config(),
&context,
"mediaAssets:getById",
json!({ "userId": user_id, "id": asset_id }),
None,
"media_sign_get_asset",
)
.await?;
if asset.is_null() {
return Err(WebError::new(
axum::http::StatusCode::NOT_FOUND,
"media_asset_not_found",
"资源不存在",
));
}
let refreshed = execute_retired_mutation_by_name(
state.config(),
&context,
"mediaAssets:refreshUrl",
json!({ "userId": user_id, "id": asset_id }),
None,
None,
"media_sign_refresh_url",
.filter(|value| !value.is_empty());
retired_convex_media_response(&context, "sign", asset_id)
}
fn retired_convex_media_response(
context: &RequestContext,
operation: &str,
asset_id: Option<&str>,
) -> Response {
(
StatusCode::GONE,
Json(json!({
"ok": false,
"code": "mnote_media_convex_retired",
"error": "旧 Convex Files media route 已退役",
"message": "旧 /api/media Convex Files 上传与签名链已退役;local-first 附件请使用 /api/local-folder/assets/upload 与 /api/local-folder/files/open。",
"operation": operation,
"assetId": asset_id,
"replacement": {
"upload": "/api/local-folder/assets/upload",
"open": "/api/local-folder/files/open",
"preflight": "/api/tree/filetree/upload-target-preflight"
},
"requestId": context.trace.request_id,
"traceId": context.trace.trace_id,
})),
)
.await?;
let signed_url = refreshed
.get("signedUrl")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| WebError::bad_gateway_code("media_sign_url_missing", "生成签名链接失败"))?;
Ok(Json(json!({
"signedUrl": proxied_file_url(&headers, signed_url),
"asset": {
"id": asset.get("id").cloned().unwrap_or(Value::Null),
"document_id": asset.get("document_id").cloned().unwrap_or(Value::Null),
"workspace_id": asset.get("workspace_id").cloned().unwrap_or(Value::Null),
"file_name": asset.get("file_name").cloned().unwrap_or(Value::Null),
"mime_type": asset.get("mime_type").cloned().unwrap_or(Value::Null),
"file_size": asset.get("file_size").cloned().unwrap_or(Value::Null),
"storage_id": asset.get("storage_id").cloned().unwrap_or(Value::Null),
"updated_at": asset.get("updated_at").cloned().unwrap_or(Value::Null),
}
}))
.into_response())
.into_response()
}
+43 -1
View File
@@ -252,6 +252,10 @@ pub fn build_router(state: AppState) -> Router {
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
get(web_shell::sidebar_tree_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
get(web_shell::local_folder_event_bus_runtime_asset),
)
.route(
"/api/mnote-browser-runtime/tree-live-controller.js",
get(web_shell::tree_live_controller_runtime_asset),
@@ -723,6 +727,7 @@ mod tests {
use crate::context::RequestContext;
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use axum::Extension;
use axum::Router;
use serde_json::{json, Value};
use std::fs;
@@ -848,6 +853,38 @@ mod tests {
}
}
#[tokio::test]
async fn legacy_media_routes_return_retired_guard() {
for (method, path, operation) in [
("POST", "/api/media/upload", "upload"),
("GET", "/api/media/sign?assetId=asset_1", "sign"),
] {
let request = Request::builder()
.method(method)
.uri(path)
.body(Body::empty())
.expect("request");
let context =
RequestContext::from_http_parts(request.method(), request.uri(), request.headers());
let response = app(false)
.layer(Extension(context))
.oneshot(request)
.await
.expect("response");
assert_eq!(response.status(), StatusCode::GONE);
let body = to_bytes(response.into_body(), usize::MAX)
.await
.expect("body bytes");
let payload: Value = serde_json::from_slice(&body).expect("json body");
assert_eq!(payload["code"], "mnote_media_convex_retired");
assert_eq!(payload["operation"], operation);
assert_eq!(
payload["replacement"]["upload"],
"/api/local-folder/assets/upload"
);
}
}
#[tokio::test]
async fn office_preview_page_serves_lightweight_viewer_shell() {
let response = app(false)
@@ -1144,8 +1181,12 @@ mod tests {
);
assert_eq!(
alice_payload["result"]["localOcrPreferences"]["localOcr.autoEnabled"],
true
false
);
assert!(alice_payload["result"]["sources"]
.as_object()
.and_then(|sources| sources.get("localOcr.autoEnabled"))
.is_none());
let mut bob_get = Request::builder()
.uri(format!("/api/ui/preferences/effective?workspaceId=workspace-ai-a&sourceKind=local_folder&rootUri={root_uri}&documentId=local-md:ai.md"))
@@ -1201,6 +1242,7 @@ mod tests {
"/api/mnote-browser-runtime/sidebar-page-ai-target-runtime.js",
"/api/mnote-browser-runtime/sidebar-page-settings-runtime.js",
"/api/mnote-browser-runtime/sidebar-tree-runtime.js",
"/api/mnote-browser-runtime/local-folder-event-bus-runtime.js",
"/api/mnote-browser-runtime/tree-live-controller.js",
"/api/mnote-browser-runtime/tree-shell-runtime.js",
"/api/mnote-browser-runtime/tree-shell-render-runtime.js",
@@ -224,7 +224,7 @@ async fn record_media_empty_trash_artifacts(
let plan = RuntimeCommandExecutionPlan {
command_name: command.name.clone(),
command_id: command.command_id.clone(),
function_name: "mediaAssets:emptyTrashByWorkspace".into(),
function_name: command.name.clone(),
workspace_id: Some(workspace_id.to_string()),
request_id: context.trace.request_id.clone(),
trace_id: context.trace.trace_id.clone(),
+130 -307
View File
@@ -7,16 +7,15 @@ use crate::routes::query_support::{
resolve_effective_workspace_id,
};
use crate::routes::web_shell::load_sidebar_tree_html;
use crate::routes::{evidence, local_folder_source, local_search_index};
use crate::routes::{local_folder_source, local_search_index};
use crate::ssr::pages::search::SearchPage;
use axum::Json;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::{Html, IntoResponse, Response};
use axum::Json;
use bridge_runtime::RuntimeQueryEnvelopeWire;
use core_protocol::{EvidenceSearchMode, EvidenceSearchResult};
use serde::Deserialize;
use serde_json::{Value, json};
use serde_json::{json, Value};
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_QUERY_NAME: &str = "x-query-name";
@@ -183,100 +182,60 @@ pub async fn documents(
None
};
let (result, evidence_results) =
if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let user_settings = resolve_local_index_user_settings(
&state,
&context,
&effective_workspace_id,
&root_path,
)?;
let effective_settings = local_search_index::effective_local_index_settings_for_root(
state.control_plane(),
&effective_workspace_id,
&root_path,
)?;
let result = local_search_index::query_local_search_index_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&effective_settings,
&user_settings,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
filters.include_ocr.unwrap_or(false),
)?;
let evidence_results = evidence::evidence_results_from_local_search(
&result,
&root_path,
root_uri,
EvidenceSearchMode::Hybrid,
&normalized_query,
);
let limit = body.limit.unwrap_or(30).max(1) as usize;
let direct_evidence_results = if !filters.title_only.unwrap_or(false) {
local_search_index::query_evidence_sqlite_results_with_mode(
&root_path,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.exact.unwrap_or(false),
)?
.unwrap_or_default()
.into_iter()
.filter(|evidence| {
let path = evidence
.source
.resource_path
.as_deref()
.unwrap_or(evidence.source.owner_document_path.as_str());
local_search_index::local_index_relative_path_is_included(path, &user_settings)
})
.collect::<Vec<_>>()
} else {
Vec::new()
};
let (result, evidence_results) = merge_local_search_with_evidence_results(
result,
evidence_results,
direct_evidence_results,
limit,
);
(result, evidence_results)
} else {
let result = load_search_results_with_filters(
state.config(),
&context,
&effective_workspace_id,
&normalized_query,
page_id,
body.limit.unwrap_or(30),
filters,
)
.await?;
(result, Vec::new())
};
let result = if body.source_kind.as_deref().map(str::trim) == Some("local_folder") {
let root_uri = body
.root_uri
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.ok_or_else(|| {
WebError::bad_request_code("local_search_root_required", "本地搜索缺少 rootUri")
.with_context(&context)
})?;
let root_path = local_folder_source::ensure_local_workspace_read_access_with_state(
&state, &context, root_uri,
)
.map_err(|error| error.with_context(&context))?;
let user_settings = resolve_local_index_user_settings(
&state,
&context,
&effective_workspace_id,
&root_path,
)?;
let effective_settings = local_search_index::effective_local_index_settings_for_root(
state.control_plane(),
&effective_workspace_id,
&root_path,
)?;
local_search_index::query_local_search_index_with_settings(
&root_path,
root_uri,
&effective_workspace_id,
&effective_settings,
&user_settings,
&normalized_query,
page_id.as_deref(),
body.limit.unwrap_or(30),
filters.title_only.unwrap_or(false),
filters.exact.unwrap_or(false),
filters.include_ocr.unwrap_or(false),
)?
} else {
load_search_results_with_filters(
state.config(),
&context,
&effective_workspace_id,
&normalized_query,
page_id,
body.limit.unwrap_or(30),
filters,
)
.await?
};
let results = result
.get("results")
.cloned()
.unwrap_or(Value::Array(vec![]));
let results = attach_evidence_to_search_results(results, &evidence_results);
let mut headers = HeaderMap::new();
stamp_search_headers(&mut headers);
@@ -285,13 +244,20 @@ pub async fn documents(
headers,
Json(json!({
"results": results,
"evidence": evidence_results,
"evidence": [],
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"recent": result.get("recentChanges").cloned().unwrap_or_else(|| Value::Array(vec![])),
"meta": {
"owner": "mnote-web",
"projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")),
"queryName": "search.documents.query",
"boundary": {
"kind": "ordinary_local_search",
"knowledgeRag": false,
"evidenceSqliteFallback": false,
"liteParseFallback": false,
"ocrSidecarFallback": false
},
"degraded": result.get("degraded").cloned().unwrap_or_else(|| json!(false)),
"degradedReason": result.get("degradedReason").cloned().unwrap_or(Value::Null),
"requestId": context.trace.request_id,
@@ -475,158 +441,6 @@ pub async fn update_local_index_settings(
))
}
fn merge_local_search_with_evidence_results(
mut result: Value,
mut evidence_results: Vec<EvidenceSearchResult>,
direct_evidence_results: Vec<EvidenceSearchResult>,
limit: usize,
) -> (Value, Vec<EvidenceSearchResult>) {
if direct_evidence_results.is_empty() {
return (result, evidence_results);
}
let original_items = result
.get("results")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
let original_evidence_results = std::mem::take(&mut evidence_results);
let mut result_items = Vec::new();
let mut merged_evidence_results = Vec::new();
let mut seen_result_ids = std::collections::HashSet::new();
let mut seen_evidence_ids = std::collections::HashSet::new();
let mut seen_paths = std::collections::HashSet::new();
for evidence in direct_evidence_results {
if result_items.len() >= limit || !seen_evidence_ids.insert(evidence.evidence_id.clone()) {
continue;
}
let item = search_result_from_evidence(&evidence);
if let Some(id) = item.get("id").and_then(Value::as_str) {
seen_result_ids.insert(id.to_string());
}
if let Some(path) = search_result_dedupe_path(&item) {
seen_paths.insert(path);
}
result_items.push(item);
merged_evidence_results.push(evidence);
}
for (index, item) in original_items.into_iter().enumerate() {
if result_items.len() >= limit {
break;
}
let item_id = item
.get("id")
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.unwrap_or_default();
if !item_id.is_empty() && !seen_result_ids.insert(item_id) {
continue;
}
if let Some(path) = search_result_dedupe_path(&item) {
if !seen_paths.insert(path) {
continue;
}
}
if let Some(evidence) = original_evidence_results.get(index).cloned() {
merged_evidence_results.push(evidence);
}
result_items.push(item);
}
if let Some(map) = result.as_object_mut() {
map.insert("results".into(), Value::Array(result_items));
}
(result, merged_evidence_results)
}
fn search_result_dedupe_path(item: &Value) -> Option<String> {
let source_kind = item
.get("sourceKind")
.and_then(Value::as_str)
.unwrap_or_default();
if source_kind != "local_folder" {
return None;
}
item.get("path")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
}
fn search_result_from_evidence(evidence: &EvidenceSearchResult) -> Value {
let source = &evidence.source;
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
let resource_path = source
.resource_path
.as_deref()
.unwrap_or(source.owner_document_path.as_str());
let title = std::path::Path::new(resource_path)
.file_name()
.and_then(|value| value.to_str())
.unwrap_or(resource_path)
.to_string();
let resource_type = match source.resource_kind {
core_protocol::EvidenceResourceKind::Markdown => "markdown",
core_protocol::EvidenceResourceKind::Pdf => "pdf",
core_protocol::EvidenceResourceKind::Image => "image",
core_protocol::EvidenceResourceKind::Office => "office",
core_protocol::EvidenceResourceKind::Mindmap => "mindmap",
core_protocol::EvidenceResourceKind::RawFile => "resource",
};
json!({
"id": format!("evidence:{}", evidence.evidence_id),
"documentId": source.owner_document_id,
"title": title,
"path": source.owner_document_path,
"resourceType": resource_type,
"sourceKind": "local_folder",
"rootUri": source.root_uri,
"snippet": evidence.quote,
"score": evidence.score,
"matchInfo": evidence.match_info,
"publicPath": source.open_action.url,
"evidence": evidence_value,
"source": {
"locator": source
},
})
}
fn attach_evidence_to_search_results(
results: Value,
evidence_results: &[EvidenceSearchResult],
) -> Value {
let Value::Array(items) = results else {
return results;
};
Value::Array(
items
.into_iter()
.enumerate()
.map(|(index, item)| {
let Some(evidence) = evidence_results.get(index) else {
return item;
};
let mut item = item;
if let Some(map) = item.as_object_mut() {
if map.get("evidence").is_some() {
return item;
}
let evidence_value = serde_json::to_value(evidence).unwrap_or(Value::Null);
map.insert("evidence".into(), evidence_value);
let source = map.entry("source").or_insert_with(|| json!({}));
if let Some(source_map) = source.as_object_mut() {
source_map.insert(
"locator".into(),
serde_json::to_value(&evidence.source).unwrap_or(Value::Null),
);
}
}
item
})
.collect(),
)
}
fn resolve_local_index_user_settings(
state: &AppState,
context: &RequestContext,
@@ -940,12 +754,12 @@ fn stamp_search_headers(headers: &mut HeaderMap) {
#[cfg(test)]
mod tests {
use crate::app::{AppConfig, AppState, build_app};
use crate::app::{build_app, AppConfig, AppState};
use crate::routes::local_search_index;
use axum::body::{Body, to_bytes};
use axum::body::{to_bytes, Body};
use axum::http::{Request, StatusCode};
use control_plane::{DirectoryGrantInput, UpsertUserInput};
use serde_json::{Value, json};
use serde_json::{json, Value};
use std::fs;
use tower::util::ServiceExt;
@@ -1194,41 +1008,38 @@ mod tests {
assert_eq!(home["sourceKind"].as_str(), Some("local_folder"));
assert_eq!(home["resourceType"].as_str(), Some("markdown"));
assert_eq!(
home["source"]["locator"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["meta"]["boundary"]["kind"].as_str(),
Some("ordinary_local_search")
);
assert_eq!(
home["evidence"]["source"]["ownerDocumentPath"].as_str(),
Some("README.md")
payload["meta"]["boundary"]["knowledgeRag"].as_bool(),
Some(false)
);
assert!(!payload["evidence"].as_array().expect("evidence").is_empty());
assert!(
home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha"))
);
assert!(
home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily"))
);
assert!(
home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf"))
);
assert!(
root.join(".mnote")
.join("index")
.join("search-index.json")
.exists()
assert_eq!(
payload["meta"]["boundary"]["evidenceSqliteFallback"].as_bool(),
Some(false)
);
assert!(payload["evidence"].as_array().expect("evidence").is_empty());
assert!(home["tags"]
.as_array()
.unwrap()
.iter()
.any(|tag| tag.as_str() == Some("alpha")));
assert!(home["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|link| link.as_str() == Some("Daily")));
assert!(home["resourceRefs"]
.as_array()
.unwrap()
.iter()
.any(|reference| reference.as_str() == Some("assets/spec.pdf")));
assert!(root
.join(".mnote")
.join("index")
.join("search-index.json")
.exists());
let evidence_db = root.join(".mnote").join("index").join("evidence.sqlite");
assert!(evidence_db.exists(), "evidence sqlite should be built");
let connection = rusqlite::Connection::open(&evidence_db).expect("open evidence sqlite");
@@ -1258,19 +1069,17 @@ mod tests {
locator["schema"].as_str(),
Some("mnote.evidence_locator.v1")
);
assert!(
payload["recent"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
);
assert!(payload["recent"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
let _ = fs::remove_dir_all(&root);
}
#[tokio::test]
async fn search_documents_local_folder_includes_evidence_sqlite_body_hits() {
async fn search_documents_local_folder_does_not_promote_evidence_sqlite_body_hits() {
let root = std::env::temp_dir().join(format!(
"mnote-local-search-evidence-route-{}",
std::process::id()
@@ -1350,20 +1159,18 @@ mod tests {
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
let results = payload["results"].as_array().expect("results");
let hit = results
assert!(!results.iter().any(|item| item["id"]
.as_str()
.unwrap_or_default()
.starts_with("evidence:")));
assert!(results
.iter()
.find(|item| {
item["snippet"]
.as_str()
.unwrap_or("")
.contains("BodyOnlyEvidenceToken")
})
.expect("evidence sqlite body hit should be promoted to search result");
assert_eq!(hit["resourceType"].as_str(), Some("markdown"));
.any(|item| item["resourceType"].as_str() == Some("markdown")));
assert_eq!(
hit["evidence"]["source"]["schema"].as_str(),
Some("mnote.evidence_locator.v1")
payload["meta"]["boundary"]["evidenceSqliteFallback"].as_bool(),
Some(false)
);
assert!(payload["evidence"].as_array().expect("evidence").is_empty());
let _ = fs::remove_dir_all(&root);
}
@@ -1382,6 +1189,15 @@ mod tests {
.expect("manifest");
fs::write(root.join("README.md"), "# Refresh\nrefresh-token\n").expect("readme");
let root_uri = format!("file://{}", root.display());
local_search_index::write_local_index_settings(
&root,
&[String::from(".")],
None,
None,
None,
None,
)
.expect("settings");
let response = app()
.oneshot(
@@ -1582,6 +1398,15 @@ mod tests {
)
.expect("child");
let root_uri = format!("file://{}", root.display());
local_search_index::write_local_index_settings(
&root,
&[String::from(".")],
None,
None,
None,
None,
)
.expect("settings");
let encoded_root = query_escape(&root_uri);
let backlinks_response = app()
@@ -1607,13 +1432,11 @@ mod tests {
backlinks_payload["meta"]["queryName"].as_str(),
Some("search.local_index.backlinks")
);
assert!(
backlinks_payload["result"]["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md"))
);
assert!(backlinks_payload["result"]["backlinks"]
.as_array()
.unwrap()
.iter()
.any(|item| item["documentId"].as_str() == Some("local-md:README.md")));
let tags_response = app()
.oneshot(
+7 -16
View File
@@ -4323,7 +4323,7 @@ mod tests {
}
#[test]
fn tree_commands_keep_documents_alias_mapping_for_runtime_plan() {
fn tree_commands_use_protocol_names_in_runtime_plan() {
let context = RequestContext::from_http_parts(
&Method::POST,
&"/api/tree/commands".parse::<Uri>().expect("uri"),
@@ -4354,14 +4354,8 @@ mod tests {
let compat_create_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_create_wire)
.expect("compat create plan");
assert_eq!(
tree_create_plan.function_name,
"documents:createWithParentReference"
);
assert_eq!(
tree_create_plan.function_name,
compat_create_plan.function_name
);
assert_eq!(tree_create_plan.function_name, "tree.node.create");
assert_eq!(compat_create_plan.function_name, "documents.create");
let tree_rename_wire = create_command_wire(
&context,
@@ -4384,11 +4378,8 @@ mod tests {
let compat_rename_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_rename_wire)
.expect("compat rename plan");
assert_eq!(tree_rename_plan.function_name, "documents:updateTitle");
assert_eq!(
tree_rename_plan.function_name,
compat_rename_plan.function_name
);
assert_eq!(tree_rename_plan.function_name, "tree.node.rename");
assert_eq!(compat_rename_plan.function_name, "documents.title.update");
let tree_move_wire = create_command_wire(
&context,
@@ -4411,8 +4402,8 @@ mod tests {
let compat_move_plan =
build_runtime_command_plan(&context, Some("ws_demo"), compat_move_wire)
.expect("compat move plan");
assert_eq!(tree_move_plan.function_name, "documents:move");
assert_eq!(tree_move_plan.function_name, compat_move_plan.function_name);
assert_eq!(tree_move_plan.function_name, "tree.subtree.move");
assert_eq!(compat_move_plan.function_name, "documents.move");
}
#[test]
@@ -282,7 +282,6 @@ fn apply_preference_records(
"source_family".to_string(),
"workspace".to_string(),
"document".to_string(),
"localOcr".to_string(),
];
for preference in preferences {
if preference.scope_kind.starts_with("ai.") && !scope_kinds.contains(&preference.scope_kind)
@@ -301,7 +300,6 @@ fn apply_preference_records(
"workspace" => preference.scope_id.trim() == scope.workspace_id,
"document" => preference.scope_id.trim() == scope.document_id,
kind if kind.starts_with("ai.") => preference.scope_id.trim() == scope.workspace_id,
"localOcr" => preference.scope_id.trim() == scope.workspace_id,
_ => false,
};
if !scope_matches {
@@ -319,8 +317,8 @@ fn apply_preference_records(
continue;
}
if preference.key.starts_with("localOcr.") {
local_ocr_preferences.insert(preference.key.clone(), value);
sources.insert(preference.key.clone(), scope_kind.to_string());
local_ocr_preferences.insert(preference.key.clone(), Value::Bool(false));
sources.insert(preference.key.clone(), "retired".to_string());
continue;
}
if let Some(content_type) = page_width_content_type_for_key(&preference.key) {
@@ -381,7 +379,7 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
}
}
if trimmed.starts_with("localOcr.") {
return Some(("localOcr".to_string(), scope.workspace_id.clone()));
return None;
}
if page_width_content_type_for_key(key).is_some() {
return Some(("global".to_string(), "default".to_string()));
@@ -416,11 +414,7 @@ fn preference_scope_for_key(key: &str, scope: &PagePreferenceScope) -> Option<(S
}
fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace"
|| scope_kind == "document"
|| scope_kind.starts_with("ai.")
|| scope_kind == "localOcr"
{
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
Some(workspace_id.to_string())
} else {
None
@@ -428,11 +422,7 @@ fn preference_workspace_for_scope(workspace_id: &str, scope_kind: &str) -> Optio
}
fn preference_source_kind_for_scope(source_kind: &str, scope_kind: &str) -> Option<String> {
if scope_kind == "workspace"
|| scope_kind == "document"
|| scope_kind.starts_with("ai.")
|| scope_kind == "localOcr"
{
if scope_kind == "workspace" || scope_kind == "document" || scope_kind.starts_with("ai.") {
Some(source_kind.to_string())
} else {
None
+44 -11
View File
@@ -2328,6 +2328,20 @@ pub async fn filetree_selection_runtime_asset() -> Response {
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn local_folder_event_bus_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/local-folder-event-bus-runtime.js");
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
"application/javascript; charset=utf-8",
)
.header(header::CACHE_CONTROL, runtime_asset_cache_control())
.header(HEADER_MNOTE_WEB_OWNER, "mnote-web")
.body(browser_runtime_js_body(JS))
.unwrap_or_else(|_| Response::new(Body::empty()))
}
pub async fn tree_live_controller_runtime_asset() -> Response {
const JS: &str = include_str!("../../browser/tree-live-controller.js");
Response::builder()
@@ -3632,11 +3646,11 @@ mod tests {
assert!(runtime.contains("document-resource-tab-runtime.js"));
assert!(resource_runtime.contains("resourceTabBadgeKind(input, kind)"));
assert!(resource_runtime.contains("后台任务"));
assert!(resource_runtime.contains("data-mnote-local-ocr-task-tab"));
assert!(resource_runtime.contains("data-mnote-local-ocr-task-clear-completed"));
assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-tab"));
assert!(resource_runtime.contains("data-mnote-knowledge-rag-task-clear-completed"));
assert!(resource_runtime.contains("role=\"progressbar\""));
assert!(resource_runtime.contains("localOcrTaskCategory(job)"));
assert!(resource_runtime.contains("localOcrTaskProgress(job)"));
assert!(resource_runtime.contains("knowledgeRagTaskCategory(job)"));
assert!(resource_runtime.contains("knowledgeRagTaskProgress(job)"));
assert!(resource_runtime.contains("mnote.open_editors_snapshot.v1"));
assert!(runtime.contains("getOpenEditorsSnapshot"));
assert!(resource_runtime.contains("bindMainEditorTabStrip"));
@@ -3734,6 +3748,11 @@ mod tests {
assert!(session_runtime.contains("mnote.localFolder.selfChangeSuppressions.v1"));
assert!(session_runtime.contains("ensureLocalFolderSelfChangeSuppressions()"));
assert!(session_runtime.contains("markLocalFolderSelfChangeSuppression(session);"));
assert!(session_runtime.contains("data-mnote-page-body-local-compat-fallback"));
assert!(session_runtime.contains("data-mnote-page-body-hard-guard"));
assert!(session_runtime.contains("local_compat_fallback"));
assert!(runtime.contains("data-mnote-page-body-local-compat-fallback"));
assert!(runtime.contains("data-mnote-page-body-hard-guard"));
assert!(session_runtime.contains("const suppressibleSelfWrite = kind.includes('Create')"));
assert!(session_runtime.contains("|| kind.includes('Modify(Data')"));
assert!(session_runtime.contains("|| kind.includes('Modify(Any')"));
@@ -3765,8 +3784,9 @@ mod tests {
resource_runtime.contains("const refreshExistingOfficeResourceTab = (entry, input) =>")
);
assert!(resource_runtime.contains("if (!entry || entry.kind !== 'office') return false;"));
assert!(resource_runtime
.contains("if (currentHref !== nextHref) void openPassiveResourceTab(entry, input);"));
assert!(resource_runtime.contains(
"if (officePreviewBaseHref(currentHref) !== officePreviewBaseHref(nextHref)) void openPassiveResourceTab(entry, input);"
));
assert!(resource_runtime.contains("refreshExistingOfficeResourceTab(existing, input);"));
assert!(resource_runtime.contains("syncActiveResourceWidthType(activeEntry, role);"));
assert!(resource_runtime.contains("data-mnote-active-resource-width-type"));
@@ -4076,14 +4096,14 @@ mod tests {
.headers()
.get("x-error-phase")
.and_then(|value| value.to_str().ok()),
Some("query_send")
Some("convex_query_retired")
);
assert_eq!(
response
.headers()
.get("x-upstream-service")
.and_then(|value| value.to_str().ok()),
Some("convex")
Some("convex-retired")
);
let body = to_bytes(response.into_body(), usize::MAX)
.await
@@ -4733,7 +4753,9 @@ mod tests {
assert!(session_runtime.contains("/api/local-folder/events"));
assert!(session_runtime.contains("localFolderEventChannelKey"));
assert!(session_runtime.contains("url.searchParams.set('documentId', session.documentId);"));
assert!(session_runtime.contains("if (!documentId) return;"));
assert!(session_runtime.contains(
"if (!session || session.sourceKind !== 'local_folder' || !session.rootUri || !session.documentId) return null;"
));
assert!(session_runtime.contains("new EventSource(url.toString())"));
assert!(session_runtime.contains("localFolderEventRegistry"));
assert!(session_runtime
@@ -4939,6 +4961,18 @@ mod tests {
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("createDocumentSessionRuntime"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("getOrCreateDocumentSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("persistSession"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("mnote:local-folder:document-changed"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("__mnoteLocalFolderEventBus"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-folder:resource-changed"));
assert!(
DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("data-mnote-local-ocr-event-stream-retired")
);
assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("local_ocr.job.updated"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:knowledge-rag-job-updated"));
assert!(!DOCUMENT_RESOURCE_TAB_RUNTIME_JS.contains("mnote:local-ocr-job-updated"));
assert!(DOCUMENT_RESOURCE_TAB_RUNTIME_JS
.contains("data-mnote-resource-watch-ready', 'event-bus'"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS.contains("document.createElement('script')"));
assert!(DOCUMENT_EDITOR_ADAPTER_RUNTIME_JS
.contains("syncPageAggregateScript({ pageAggregateScriptId"));
@@ -5120,8 +5154,7 @@ mod tests {
assert!(runtime.contains("typeof payload.text === 'string'"));
assert!(runtime.contains("payload.type === 'hard_break'"));
assert!(runtime.contains("typeof body?.fileVersion === 'string'"));
assert!(DOCUMENT_SESSION_RUNTIME_JS
.contains("expectedFileVersion: session.conflictDetectionKey"));
assert!(DOCUMENT_SESSION_RUNTIME_JS.contains("expectedFileVersion: expectedFileVersion"));
assert!(runtime.contains("node?.attrs?.mnoteBlockType === 'mindmap'"));
assert!(runtime.contains("blockType: 'mindmap'"));
assert!(runtime.contains("...mindmapPropsFromAttrs(node?.attrs, blockId)"));
+83 -15
View File
@@ -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"));
+46 -46
View File
@@ -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);
}
}
+56 -5
View File
@@ -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(