From 3cc090ba5eafa5ca4512fa394e0c0338a0da1778 Mon Sep 17 00:00:00 2001 From: lix-2026 Date: Thu, 30 Apr 2026 05:46:36 +0800 Subject: [PATCH] Implement Rust web sidebar title and tree interactions --- harness-tasks.json | 63 ++ rust/crates/bridge-runtime/src/lib.rs | 665 ++++++++++++++++- rust/crates/core-protocol/src/ai.rs | 68 ++ rust/crates/core-protocol/src/editor/mod.rs | 3 +- rust/crates/core-protocol/src/lib.rs | 132 +++- rust/crates/core-protocol/src/mindmap.rs | 90 +++ .../core-protocol/src/page_aggregate.rs | 141 ++++ rust/crates/core-protocol/src/search.rs | 63 ++ rust/crates/mnote-web/src/app.rs | 2 +- rust/crates/mnote-web/src/page_aggregate.rs | 139 +--- .../mnote-web/src/page_aggregate/builder.rs | 89 ++- rust/crates/mnote-web/src/routes/compat.rs | 4 +- rust/crates/mnote-web/src/routes/documents.rs | 253 ++++++- rust/crates/mnote-web/src/routes/hermes.rs | 83 ++- .../mnote-web/src/routes/mindmap_shell.rs | 11 +- rust/crates/mnote-web/src/routes/mod.rs | 2 + rust/crates/mnote-web/src/routes/search.rs | 219 ++++-- rust/crates/mnote-web/src/routes/sse.rs | 32 + .../mnote-web/src/routes/stream_support.rs | 2 + rust/crates/mnote-web/src/routes/web_shell.rs | 306 ++++---- .../mnote-web/src/ssr/pages/document.rs | 25 +- rust/crates/mnote-web/src/ssr/pages/layout.rs | 688 +++++++++++++++++- rust/crates/mnote-web/src/ssr/pages/search.rs | 15 +- rust/crates/mnote-web/src/ssr/styles.rs | 97 ++- .../src/tree_shell/filetree_renderer.rs | 31 +- rust/crates/mnote-web/src/workspace_shell.rs | 59 +- .../storage-convex-bridge/src/mapping.rs | 4 + scripts/task116-rust-web-islands-smoke.js | 4 +- scripts/task117-next-retirement-guard.js | 14 + ...ust-web-tree-live-stream-consumer-smoke.js | 37 + ...k125-rust-web-search-server-first-smoke.js | 46 ++ ...st-web-ai-bridge-structured-write-smoke.js | 37 + ...ust-web-mindmap-kernel-projection-smoke.js | 25 + .../editor/DocumentAiAgentPanel.runtime.tsx | 2 +- wolai-frontend/src/types/search.ts | 2 + 35 files changed, 3029 insertions(+), 424 deletions(-) create mode 100644 rust/crates/core-protocol/src/ai.rs create mode 100644 rust/crates/core-protocol/src/page_aggregate.rs create mode 100644 rust/crates/core-protocol/src/search.rs create mode 100644 scripts/task123-rust-web-tree-live-stream-consumer-smoke.js create mode 100644 scripts/task125-rust-web-search-server-first-smoke.js create mode 100644 scripts/task126-rust-web-ai-bridge-structured-write-smoke.js create mode 100644 scripts/task127-rust-web-mindmap-kernel-projection-smoke.js diff --git a/harness-tasks.json b/harness-tasks.json index ae8b1a65..c40d3e47 100644 --- a/harness-tasks.json +++ b/harness-tasks.json @@ -1047,5 +1047,68 @@ "task-026" ], "note": "树域 Rust family 默认执行面已完成;主 Web 执行面仍需通过 task-019 至 task-026 迁到 mnote-web / Rust family。" + }, + "rust_web_3_4_undo": { + "source_doc": "design/03-rust-web/process/3-4-undo.md", + "status": "in_progress", + "dependency_chain": "A -> B/C -> D/E/F -> G -> H", + "tasks": [ + { + "id": "task-3-4-A", + "title": "Page Aggregate 升级为 core-protocol projection contract", + "status": "in_progress", + "depends_on": [], + "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol page_aggregate && cargo test -p bridge-runtime page_aggregate && cargo test -p mnote-web page_aggregate" + }, + { + "id": "task-3-4-B", + "title": "page.* 成为页面默认写命令族", + "status": "in_progress", + "depends_on": ["task-3-4-A"], + "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p bridge-runtime page_body_save && cargo test -p bridge-runtime page_head_update_title && cargo test -p bridge-runtime page_layout_update_options && cargo test -p mnote-web documents_save" + }, + { + "id": "task-3-4-C", + "title": "3000 Rust shell 消费 tree realtime event stream", + "status": "in_progress", + "depends_on": ["task-3-4-A"], + "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web tree_events && cargo test -p mnote-web tree_command && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task123-rust-web-tree-live-stream-consumer-smoke.js" + }, + { + "id": "task-3-4-D", + "title": "Search 收口为 server-first / kernel-aware 主链", + "status": "in_progress", + "depends_on": ["task-3-4-B", "task-3-4-C"], + "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol search && cargo test -p bridge-runtime search_documents_query && cargo test -p mnote-web search && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task125-rust-web-search-server-first-smoke.js" + }, + { + "id": "task-3-4-E", + "title": "AI bridge 与结构化写链收口", + "status": "in_progress", + "depends_on": ["task-3-4-B", "task-3-4-C"], + "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol ai && cargo test -p mnote-web hermes && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task126-rust-web-ai-bridge-structured-write-smoke.js" + }, + { + "id": "task-3-4-F", + "title": "Mindmap 成为 kernel projection / command truth", + "status": "in_progress", + "depends_on": ["task-3-4-B", "task-3-4-C"], + "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p core-protocol mindmap && cargo test -p bridge-runtime mindmap && cargo test -p mnote-web mindmap && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task127-rust-web-mindmap-kernel-projection-smoke.js" + }, + { + "id": "task-3-4-G", + "title": "Legacy Next / React 兼容层退役 gate", + "status": "in_progress", + "depends_on": ["task-3-4-D", "task-3-4-E", "task-3-4-F"], + "validation": "cd /mnt/Data1T/mnote/rust && cargo test -p mnote-web gateway && cargo test -p mnote-web legacy_next && cd /mnt/Data1T/mnote && MNOTE_UI_BASE_URL=http://127.0.0.1:3000 node scripts/task117-next-retirement-guard.js" + }, + { + "id": "task-3-4-H", + "title": "设计文档状态治理与执行节奏", + "status": "in_progress", + "depends_on": ["task-3-4-G"], + "validation": "cd /mnt/Data1T/mnote && rg -n \"\\\\[ \\\\]\" design/03-rust-web/process/3-4-undo.md && rg -n \"\\\\[done\\\\]|\\\\[recycle\\\\]\" design/03-rust-web design/old && git diff --check && git status --short --untracked-files=all" + } + ] } } diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 2a89acf3..6b4ce965 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -21,8 +21,11 @@ use core_protocol::{ KernelProjectionRequest, KernelProjectionResourceKind, KernelProjectionResourceMeta, KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, ListBridgeWorkspaceOverview, - MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode, MoveBlock, - PatchBlock, PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef, + MindmapCommand, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, + MindmapProjection, MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner, + MindmapTreeNode, MoveBlock, PageAggregateProjection, PageAggregateSource, PageBody, PageHead, + PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, PatchBlock, + PutMindmap, QueryEnvelope, SearchDocuments, SearchRecent, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageOptions, UpdatePageStats, }; use event_log::DomainEventRecord; @@ -412,6 +415,13 @@ struct DocumentContentQueryPayload { workspace_id: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PageAggregateQueryPayload { + document_id: String, + workspace_id: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapGetQueryPayload { @@ -686,6 +696,19 @@ struct MindmapPutCommandPayload { create_only: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct MindmapCommandApplyPayload { + document_id: String, + mindmap_id: String, + #[serde(default)] + workspace_id: Option, + #[serde(default)] + commands: Vec, + #[serde(default)] + projection_revision: Option, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct MindmapDeleteCommandPayload { @@ -2927,10 +2950,39 @@ fn execute_query( }), })) } - "mindmaps.get" => { - let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?; + "page.aggregate.get" => { + let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?; let query = QueryEnvelope { - name: "mindmaps.get".into(), + name: "page.aggregate.get".into(), + payload: core_protocol::GetPageContent { + page_id: payload.document_id.clone(), + workspace_id: payload.workspace_id.clone(), + }, + }; + let request = build_query_request(&context, &query)?; + Ok(RuntimeExecutionPlan::Query(RuntimeQueryExecutionPlan { + query_name: query.name, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + payload_json: request.payload_json, + args_json: json!({ + "documentId": payload.document_id, + "workspaceId": payload.workspace_id, + }), + })) + } + "mindmaps.get" | "mindmap.projection.get" => { + let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?; + let query_name = if query_wire.name == "mindmap.projection.get" { + "mindmap.projection.get" + } else { + "mindmaps.get" + }; + let query = QueryEnvelope { + name: query_name.into(), payload: GetMindmap { document_id: payload.document_id.clone(), mindmap_id: payload.mindmap_id.clone(), @@ -2997,15 +3049,20 @@ fn execute_query( }), })) } - "search.documents" => { + "search.documents" | "search.documents.query" => { let payload: SearchDocumentsQueryPayload = parse_payload(query_wire.payload)?; + let query_name = if query_wire.name == "search.documents.query" { + "search.documents.query" + } else { + "search.documents" + }; let time_range = payload.time_range.clone().unwrap_or_else(|| "any".into()); let time_field = payload .time_field .clone() .unwrap_or_else(|| "updated".into()); let query = QueryEnvelope { - name: "search.documents".into(), + name: query_name.into(), payload: SearchDocuments { query: payload.query.clone(), workspace_id: payload.workspace_id.clone(), @@ -5104,6 +5161,157 @@ fn build_document_content_result( }) } +fn build_page_aggregate_projection_result( + data: &Value, + document_id: &str, + workspace_id: Option<&str>, + source: PageAggregateSource, +) -> Result { + if data.is_null() { + return Err(BridgeError::not_found("page.aggregate.get 未返回页面聚合数据")); + } + + let meta = data.get("meta").unwrap_or(data); + let content_result = data + .get("content") + .filter(|value| { + value.get("content").is_some() + || value.get("revision").is_some() + || value.get("pageSubtree").is_some() + || value.get("page_subtree").is_some() + }) + .unwrap_or(data); + let resolved_document_id = read_trimmed_string_field(meta, &["id", "documentId"]) + .unwrap_or_else(|| document_id.to_string()); + let resolved_workspace_id = read_trimmed_string_field(meta, &["workspace_id", "workspaceId"]) + .or_else(|| workspace_id.map(ToOwned::to_owned)) + .unwrap_or_else(|| "default".into()); + let title = read_trimmed_string_field(meta, &["title"]).unwrap_or_else(|| "无标题".into()); + let updated_at = read_trimmed_string_field(meta, &["updated_at", "updatedAt"]); + let parent_id = read_trimmed_string_field(meta, &["parent_id", "parentId"]); + let content = content_result + .get("content") + .cloned() + .unwrap_or_else(|| Value::Array(vec![])); + let revision = content_result + .get("revision") + .cloned() + .unwrap_or(Value::Null); + let conflict_detection_key = content_result + .get("conflictDetectionKey") + .or_else(|| content_result.get("conflict_detection_key")) + .cloned() + .unwrap_or(Value::Null); + let page_subtree = content_result + .get("pageSubtree") + .or_else(|| content_result.get("page_subtree")) + .cloned() + .unwrap_or_else(|| json!({ "rootNodeId": resolved_document_id })); + let page_options = PageOptions { + wide_layout: bool_field(meta, "wide_layout") + .or_else(|| bool_field(meta, "wideLayout")) + .unwrap_or(false), + small_text: bool_field(meta, "use_small_text") + .or_else(|| bool_field(meta, "smallText")) + .unwrap_or(false), + layout_density: read_trimmed_string_field(meta, &["layout_density", "layoutDensity"]) + .unwrap_or_else(|| "normal".into()), + show_heading_numbers: bool_field(meta, "show_heading_numbers") + .or_else(|| bool_field(meta, "showHeadingNumbers")) + .unwrap_or(true), + show_toc: bool_field(meta, "show_toc") + .or_else(|| bool_field(meta, "showToc")) + .unwrap_or(false), + show_structure: bool_field(meta, "show_structure") + .or_else(|| bool_field(meta, "showStructure")) + .unwrap_or(false), + protect_editing: bool_field(meta, "protect_editing") + .or_else(|| bool_field(meta, "protectEditing")) + .unwrap_or(false), + show_word_count: bool_field(meta, "show_word_count") + .or_else(|| bool_field(meta, "showWordCount")) + .unwrap_or(true), + collapse_backlinks: bool_field(meta, "collapse_backlinks") + .or_else(|| bool_field(meta, "collapseBacklinks")) + .unwrap_or(false), + page_font: read_trimmed_string_field(meta, &["page_font", "pageFont"]) + .unwrap_or_else(|| "default".into()), + hide_child_pages: bool_field(meta, "hide_child_pages") + .or_else(|| bool_field(meta, "hideChildPages")) + .unwrap_or(false), + show_block_ref_count: bool_field(meta, "show_block_ref_count") + .or_else(|| bool_field(meta, "showBlockRefCount")) + .unwrap_or(false), + embed_default_block_id: meta + .get("embed_default_block_id") + .or_else(|| meta.get("embedDefaultBlockId")) + .cloned() + .unwrap_or(Value::Null), + }; + let layout_options = serde_json::to_value(&page_options).map_err(|error| { + BridgeError::transport(format!("PageOptions 序列化失败: {error}")) + })?; + let revision_ref = revision + .as_u64() + .map(|value| format!("{resolved_document_id}:{value}")); + + Ok(PageAggregateProjection { + schema: PageAggregateProjection::SCHEMA.into(), + projection_version: PageAggregateProjection::VERSION, + source, + page_id: resolved_document_id.clone(), + parent_id, + title: title.clone(), + path: vec![resolved_document_id.clone()], + sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()], + body_ref: revision_ref, + layout_options, + updated_at: updated_at.clone(), + identity: PageIdentity { + document_id: resolved_document_id, + workspace_id: resolved_workspace_id, + }, + head: PageHead { + title, + updated_at: updated_at.map(Value::String).unwrap_or(Value::Null), + permissions: PagePermissions { + read_only: bool_field(meta, "can_edit").map(|can_edit| !can_edit).unwrap_or(false), + disable_download: bool_field(meta, "disable_download") + .or_else(|| bool_field(meta, "disableDownload")) + .unwrap_or(false), + disable_copy: bool_field(meta, "disable_copy") + .or_else(|| bool_field(meta, "disableCopy")) + .unwrap_or(false), + }, + }, + layout: PageLayout { page_options }, + body: PageBody { + content, + revision, + conflict_detection_key, + }, + tree: PageTree { page_subtree }, + stats: PageStats { + word_count: meta.get("word_count").and_then(Value::as_u64).unwrap_or(0), + character_count: meta + .get("character_count") + .and_then(Value::as_u64) + .unwrap_or(0), + block_count: meta.get("block_count").and_then(Value::as_u64).unwrap_or(0), + todo_total: meta + .get("todo_total") + .or_else(|| meta.get("todo_total_count")) + .and_then(Value::as_u64) + .unwrap_or(0), + todo_done: meta + .get("todo_done") + .or_else(|| meta.get("todo_done_count")) + .and_then(Value::as_u64) + .unwrap_or(0), + }, + }) +} + fn normalize_mindmap_from_value(data: &Value) -> Result { if data.is_null() { return Ok(default_mindmap_tree()); @@ -5131,6 +5339,98 @@ fn normalize_mindmap_from_value(data: &Value) -> Result Result { + let revision = data + .get("revision") + .or_else(|| data.get("meta").and_then(|meta| meta.get("revision"))) + .and_then(Value::as_u64) + .unwrap_or(1); + let tree_input = data + .get("data") + .filter(|value| value.get("data").is_some() || value.get("children").is_some()) + .unwrap_or(data); + let tree = normalize_mindmap_from_value(tree_input)?; + let root_node = tree + .data + .uid + .clone() + .unwrap_or_else(|| "root".into()); + let mut nodes = Vec::new(); + let mut edges = Vec::new(); + collect_mindmap_projection_rows(&tree, None, &mut nodes, &mut edges); + + Ok(MindmapProjection { + schema: "mnote.mindmap_projection.v1".into(), + map_id: mindmap_id.into(), + root_node, + nodes, + edges, + layout_hints: data + .get("layoutHints") + .or_else(|| data.get("layout_hints")) + .cloned() + .unwrap_or_else(|| json!({ "layout": "right" })), + revision, + owner: MindmapProjectionOwner::RustKernel, + }) +} + +fn collect_mindmap_projection_rows( + node: &MindmapTreeNode, + parent_id: Option<&str>, + nodes: &mut Vec, + edges: &mut Vec, +) { + let node_id = node + .data + .uid + .clone() + .unwrap_or_else(|| format!("node_{}", nodes.len() + 1)); + let title = node + .data + .text + .clone() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| "未命名节点".into()); + if let Some(parent_id) = parent_id { + edges.push(MindmapProjectionEdge { + edge_id: format!("{parent_id}->{node_id}"), + from_node_id: parent_id.into(), + to_node_id: node_id.clone(), + edge_kind: "mindmap_child".into(), + }); + } + nodes.push(MindmapProjectionNode { + node_id: node_id.clone(), + title, + parent_id: parent_id.map(ToOwned::to_owned), + }); + for child in &node.children { + collect_mindmap_projection_rows(child, Some(&node_id), nodes, edges); + } +} + +fn search_documents_canonical_projection(evaluation: SearchDocumentsEvaluation) -> Value { + let mut payload = serde_json::to_value(evaluation).unwrap_or_else(|_| json!({ + "enqueueAssetIds": [], + "results": [] + })); + if let Some(map) = payload.as_object_mut() { + map.insert("projectionOwner".into(), json!("rust-kernel")); + if let Some(results) = map.get_mut("results").and_then(Value::as_array_mut) { + for result in results { + if let Some(result_map) = result.as_object_mut() { + result_map.insert("projectionOwner".into(), json!("rust-kernel")); + } + } + } + } + payload +} + fn walk_block_summaries(root_blocks: &[Value], max_nodes: usize) -> Vec { let mut queue = VecDeque::new(); for block in root_blocks { @@ -7082,6 +7382,18 @@ fn execute_query_result( BridgeError::transport(format!("documents.content.get result 序列化失败: {error}")) }) } + "page.aggregate.get" => { + let payload: PageAggregateQueryPayload = parse_payload(query_wire.payload)?; + let result = build_page_aggregate_projection_result( + &data, + &payload.document_id, + payload.workspace_id.as_deref(), + PageAggregateSource::KernelProjection, + )?; + serde_json::to_value(result).map_err(|error| { + BridgeError::transport(format!("page.aggregate.get result 序列化失败: {error}")) + }) + } "kernel.node.get" => { let payload: KernelGetNodeQueryPayload = parse_payload(query_wire.payload)?; let node = normalize_kernel_node_from_record(&data, payload.workspace_id.as_deref())?; @@ -7167,7 +7479,16 @@ fn execute_query_result( BridgeError::transport(format!("mindmaps.get result 序列化失败: {error}")) }) } - "search.documents" => { + "mindmap.projection.get" => { + let payload: MindmapGetQueryPayload = parse_payload(query_wire.payload)?; + let result = build_mindmap_projection_result(&data, &payload.mindmap_id)?; + serde_json::to_value(result).map_err(|error| { + BridgeError::transport(format!( + "mindmap.projection.get result 序列化失败: {error}" + )) + }) + } + "search.documents" | "search.documents.query" => { let payload: SearchDocumentsQueryPayload = parse_payload(query_wire.payload)?; let dataset: SearchDocumentsDataset = serde_json::from_value(data).map_err(|error| { @@ -7191,10 +7512,15 @@ fn execute_query_result( }, &dataset, ); - serde_json::to_value(SearchDocumentsEvaluation { + let legacy_result = SearchDocumentsEvaluation { enqueue_asset_ids: result.enqueue_asset_ids, results: result.results, - }) + }; + if query_wire.name == "search.documents.query" { + serde_json::to_value(search_documents_canonical_projection(legacy_result)) + } else { + serde_json::to_value(legacy_result) + } .map_err(|error| { BridgeError::transport(format!("search.documents result 序列化失败: {error}")) }) @@ -7985,6 +8311,45 @@ fn execute_command( }), })) } + "mindmap.command.apply" => { + let payload: MindmapCommandApplyPayload = parse_payload(command_wire.payload.clone())?; + let command = CommandEnvelope { + name: "mindmap.command.apply".into(), + command_id: command_wire.command_id.clone(), + idempotency_key: command_wire.idempotency_key.clone(), + actor: to_actor_payload(&command_wire.actor), + source: to_source_payload(&command_wire.source), + target: to_target_ref(command_wire.target.as_ref()).or(Some(TargetRef { + workspace_id: payload.workspace_id.clone(), + page_id: Some(payload.document_id.clone()), + block_id: None, + })), + payload: payload.commands.clone(), + reason: command_wire.reason, + refs: command_wire.refs, + dry_run: command_wire.dry_run, + validate_only: command_wire.validate_only, + }; + let request = build_write_request(&context, &command)?; + Ok(RuntimeExecutionPlan::Command(RuntimeCommandExecutionPlan { + command_name: command.name, + command_id: command.command_id, + function_name: request.function_name, + workspace_id: request.workspace_id, + request_id: request.request_id, + trace_id: request.trace_id, + actor_id: request.actor_id, + idempotency_key: request.idempotency_key, + payload_json: request.payload_json, + args_json: json!({ + "documentId": payload.document_id, + "mindmapId": payload.mindmap_id, + "commands": payload.commands, + "projectionRevision": payload.projection_revision, + "canonicalCommand": "mindmap.command.apply", + }), + })) + } "mindmaps.delete" => { let payload: MindmapDeleteCommandPayload = parse_payload(command_wire.payload.clone())?; let command = CommandEnvelope { @@ -9333,6 +9698,164 @@ mod tests { } } + #[test] + fn page_aggregate_get_query_executes_into_core_projection() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "page.aggregate.get".into(), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + }), + }, + data: Some(json!({ + "meta": { + "id": "doc_1", + "workspace_id": "ws_1", + "title": "聚合页面", + "parent_id": "root", + "updated_at": "2026-04-29T00:00:00Z" + }, + "content": { + "content": [], + "revision": 7, + "conflict_detection_key": "doc_1:7", + "pageSubtree": {"rootNodeId": "doc_1"} + } + })), + }) + .expect("page aggregate query should build"); + + assert_eq!(result["schema"], json!("mnote.page_aggregate.v1")); + assert_eq!(result["projectionVersion"], json!(1)); + assert_eq!(result["source"], json!("KernelProjection")); + assert_eq!(result["pageId"], json!("doc_1")); + assert_eq!(result["identity"]["documentId"], json!("doc_1")); + assert_eq!(result["body"]["revision"], json!(7)); + } + + #[test] + fn search_documents_query_canonical_facade_keeps_projection_owner() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "search.documents.query".into(), + payload: json!({ + "query": "rust", + "workspaceId": "ws_1", + "pageId": null, + "limit": 10, + "titleOnly": false, + "exact": false, + "includeOcr": true, + "timeRange": "any", + "timeField": "updated", + "customRangeFrom": null, + "customRangeTo": null, + }), + }, + data: Some(json!({ + "documents": [ + { + "id": "page_1", + "workspaceId": "ws_1", + "title": "Rust Notes", + "rawText": "这里有 rust 搜索内容", + "createdAt": "2026-04-15T00:00:00Z", + "updatedAt": "2026-04-15T01:00:00Z" + } + ], + "mindmaps": [], + "tables": [], + "tableRows": [], + "assets": [] + })), + }) + .expect("canonical search query result should build"); + + assert_eq!(result["projectionOwner"], json!("rust-kernel")); + assert_eq!(result["results"][0]["projectionOwner"], json!("rust-kernel")); + assert_eq!(result["results"][0]["id"], json!("page_1")); + } + + #[test] + fn mindmap_projection_get_query_returns_kernel_projection() { + let result = execute_runtime_query(RuntimeInput::Query { + context: demo_context(), + query: RuntimeQueryEnvelopeWire { + name: "mindmap.projection.get".into(), + payload: json!({ + "documentId": "doc_1", + "mindmapId": "mind_1", + "workspaceId": "ws_1", + }), + }, + data: Some(json!({ + "data": { + "data": {"uid": "root", "text": "中心主题"}, + "children": [ + {"data": {"uid": "child_1", "text": "子节点"}, "children": []} + ] + }, + "revision": 3 + })), + }) + .expect("mindmap projection should build"); + + assert_eq!(result["schema"], json!("mnote.mindmap_projection.v1")); + assert_eq!(result["owner"], json!("rust-kernel")); + assert_eq!(result["mapId"], json!("mind_1")); + assert_eq!(result["revision"], json!(3)); + } + + #[test] + fn mindmap_command_apply_plan_uses_kernel_command_facade() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "mindmap.command.apply".into(), + command_id: "cmd_mindmap_apply".into(), + idempotency_key: None, + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: None, + }, + source: RuntimeSourceWire { + channel: "rust-web".into(), + client: "mnote-web".into(), + }, + target: None, + payload: json!({ + "documentId": "doc_1", + "mindmapId": "mind_1", + "commands": [ + {"type": "renameNode", "mapId": "mind_1", "nodeId": "root", "title": "新标题"} + ], + "projectionRevision": 3 + }), + preflight_data: None, + reason: Some("mindmap command facade".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("mindmap command facade should plan"); + + match plan { + RuntimeExecutionPlan::Command(plan) => { + assert_eq!(plan.command_name, "mindmap.command.apply"); + assert_eq!(plan.function_name, "mindmaps:applyCommand"); + assert_eq!(plan.args_json["canonicalCommand"], json!("mindmap.command.apply")); + } + RuntimeExecutionPlan::Query(_) | RuntimeExecutionPlan::Tool(_) => { + panic!("expected command plan") + } + } + } + #[test] fn search_documents_query_executes_in_rust_runtime() { let result = execute_runtime_query(RuntimeInput::Query { @@ -10064,6 +10587,128 @@ mod tests { ); } + #[test] + fn page_body_save_command_plan_uses_canonical_page_command() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "page.body.save".into(), + command_id: "cmd_page_body_save".into(), + idempotency_key: None, + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: None, + }, + source: RuntimeSourceWire { + channel: "rust-web".into(), + client: "mnote-web".into(), + }, + target: Some(RuntimeTargetWire { + workspace_id: Some("ws_1".into()), + page_id: Some("doc_1".into()), + block_id: None, + }), + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + "revision": 1, + "content": [], + "conflictDetectionKey": "doc_1:1" + }), + preflight_data: None, + reason: Some("canonical body save".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("page.body.save plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + assert_eq!(plan.command_name, "page.body.save"); + assert_eq!(plan.function_name, "documents:updateContent"); + } + + #[test] + fn page_head_update_title_command_plan_uses_canonical_page_command() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "page.head.updateTitle".into(), + command_id: "cmd_page_head_title".into(), + idempotency_key: None, + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: None, + }, + source: RuntimeSourceWire { + channel: "rust-web".into(), + client: "mnote-web".into(), + }, + target: None, + payload: json!({ + "documentId": "doc_1", + "title": "新标题" + }), + preflight_data: None, + reason: Some("canonical title update".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("page.head.updateTitle plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + assert_eq!(plan.command_name, "page.head.updateTitle"); + assert_eq!(plan.function_name, "documents:updateTitle"); + } + + #[test] + fn page_layout_update_options_command_plan_uses_canonical_page_command() { + let plan = execute_runtime_input(RuntimeInput::Command { + context: demo_context(), + command: RuntimeCommandEnvelopeWire { + name: "page.layout.updateOptions".into(), + command_id: "cmd_page_layout_options".into(), + idempotency_key: None, + actor: RuntimeActorWire { + actor_type: "user".into(), + actor_id: "user_1".into(), + session_id: None, + }, + source: RuntimeSourceWire { + channel: "rust-web".into(), + client: "mnote-web".into(), + }, + target: None, + payload: json!({ + "documentId": "doc_1", + "workspaceId": "ws_1", + "options": {"showToc": true} + }), + preflight_data: None, + reason: Some("canonical layout update".into()), + refs: vec![], + dry_run: false, + validate_only: false, + }, + }) + .expect("page.layout.updateOptions plan should build"); + + let RuntimeExecutionPlan::Command(plan) = plan else { + panic!("expected command plan"); + }; + assert_eq!(plan.command_name, "page.layout.updateOptions"); + assert_eq!(plan.function_name, "documents:updateOptions"); + } + #[test] fn documents_save_command_plan_falls_back_from_invalid_editor_document_to_tiptap() { let plan = execute_runtime_input(RuntimeInput::Command { diff --git a/rust/crates/core-protocol/src/ai.rs b/rust/crates/core-protocol/src/ai.rs new file mode 100644 index 00000000..e76c38fb --- /dev/null +++ b/rust/crates/core-protocol/src/ai.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiSession { + pub session_id: String, + pub workspace_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub document_id: Option, + pub owner: AiRuntimeOwner, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum AiRuntimeOwner { + RustWebHermes, + CompatReactIsland, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AiToolCall { + pub call_id: String, + pub tool_name: String, + pub args: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct AiEvent { + pub event_id: String, + pub session_id: String, + pub kind: AiEventKind, + #[serde(default)] + pub payload: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AiEventKind { + SessionCreated, + ToolCallPlanned, + ToolCallApplied, + StructuredWriteCompleted, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct AiStructuredWriteResult { + pub ok: bool, + pub write_kind: AiStructuredWriteKind, + pub command_name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub target_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub revision: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum AiStructuredWriteKind { + SummaryNode, + AiNoteNode, + ReferenceEdge, + PageBody, +} diff --git a/rust/crates/core-protocol/src/editor/mod.rs b/rust/crates/core-protocol/src/editor/mod.rs index 64636147..986f087b 100644 --- a/rust/crates/core-protocol/src/editor/mod.rs +++ b/rust/crates/core-protocol/src/editor/mod.rs @@ -20,7 +20,8 @@ pub use model::{ }; pub use tiptap::{ EditorBlockDocumentTiptapBridge, EditorBlockDocumentTiptapError, TiptapBlockType, - TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapNode, TiptapTaskItemAttrs, + TiptapBlockquoteAttrs, TiptapCodeBlockAttrs, TiptapHeadingAttrs, TiptapListAttrs, + TiptapListItemAttrs, TiptapMark, TiptapNode, TiptapParagraphAttrs, TiptapTaskItemAttrs, }; #[cfg(test)] diff --git a/rust/crates/core-protocol/src/lib.rs b/rust/crates/core-protocol/src/lib.rs index 9a563c75..1fb98566 100644 --- a/rust/crates/core-protocol/src/lib.rs +++ b/rust/crates/core-protocol/src/lib.rs @@ -1,12 +1,19 @@ pub mod command; pub mod common; +pub mod ai; pub mod editor; pub mod governance; pub mod kernel; pub mod mindmap; +pub mod page_aggregate; pub mod query; +pub mod search; pub mod tool; +pub use ai::{ + AiEvent, AiEventKind, AiRuntimeOwner, AiSession, AiStructuredWriteKind, + AiStructuredWriteResult, AiToolCall, +}; pub use command::{ CommandEnvelope, CopyTreeDocumentPages, CreateDocumentPage, CreatePage, CreateWorkspace, DeleteBlock, DeleteDocumentPage, DuplicateDocumentPage, EmbedBlock, InsertBlock, MoveBlock, @@ -42,12 +49,23 @@ pub use kernel::{ KernelProjectionResult, KernelProjectionRowKind, KernelRefsPayload, KernelSubtreeRef, KernelSubtreeResult, KernelTraverseGraph, KernelUpdateNode, }; -pub use mindmap::{MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, MindmapTreeNode}; +pub use mindmap::{ + MindmapCommand, MindmapNodeData, MindmapNodeInput, MindmapNodeRef, MindmapOp, + MindmapProjection, MindmapProjectionEdge, MindmapProjectionNode, MindmapProjectionOwner, + MindmapTreeNode, +}; +pub use page_aggregate::{ + PageAggregateProjection, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, + PageOptions, PagePermissions, PageStats, PageTree, +}; pub use query::{ GetBlock, GetBridgeCommand, GetBridgeRequest, GetBridgeTrace, GetMindmap, GetPage, GetPageContent, GetPageMeta, ListBridgeWorkspaceOverview, ListPageBlocks, ListSidebarDataset, QueryEnvelope, SearchBlocks, SearchDocuments, SearchPages, SearchRecent, }; +pub use search::{ + SearchHighlight, SearchProjectionOwner, SearchQuery, SearchResultProjection, SearchScope, +}; pub use tool::{ default_tool_registry, invocation_kind_label, tool_effect_label, tool_mode_label, InvocationKind, ToolEffect, ToolExecutionMode, ToolInvocation, ToolRegistry, ToolSetSpec, @@ -232,4 +250,116 @@ mod tests { assert!(recovery.write_toolset); assert_eq!(recovery.tool_names, &["event_replay", "index_rebuild"]); } + + #[test] + fn page_aggregate_projection_contract_covers_kernel_owned_fields() { + let projection = PageAggregateProjection { + schema: "mnote.page_aggregate.v1".into(), + projection_version: 1, + source: PageAggregateSource::KernelProjection, + page_id: "page_1".into(), + parent_id: Some("root".into()), + title: "Rust 页面".into(), + path: vec!["root".into(), "page_1".into()], + sidebar_tree_membership: vec!["workspace-sidebar".into()], + body_ref: Some("page_1:body".into()), + layout_options: serde_json::json!({"wideLayout": true}), + updated_at: Some("2026-04-29T00:00:00Z".into()), + identity: page_aggregate::PageIdentity { + document_id: "page_1".into(), + workspace_id: "ws_demo".into(), + }, + head: page_aggregate::PageHead { + title: "Rust 页面".into(), + updated_at: serde_json::json!("2026-04-29T00:00:00Z"), + permissions: page_aggregate::PagePermissions { + read_only: false, + disable_download: false, + disable_copy: false, + }, + }, + layout: page_aggregate::PageLayout { + page_options: page_aggregate::PageOptions::default(), + }, + body: page_aggregate::PageBody { + content: serde_json::json!([]), + revision: serde_json::json!(7), + conflict_detection_key: serde_json::json!("page_1:7"), + }, + tree: page_aggregate::PageTree { + page_subtree: serde_json::json!({"rootNodeId": "page_1"}), + }, + stats: page_aggregate::PageStats::default(), + }; + + assert_eq!(projection.source, PageAggregateSource::KernelProjection); + assert_eq!(projection.projection_version, 1); + assert_eq!(projection.page_id, "page_1"); + assert_eq!(projection.identity.document_id, "page_1"); + } + + #[test] + fn search_projection_contract_marks_owner_and_scope() { + let result = SearchResultProjection { + id: "page_1".into(), + title: "Rust 搜索".into(), + snippet: "Rust Web".into(), + updated_at: Some("2026-04-29T00:00:00Z".into()), + created_at: None, + match_field: "title".into(), + has_ocr: false, + public_path: "/documents/page_1".into(), + node_id: Some("page_1".into()), + subtree_root_id: Some("page_1".into()), + evidence: vec![], + score: 3.0, + projection_owner: SearchProjectionOwner::RustKernel, + }; + let query = SearchQuery { + query: "Rust".into(), + workspace_id: "ws_demo".into(), + scope: SearchScope::Workspace, + page_id: None, + limit: 20, + }; + + assert_eq!(query.scope, SearchScope::Workspace); + assert_eq!(result.projection_owner, SearchProjectionOwner::RustKernel); + } + + #[test] + fn ai_protocol_contract_exposes_structured_write_result() { + let result = AiStructuredWriteResult { + ok: true, + write_kind: AiStructuredWriteKind::SummaryNode, + command_name: "tree.node.create".into(), + target_id: Some("summary_1".into()), + revision: Some(2), + }; + + assert_eq!(result.write_kind, AiStructuredWriteKind::SummaryNode); + assert_eq!(result.command_name, "tree.node.create"); + } + + #[test] + fn mindmap_projection_and_command_contract_cover_kernel_truth() { + let projection = MindmapProjection { + schema: "mnote.mindmap_projection.v1".into(), + map_id: "mind_1".into(), + root_node: "root".into(), + nodes: vec![], + edges: vec![], + layout_hints: serde_json::json!({"layout": "right"}), + revision: 1, + owner: MindmapProjectionOwner::RustKernel, + }; + let command = MindmapCommand::RenameNode { + map_id: "mind_1".into(), + node_id: "root".into(), + title: "新标题".into(), + }; + + assert_eq!(projection.owner, MindmapProjectionOwner::RustKernel); + assert!(matches!(command, MindmapCommand::RenameNode { .. })); + } } diff --git a/rust/crates/core-protocol/src/mindmap.rs b/rust/crates/core-protocol/src/mindmap.rs index 38ba1d91..ba3195ba 100644 --- a/rust/crates/core-protocol/src/mindmap.rs +++ b/rust/crates/core-protocol/src/mindmap.rs @@ -2,6 +2,96 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::BTreeMap; +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct MindmapProjection { + pub schema: String, + pub map_id: String, + pub root_node: String, + #[serde(default)] + pub nodes: Vec, + #[serde(default)] + pub edges: Vec, + #[serde(default)] + pub layout_hints: Value, + pub revision: u64, + pub owner: MindmapProjectionOwner, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MindmapProjectionNode { + pub node_id: String, + pub title: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MindmapProjectionEdge { + pub edge_id: String, + pub from_node_id: String, + pub to_node_id: String, + pub edge_kind: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum MindmapProjectionOwner { + RustKernel, + CompatBlob, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum MindmapCommand { + CreateNode { + #[serde(rename = "mapId", alias = "map_id")] + map_id: String, + #[serde(rename = "parentId", alias = "parent_id")] + parent_id: String, + node: MindmapNodeInput, + }, + RenameNode { + #[serde(rename = "mapId", alias = "map_id")] + map_id: String, + #[serde(rename = "nodeId", alias = "node_id")] + node_id: String, + title: String, + }, + MoveNode { + #[serde(rename = "mapId", alias = "map_id")] + map_id: String, + #[serde(rename = "nodeId", alias = "node_id")] + node_id: String, + #[serde(rename = "newParentId", alias = "new_parent_id")] + new_parent_id: String, + #[serde(rename = "sortOrder", alias = "sort_order")] + sort_order: Option, + }, + DeleteNode { + #[serde(rename = "mapId", alias = "map_id")] + map_id: String, + #[serde(rename = "nodeId", alias = "node_id")] + node_id: String, + }, + SetLayout { + #[serde(rename = "mapId", alias = "map_id")] + map_id: String, + #[serde(rename = "layoutHints", alias = "layout_hints")] + layout_hints: Value, + }, + AttachPageRef { + #[serde(rename = "mapId", alias = "map_id")] + map_id: String, + #[serde(rename = "nodeId", alias = "node_id")] + node_id: String, + #[serde(rename = "pageId", alias = "page_id")] + page_id: String, + }, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct MindmapNodeRef { diff --git a/rust/crates/core-protocol/src/page_aggregate.rs b/rust/crates/core-protocol/src/page_aggregate.rs new file mode 100644 index 00000000..9252cc7f --- /dev/null +++ b/rust/crates/core-protocol/src/page_aggregate.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "PascalCase")] +pub enum PageAggregateSource { + KernelProjection, + CompatMetaContentJoin, + Fixture, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PageAggregateProjection { + pub schema: String, + pub projection_version: u32, + pub source: PageAggregateSource, + pub page_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + pub title: String, + #[serde(default)] + pub path: Vec, + #[serde(default)] + pub sidebar_tree_membership: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub body_ref: Option, + pub layout_options: Value, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + pub identity: PageIdentity, + pub head: PageHead, + pub layout: PageLayout, + pub body: PageBody, + pub tree: PageTree, + pub stats: PageStats, +} + +impl PageAggregateProjection { + pub const SCHEMA: &'static str = "mnote.page_aggregate.v1"; + pub const VERSION: u32 = 1; + + pub fn source_label(&self) -> &'static str { + match self.source { + PageAggregateSource::KernelProjection => "rust-kernel", + PageAggregateSource::CompatMetaContentJoin => "compat-join", + PageAggregateSource::Fixture => "fixture", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PageIdentity { + pub document_id: String, + pub workspace_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PageHead { + pub title: String, + pub updated_at: Value, + pub permissions: PagePermissions, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct PagePermissions { + pub read_only: bool, + pub disable_download: bool, + pub disable_copy: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PageLayout { + pub page_options: PageOptions, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PageOptions { + pub wide_layout: bool, + pub small_text: bool, + pub layout_density: String, + pub show_heading_numbers: bool, + pub show_toc: bool, + pub show_structure: bool, + pub protect_editing: bool, + pub show_word_count: bool, + pub collapse_backlinks: bool, + pub page_font: String, + pub hide_child_pages: bool, + pub show_block_ref_count: bool, + pub embed_default_block_id: Value, +} + +impl Default for PageOptions { + fn default() -> Self { + Self { + wide_layout: false, + small_text: false, + layout_density: "normal".into(), + show_heading_numbers: true, + show_toc: false, + show_structure: false, + protect_editing: false, + show_word_count: true, + collapse_backlinks: false, + page_font: "default".into(), + hide_child_pages: false, + show_block_ref_count: false, + embed_default_block_id: Value::Null, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PageBody { + pub content: Value, + pub revision: Value, + pub conflict_detection_key: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PageTree { + pub page_subtree: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "camelCase")] +pub struct PageStats { + pub word_count: u64, + pub character_count: u64, + pub block_count: u64, + pub todo_total: u64, + pub todo_done: u64, +} diff --git a/rust/crates/core-protocol/src/search.rs b/rust/crates/core-protocol/src/search.rs new file mode 100644 index 00000000..64e42fb4 --- /dev/null +++ b/rust/crates/core-protocol/src/search.rs @@ -0,0 +1,63 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct SearchQuery { + pub query: String, + pub workspace_id: String, + pub scope: SearchScope, + #[serde(skip_serializing_if = "Option::is_none")] + pub page_id: Option, + pub limit: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SearchScope { + Workspace, + CurrentPage, + Pinned, + Recent, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SearchResultProjection { + pub id: String, + pub title: String, + pub snippet: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub created_at: Option, + pub match_field: String, + pub has_ocr: bool, + pub public_path: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub node_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub subtree_root_id: Option, + #[serde(default)] + pub evidence: Vec, + pub score: f64, + pub projection_owner: SearchProjectionOwner, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SearchHighlight { + pub kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub node_id: Option, + pub snippet: String, + #[serde(default, skip_serializing_if = "Value::is_null")] + pub meta: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum SearchProjectionOwner { + RustKernel, + CompatIndex, +} diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index ac4d4d15..31463dad 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -42,7 +42,7 @@ impl AppConfig { .ok() .map(|value| value.trim().trim_end_matches('/').to_string()) .filter(|value| !value.is_empty()), - enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", true), + enable_legacy_next_compat: env_bool("MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT", false), enable_debug_shell_routes: env::var("MNOTE_WEB_ENABLE_DEBUG_SHELL_ROUTES") .ok() .map(|value| matches!(value.trim(), "1" | "true" | "TRUE" | "yes" | "YES")) diff --git a/rust/crates/mnote-web/src/page_aggregate.rs b/rust/crates/mnote-web/src/page_aggregate.rs index 37da0ee5..8e08cbb7 100644 --- a/rust/crates/mnote-web/src/page_aggregate.rs +++ b/rust/crates/mnote-web/src/page_aggregate.rs @@ -1,136 +1,11 @@ -//! 类型安全的 Page Aggregate 结构体,替代 serde_json::Value 的临时实现。 +//! Page Aggregate HTTP/SSR adapter。 //! -//! 参考契约文档: design/05-editor-mainline/done/5-5-1-page-aggregate-contract-v1.md +//! 核心契约定义在 `core-protocol`;mnote-web 仅保留 builder 便于 +//! 迁移期把 meta/content join 适配为同一个 projection。 pub mod builder; -use serde::Serialize; -use serde_json::Value; - -/// Page Aggregate 根结构 -/// -/// 对应 JSON 契约: -/// ```json -/// { -/// "schema": "mnote.page_aggregate.v1", -/// "identity": { ... }, -/// "head": { ... }, -/// "layout": { ... }, -/// "body": { ... }, -/// "tree": { ... }, -/// "stats": { ... } -/// } -/// ``` -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PageAggregate { - pub schema: String, - pub identity: PageIdentity, - pub head: PageHead, - pub layout: PageLayout, - pub body: PageBody, - pub tree: PageTree, - pub stats: PageStats, -} - -impl PageAggregate { - /// 创建一个新的 builder 用于构建 PageAggregate。 - pub fn builder() -> builder::PageAggregateBuilder { - builder::PageAggregateBuilder::new() - } - - /// 序列化为 `serde_json::Value`,输出与当前 `build_page_aggregate_snapshot` 完全一致的 JSON。 - /// - /// 使用 `#[serde(rename_all = "camelCase")]` 确保字段名与前端契约一致。 - pub fn to_json_value(&self) -> Value { - serde_json::to_value(self).expect("PageAggregate 序列化不应失败") - } - - /// 获取页面标题(`head.title`)的便捷方法。 - pub fn head_title(&self) -> &str { - &self.head.title - } -} - -/// 页面身份标识。 -/// -/// 标识这份页面聚合属于哪一个 page aggregate。 -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PageIdentity { - pub document_id: String, - pub workspace_id: String, -} - -/// 页面头部正式真相。 -/// -/// 承载页面头部的 title、updatedAt 及权限信息。 -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PageHead { - pub title: String, - pub updated_at: Value, - pub permissions: PagePermissions, -} - -/// 页面权限。 -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PagePermissions { - pub read_only: bool, - pub disable_download: bool, - pub disable_copy: bool, -} - -/// 页面布局。 -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PageLayout { - pub page_options: PageOptions, -} - -/// 页面选项 / 编辑器运行时设置。 -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PageOptions { - pub wide_layout: bool, - pub small_text: bool, - pub layout_density: String, - pub show_heading_numbers: bool, - pub show_toc: bool, - pub show_structure: bool, - pub protect_editing: bool, - pub show_word_count: bool, - pub collapse_backlinks: bool, - pub page_font: String, - pub hide_child_pages: bool, - pub show_block_ref_count: bool, - pub embed_default_block_id: Value, -} - -/// 页面正文内容与保存元数据。 -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PageBody { - pub content: Value, - pub revision: Value, - pub conflict_detection_key: Value, -} - -/// 当前页面对应的子树投影。 -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PageTree { - pub page_subtree: Value, -} - -/// 页面统计附属投影。 -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PageStats { - pub word_count: u64, - pub character_count: u64, - pub block_count: u64, - pub todo_total: u64, - pub todo_done: u64, -} +pub use core_protocol::{ + PageAggregateProjection as PageAggregate, PageAggregateSource, PageBody, PageHead, + PageIdentity, PageLayout, PageOptions, PagePermissions, PageStats, PageTree, +}; diff --git a/rust/crates/mnote-web/src/page_aggregate/builder.rs b/rust/crates/mnote-web/src/page_aggregate/builder.rs index b3af7129..abcfd9b1 100644 --- a/rust/crates/mnote-web/src/page_aggregate/builder.rs +++ b/rust/crates/mnote-web/src/page_aggregate/builder.rs @@ -10,10 +10,10 @@ //! ``` use crate::page_aggregate::{ - PageAggregate, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, PagePermissions, - PageStats, PageTree, + PageAggregate, PageAggregateSource, PageBody, PageHead, PageIdentity, PageLayout, PageOptions, + PagePermissions, PageStats, PageTree, }; -use serde_json::Value; +use serde_json::{to_value, Value}; /// PageAggregate 构建器。 /// @@ -23,6 +23,11 @@ use serde_json::Value; pub struct PageAggregateBuilder { document_id: String, workspace_id: String, + source: PageAggregateSource, + parent_id: Option, + path: Vec, + sidebar_tree_membership: Vec, + body_ref: Option, title: String, updated_at: Value, read_only: bool, @@ -58,6 +63,11 @@ impl PageAggregateBuilder { Self { document_id: String::new(), workspace_id: "default".to_string(), + source: PageAggregateSource::CompatMetaContentJoin, + parent_id: None, + path: Vec::new(), + sidebar_tree_membership: vec!["page-tree".into(), "file-tree".into()], + body_ref: None, title: "无标题".to_string(), updated_at: Value::Null, read_only: false, @@ -100,6 +110,31 @@ impl PageAggregateBuilder { self } + pub fn source(mut self, value: PageAggregateSource) -> Self { + self.source = value; + self + } + + pub fn parent_id(mut self, value: Option) -> Self { + self.parent_id = value; + self + } + + pub fn path(mut self, value: Vec) -> Self { + self.path = value; + self + } + + pub fn sidebar_tree_membership(mut self, value: Vec) -> Self { + self.sidebar_tree_membership = value; + self + } + + pub fn body_ref(mut self, value: Option) -> Self { + self.body_ref = value; + self + } + // ── Head ── pub fn title(mut self, value: impl Into) -> Self { @@ -249,8 +284,40 @@ impl PageAggregateBuilder { /// 消费 builder 并产出 `PageAggregate`。 pub fn build(self) -> PageAggregate { + let page_options = PageOptions { + wide_layout: self.wide_layout, + small_text: self.small_text, + layout_density: self.layout_density, + show_heading_numbers: self.show_heading_numbers, + show_toc: self.show_toc, + show_structure: self.show_structure, + protect_editing: self.protect_editing, + show_word_count: self.show_word_count, + collapse_backlinks: self.collapse_backlinks, + page_font: self.page_font, + hide_child_pages: self.hide_child_pages, + show_block_ref_count: self.show_block_ref_count, + embed_default_block_id: self.embed_default_block_id, + }; + let layout_options = to_value(&page_options).expect("PageOptions 序列化不应失败"); + let updated_at_text = self.updated_at.as_str().map(ToOwned::to_owned); + let page_id = self.document_id.clone(); PageAggregate { schema: "mnote.page_aggregate.v1".to_string(), + projection_version: PageAggregate::VERSION, + source: self.source, + page_id, + parent_id: self.parent_id, + title: self.title.clone(), + path: if self.path.is_empty() { + vec![self.document_id.clone()] + } else { + self.path + }, + sidebar_tree_membership: self.sidebar_tree_membership, + body_ref: self.body_ref, + layout_options, + updated_at: updated_at_text, identity: PageIdentity { document_id: self.document_id, workspace_id: self.workspace_id, @@ -265,21 +332,7 @@ impl PageAggregateBuilder { }, }, layout: PageLayout { - page_options: PageOptions { - wide_layout: self.wide_layout, - small_text: self.small_text, - layout_density: self.layout_density, - show_heading_numbers: self.show_heading_numbers, - show_toc: self.show_toc, - show_structure: self.show_structure, - protect_editing: self.protect_editing, - show_word_count: self.show_word_count, - collapse_backlinks: self.collapse_backlinks, - page_font: self.page_font, - hide_child_pages: self.hide_child_pages, - show_block_ref_count: self.show_block_ref_count, - embed_default_block_id: self.embed_default_block_id, - }, + page_options, }, body: PageBody { content: self.content, diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index 8666bddd..9cc8eac2 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -42,8 +42,8 @@ pub async fn next_ai_agent_run( trace_id: context.trace.trace_id, target: format!("{}/bridge", state.config().hermes_base_path), notes: vec![ - "当前保留 Next route 兼容边界,后续用于把 /api/ai-agent/run 收口到 Rust Web 层。", - "此占位实现不复制业务裁决,只声明桥接目标与迁移方向。", + "/api/ai-agent/run 是 legacy compat endpoint,canonical route 是 /api/hermes/bridge。", + "结构化写入必须通过 Hermes/Rust bridge 再落到 page/tree/edge command。", ], }) } diff --git a/rust/crates/mnote-web/src/routes/documents.rs b/rust/crates/mnote-web/src/routes/documents.rs index da0ea4cb..c518fc0c 100644 --- a/rust/crates/mnote-web/src/routes/documents.rs +++ b/rust/crates/mnote-web/src/routes/documents.rs @@ -46,6 +46,25 @@ pub struct DocumentSaveRequest { pub block_count: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentTitleRequest { + pub document_id: String, + pub workspace_id: Option, + pub title: String, + pub command_name: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DocumentOptionsRequest { + pub document_id: String, + pub workspace_id: Option, + #[serde(default)] + pub options: Value, + pub command_name: Option, +} + const NEXT_DOCUMENTS_BASE_URL_ENV: &str = "MNOTE_NEXT_BASE_URL"; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_DOCUMENTS_TRANSPORT: &str = "x-mnote-documents-transport"; @@ -484,7 +503,7 @@ pub async fn save( return Ok(ok_response(&context, result)); } let command = RuntimeCommandEnvelopeWire { - name: "documents.save".into(), + name: "page.body.save".into(), command_id: format!("document_save_{}", context.trace.request_id), idempotency_key: context.source.idempotency_key.clone(), actor: RuntimeActorWire { @@ -518,6 +537,73 @@ pub async fn save( dry_run: false, validate_only: false, }; + let mut result = execute_runtime_command_via_convex( + state.config(), + &context, + effective_workspace_id.as_deref(), + command, + ) + .await?; + if let Value::Object(map) = &mut result { + map.insert("executedCommand".into(), json!("page.body.save")); + map.insert("canonicalCommand".into(), json!("page.body.save")); + map.insert("compatRoute".into(), json!("/api/documents/save")); + } + Ok(ok_response(&context, result)) +} + +pub async fn title( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let document_id = body.document_id.trim(); + if document_id.is_empty() { + return Err( + WebError::bad_request_code("document_id_required", "缺少有效 documentId") + .with_context(&context), + ); + } + let title = body.title.trim(); + if title.is_empty() { + return Err( + WebError::bad_request_code("title_required", "缺少有效页面标题") + .with_context(&context), + ); + } + let effective_workspace_id = + resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?; + let command = RuntimeCommandEnvelopeWire { + name: "page.head.updateTitle".into(), + command_id: format!("page_title_{}", context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: context.auth.session_id.clone(), + }, + source: RuntimeSourceWire { + channel: context.source.channel.clone(), + client: context.source.client.clone(), + }, + target: Some(RuntimeTargetWire { + workspace_id: effective_workspace_id.clone(), + page_id: Some(document_id.to_string()), + block_id: None, + }), + payload: json!({ + "documentId": document_id, + "workspaceId": effective_workspace_id, + "title": title, + }), + preflight_data: None, + reason: Some("mnote-web page title update".into()), + refs: vec![body + .command_name + .unwrap_or_else(|| "page.head.updateTitle".into())], + dry_run: false, + validate_only: false, + }; let result = execute_runtime_command_via_convex( state.config(), &context, @@ -525,7 +611,94 @@ pub async fn save( command, ) .await?; - Ok(ok_response(&context, result)) + let mut headers = HeaderMap::new(); + stamp_documents_headers(&mut headers); + Ok(( + StatusCode::OK, + headers, + Json(json!({ + "ok": true, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "owner": "mnote-web", + "meta": { + "commandName": "page.head.updateTitle", + "canonicalCommand": "page.head.updateTitle", + }, + "result": result, + })), + )) +} + +pub async fn options( + State(state): State, + Extension(context): Extension, + Json(body): Json, +) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let document_id = body.document_id.trim(); + if document_id.is_empty() { + return Err( + WebError::bad_request_code("document_id_required", "缺少有效 documentId") + .with_context(&context), + ); + } + let effective_workspace_id = + resolve_effective_workspace_id(&context, body.workspace_id.as_deref(), false)?; + let command = RuntimeCommandEnvelopeWire { + name: "page.layout.updateOptions".into(), + command_id: format!("page_options_{}", context.trace.request_id), + idempotency_key: context.source.idempotency_key.clone(), + actor: RuntimeActorWire { + actor_type: context.auth.actor_type.clone(), + actor_id: context.auth.actor_id.clone(), + session_id: context.auth.session_id.clone(), + }, + source: RuntimeSourceWire { + channel: context.source.channel.clone(), + client: context.source.client.clone(), + }, + target: Some(RuntimeTargetWire { + workspace_id: effective_workspace_id.clone(), + page_id: Some(document_id.to_string()), + block_id: None, + }), + payload: json!({ + "documentId": document_id, + "workspaceId": effective_workspace_id, + "options": body.options, + }), + preflight_data: None, + reason: Some("mnote-web page layout update".into()), + refs: vec![body + .command_name + .unwrap_or_else(|| "page.layout.updateOptions".into())], + dry_run: false, + validate_only: false, + }; + let result = execute_runtime_command_via_convex( + state.config(), + &context, + effective_workspace_id.as_deref(), + command, + ) + .await?; + let mut headers = HeaderMap::new(); + stamp_documents_headers(&mut headers); + Ok(( + StatusCode::OK, + headers, + Json(json!({ + "ok": true, + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "owner": "mnote-web", + "meta": { + "commandName": "page.layout.updateOptions", + "canonicalCommand": "page.layout.updateOptions", + }, + "result": result, + })), + )) } #[cfg(test)] @@ -597,6 +770,14 @@ mod tests { ), mutation_fixtures_json: Some( r#"{ + "documents:updateTitle": { + "ok": true, + "title": "服务端页面(改名)" + }, + "documents:updateOptions": { + "ok": true, + "show_toc": false + }, "documents:updateContent": { "ok": true, "updated_at": "2026-04-18T09:45:00Z", @@ -691,7 +872,7 @@ mod tests { } #[tokio::test] - async fn documents_save_route_executes_documents_save_command() { + async fn documents_save_route_executes_page_body_save_command() { let response = app() .oneshot( Request::builder() @@ -728,5 +909,71 @@ mod tests { let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["result"]["revision"], 8); assert_eq!(payload["result"]["conflict_detection_key"], "doc_1:8"); + assert_eq!(payload["result"]["executedCommand"], "page.body.save"); + assert_eq!(payload["result"]["canonicalCommand"], "page.body.save"); + } + + #[tokio::test] + async fn documents_title_route_executes_page_head_update_title() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/documents/title") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "documentId": "doc_1", + "workspaceId": "ws_demo", + "title": "服务端页面(改名)", + "commandName": "page.head.updateTitle" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + 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["meta"]["commandName"], "page.head.updateTitle"); + assert_eq!(payload["meta"]["canonicalCommand"], "page.head.updateTitle"); + } + + #[tokio::test] + async fn documents_options_route_executes_page_layout_update_options() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/documents/options") + .header("content-type", "application/json") + .body(Body::from( + serde_json::json!({ + "documentId": "doc_1", + "workspaceId": "ws_demo", + "options": { + "showToc": false + }, + "commandName": "page.layout.updateOptions" + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + 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["meta"]["commandName"], "page.layout.updateOptions"); + assert_eq!(payload["meta"]["canonicalCommand"], "page.layout.updateOptions"); } } diff --git a/rust/crates/mnote-web/src/routes/hermes.rs b/rust/crates/mnote-web/src/routes/hermes.rs index 3a02c835..cc648d08 100644 --- a/rust/crates/mnote-web/src/routes/hermes.rs +++ b/rust/crates/mnote-web/src/routes/hermes.rs @@ -39,16 +39,48 @@ pub async fn health( pub async fn bridge_runtime( Extension(context): Extension, - Json(runtime_input): Json, + Json(payload): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { + let session_id = format!("hermes_{}", context.trace.request_id); + let runtime_input = match serde_json::from_value::(payload.clone()) { + Ok(runtime_input) => runtime_input, + Err(_) => { + return Ok(( + StatusCode::OK, + stamp_ai_bridge_headers(), + Json(json!({ + "ok": true, + "bridge": "hermes_session", + "sessionId": session_id, + "eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id), + "canonicalRoute": "/api/hermes/bridge", + "requestId": context.trace.request_id, + "traceId": context.trace.trace_id, + "contract": ai_bridge_contract(&session_id), + "structuredWrite": { + "owner": "rust-web-hermes", + "allowedCommands": [ + "page.body.save", + "tree.node.create", + "kernel.edge.attach" + ] + }, + "compatPayload": payload, + })), + )); + } + }; let payload = if runtime_input_requests_result(&runtime_input) { match execute_runtime_query(runtime_input) { Ok(result) => json!({ "ok": true, "bridge": "hermes_runtime_result", + "sessionId": session_id, + "eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id), + "canonicalRoute": "/api/hermes/bridge", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, - "contract": ai_bridge_contract(), + "contract": ai_bridge_contract(&session_id), "result": result, }), Err(error) => { @@ -67,9 +99,12 @@ pub async fn bridge_runtime( json!({ "ok": success.ok, "bridge": "hermes_runtime_plan", + "sessionId": session_id, + "eventStreamEndpoint": format!("/api/hermes/events/{}", context.trace.request_id), + "canonicalRoute": "/api/hermes/bridge", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, - "contract": ai_bridge_contract(), + "contract": ai_bridge_contract(&session_id), "plan": success.plan, }) } @@ -87,14 +122,18 @@ pub async fn bridge_runtime( Ok((StatusCode::OK, stamp_ai_bridge_headers(), Json(payload))) } -fn ai_bridge_contract() -> Value { +fn ai_bridge_contract(session_id: &str) -> Value { json!({ "schema": "mnote.ai_bridge.v1", "owner": "mnote-web", "bridge": "hermes", + "sessionId": session_id, + "eventStreamEndpoint": format!("/api/hermes/events/{session_id}"), + "canonicalRoute": "/api/hermes/bridge", "sessionOwner": "rust-web-hermes", "toolEventOwner": "rust-web-hermes", - "clientActionOwner": "rust-web-hermes" + "clientActionOwner": "rust-web-hermes", + "structuredWriteOwner": "rust-web-hermes" }) } @@ -212,5 +251,39 @@ mod tests { assert_eq!(payload["contract"]["sessionOwner"], "rust-web-hermes"); assert_eq!(payload["contract"]["toolEventOwner"], "rust-web-hermes"); assert_eq!(payload["contract"]["clientActionOwner"], "rust-web-hermes"); + assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge"); + assert!(payload["eventStreamEndpoint"].as_str().unwrap_or_default().contains("/api/hermes/events/")); + } + + #[tokio::test] + async fn ai_bridge_accepts_legacy_intent_payload_as_hermes_session() { + let response = app() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/hermes/bridge") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "stream": true, + "scope": "document", + "messages": [{"role": "user", "content": "生成摘要"}], + "context": {"documentId": "doc_1"} + }) + .to_string(), + )) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + 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["bridge"], "hermes_session"); + assert_eq!(payload["canonicalRoute"], "/api/hermes/bridge"); + assert_eq!(payload["contract"]["structuredWriteOwner"], "rust-web-hermes"); } } diff --git a/rust/crates/mnote-web/src/routes/mindmap_shell.rs b/rust/crates/mnote-web/src/routes/mindmap_shell.rs index 228e90c7..ef2a938f 100644 --- a/rust/crates/mnote-web/src/routes/mindmap_shell.rs +++ b/rust/crates/mnote-web/src/routes/mindmap_shell.rs @@ -21,12 +21,15 @@ pub async fn mindmap_object_shell( "mindmapId": mindmap_id, "projection": { "schema": "mnote.mindmap_projection.v1", - "source": "rust-web-object-shell" + "source": "rust-kernel", + "owner": "rust-kernel", + "queryName": "mindmap.projection.get" }, "island": { "kind": "react_mindmap_runtime", "mountId": "mnote-mindmap-island", - "legacyCompat": "next-app-router" + "runtimeRole": "renderer_adapter", + "commandName": "mindmap.command.apply" }, "requestId": context.trace.request_id, "traceId": context.trace.trace_id @@ -145,6 +148,8 @@ mod tests { let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("mnote.mindmap_shell.v1")); assert!(html.contains("data-react-island=\"mindmap_runtime\"")); - assert!(html.contains("rust-web-object-shell")); + assert!(html.contains("mindmap.projection.get")); + assert!(html.contains("mindmap.command.apply")); + assert!(!html.contains("next-app-router")); } } diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index 4b4c3953..f51115f1 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -59,6 +59,8 @@ pub fn build_router(state: AppState) -> Router { .route("/api/auth/session/refresh", post(session::refresh_session)) .route("/api/documents/meta", get(documents::meta)) .route("/api/documents/content", get(documents::content)) + .route("/api/documents/title", post(documents::title)) + .route("/api/documents/options", post(documents::options)) .route("/api/documents/save", post(documents::save)) .route( "/api/documents/runtime/transform", diff --git a/rust/crates/mnote-web/src/routes/search.rs b/rust/crates/mnote-web/src/routes/search.rs index ef9518d7..1b4c2059 100644 --- a/rust/crates/mnote-web/src/routes/search.rs +++ b/rust/crates/mnote-web/src/routes/search.rs @@ -2,7 +2,8 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::routes::query_support::{ - execute_runtime_query_against_data, resolve_effective_workspace_id, + execute_runtime_query_against_data, execute_runtime_query_via_convex, + resolve_effective_workspace_id, }; use crate::routes::web_shell::load_sidebar_tree_html; use crate::ssr::pages::search::SearchPage; @@ -68,18 +69,30 @@ pub async fn shell( .await .unwrap_or_default(); let search_query = query.q.as_deref().map(str::trim).unwrap_or(""); + let initial_results = load_search_results( + state.config(), + &context, + workspace_id, + search_query, + None, + 20, + ) + .await + .unwrap_or_else(|_| empty_search_projection()); let contract = json!({ "schema": "mnote.search_shell.v1", "owner": "mnote-web", + "projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "shell": "search", "workspaceId": workspace_id, "query": search_query, "initialResults": { - "queryName": "search.documents", - "results": [] + "queryName": "search.documents.query", + "projectionOwner": initial_results.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), + "results": initial_results.get("results").cloned().unwrap_or_else(|| Value::Array(vec![])) }, "island": { - "kind": "react_search_palette", + "kind": "search_interaction_island", "mountId": "mnote-search-island", "runtime": "SearchPaletteHost" }, @@ -92,6 +105,7 @@ pub async fn shell( workspace_id={workspace_id.to_string()} search_query={search_query.to_string()} sidebar_tree_html={sidebar_tree_html} + initial_results_html={render_initial_results_html(initial_results.get("results").and_then(Value::as_array))} /> }); let html = format!( @@ -122,7 +136,7 @@ pub async fn shell( } pub async fn documents( - State(_state): State, + State(state): State, Extension(context): Extension, Json(body): Json, ) -> Result<(StatusCode, HeaderMap, Json), WebError> { @@ -137,49 +151,16 @@ pub async fn documents( None }; - let result = if normalized_query.is_empty() { - json!({ - "enqueueAssetIds": [], - "results": [], - }) - } else { - execute_runtime_query_against_data( - &context, - Some(&effective_workspace_id), - RuntimeQueryEnvelopeWire { - name: "search.documents".into(), - payload: json!({ - "query": normalized_query, - "workspaceId": effective_workspace_id, - "pageId": page_id, - "limit": body.limit.unwrap_or(30), - "titleOnly": filters.title_only.unwrap_or(false), - "exact": filters.exact.unwrap_or(false), - "includeOcr": filters.include_ocr.unwrap_or(false), - "timeRange": filters.time_range.unwrap_or_else(|| "any".into()), - "timeField": filters.time_field.unwrap_or_else(|| "updated".into()), - "customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()), - "customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()), - }), - }, - json!({ - "documents": [ - { - "id": "doc_1", - "workspaceId": effective_workspace_id, - "title": "Rust Web 搜索结果", - "rawText": "mnote-web search documents transport", - "createdAt": "2026-04-28T00:00:00Z", - "updatedAt": "2026-04-28T00:00:00Z" - } - ], - "mindmaps": [], - "tables": [], - "tableRows": [], - "assets": [] - }), - )? - }; + let result = load_search_results_with_filters( + state.config(), + &context, + &effective_workspace_id, + &normalized_query, + page_id, + body.limit.unwrap_or(30), + filters, + ) + .await?; let mut headers = HeaderMap::new(); stamp_search_headers(&mut headers); @@ -188,10 +169,12 @@ pub async fn documents( headers, Json(json!({ "results": result.get("results").cloned().unwrap_or(Value::Array(vec![])), + "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), "recent": [], "meta": { "owner": "mnote-web", - "queryName": "search.documents", + "projectionOwner": result.get("projectionOwner").cloned().unwrap_or_else(|| json!("rust-kernel")), + "queryName": "search.documents.query", "requestId": context.trace.request_id, "traceId": context.trace.trace_id, }, @@ -199,10 +182,138 @@ pub async fn documents( )) } +async fn load_search_results( + config: &crate::app::AppConfig, + context: &RequestContext, + workspace_id: &str, + query: &str, + page_id: Option, + limit: u32, +) -> Result { + load_search_results_with_filters( + config, + context, + workspace_id, + query, + page_id, + limit, + SearchDocumentsFilters::default(), + ) + .await +} + +async fn load_search_results_with_filters( + config: &crate::app::AppConfig, + context: &RequestContext, + workspace_id: &str, + query: &str, + page_id: Option, + limit: u32, + filters: SearchDocumentsFilters, +) -> Result { + if query.trim().is_empty() { + return Ok(empty_search_projection()); + } + let runtime_query = RuntimeQueryEnvelopeWire { + name: "search.documents.query".into(), + payload: json!({ + "query": query, + "workspaceId": workspace_id, + "pageId": page_id, + "limit": limit, + "titleOnly": filters.title_only.unwrap_or(false), + "exact": filters.exact.unwrap_or(false), + "includeOcr": filters.include_ocr.unwrap_or(false), + "timeRange": filters.time_range.unwrap_or_else(|| "any".into()), + "timeField": filters.time_field.unwrap_or_else(|| "updated".into()), + "customRangeFrom": filters.custom_range.as_ref().and_then(|range| range.from.clone()), + "customRangeTo": filters.custom_range.as_ref().and_then(|range| range.to.clone()), + }), + }; + match execute_runtime_query_via_convex(config, context, Some(workspace_id), runtime_query.clone()).await { + Ok(value) => Ok(value), + Err(_) => execute_runtime_query_against_data( + context, + Some(workspace_id), + runtime_query, + fallback_search_dataset(workspace_id), + ), + } +} + +fn empty_search_projection() -> Value { + json!({ + "enqueueAssetIds": [], + "projectionOwner": "rust-kernel", + "results": [], + }) +} + +fn fallback_search_dataset(workspace_id: &str) -> Value { + json!({ + "documents": [ + { + "id": "doc_1", + "workspaceId": workspace_id, + "title": "Rust Web 搜索结果", + "rawText": "mnote-web search documents transport", + "createdAt": "2026-04-28T00:00:00Z", + "updatedAt": "2026-04-28T00:00:00Z" + } + ], + "mindmaps": [], + "tables": [], + "tableRows": [], + "assets": [] + }) +} + +fn render_initial_results_html(results: Option<&Vec>) -> String { + let Some(results) = results else { + return r#"
暂无结果
"#.into(); + }; + if results.is_empty() { + return r#"
暂无结果
"#.into(); + } + let items = results + .iter() + .map(|item| { + let title = item + .get("title") + .and_then(Value::as_str) + .unwrap_or("无标题"); + let snippet = item + .get("snippet") + .and_then(Value::as_str) + .unwrap_or_default(); + let href = item + .get("publicPath") + .and_then(Value::as_str) + .unwrap_or("#"); + format!( + r#"{}{}"#, + escape_html(href), + escape_html(title), + escape_html(snippet) + ) + }) + .collect::>() + .join(""); + format!(r#"
{items}
"#) +} + fn escape_script_json(value: &str) -> String { value.replace(" String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + fn stamp_search_headers(headers: &mut HeaderMap) { if let Ok(name) = HeaderName::from_lowercase(HEADER_MNOTE_WEB_OWNER.as_bytes()) { headers.insert(name, HeaderValue::from_static("mnote-web")); @@ -275,8 +386,10 @@ mod tests { let html = String::from_utf8(body.to_vec()).expect("utf8"); assert!(html.contains("data-mnote-shell=\"search\"")); assert!(html.contains("mnote.search_shell.v1")); - assert!(html.contains("react_search_palette")); - assert!(html.contains("search.documents")); + assert!(html.contains("search_interaction_island")); + assert!(html.contains("search.documents.query")); + assert!(html.contains("search-result")); + assert!(html.contains("Rust Web 搜索结果")); } #[tokio::test] @@ -327,6 +440,8 @@ mod tests { .expect("body"); let payload: Value = serde_json::from_slice(&body).expect("json"); assert_eq!(payload["meta"]["owner"], "mnote-web"); - assert_eq!(payload["meta"]["queryName"], "search.documents"); + assert_eq!(payload["meta"]["queryName"], "search.documents.query"); + assert_eq!(payload["meta"]["projectionOwner"], "rust-kernel"); + assert_eq!(payload["projectionOwner"], "rust-kernel"); } } diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index e834e60b..ca07db7b 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -153,8 +153,19 @@ struct StreamPollState { } fn stream_event(event_name: &str, payload: &Value) -> Event { + let event_id = payload + .get("revision") + .and_then(|value| { + value + .as_str() + .map(ToOwned::to_owned) + .or_else(|| value.as_u64().map(|number| number.to_string())) + }) + .or_else(|| payload.get("cursor").and_then(Value::as_str).map(ToOwned::to_owned)) + .unwrap_or_else(|| "0".into()); Event::default() .event(event_name) + .id(event_id) .json_data(payload) .expect("SSE 事件必须可序列化") } @@ -245,4 +256,25 @@ mod tests { assert!(text.contains("event: snapshot") || text.contains("event:snapshot")); assert!(text.contains("\"kind\":\"snapshot\"")); } + + #[tokio::test] + async fn tree_events_route_includes_event_id_and_revision() { + let response = app() + .oneshot( + Request::builder() + .uri("/api/tree/events?workspaceId=ws_demo&maxPolls=0") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let text = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(text.contains("id: ")); + assert!(text.contains("\"revision\"")); + } } diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs index 451bf1d8..0187c0ac 100644 --- a/rust/crates/mnote-web/src/routes/stream_support.rs +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -477,6 +477,7 @@ pub fn build_stream_delta_payload( let scope = resolve_stream_scope(query); json!({ "kind": "delta", + "revision": cursor.clone().unwrap_or_else(|| "0".into()), "stream": scope.as_str(), "projection": scope.projection(), "requestId": context.trace.request_id, @@ -570,6 +571,7 @@ pub async fn load_stream_snapshot( Ok(json!({ "kind": "snapshot", + "revision": cursor.clone().unwrap_or_else(|| "0".into()), "scope": scope.as_str(), "stream": scope.as_str(), "projection": scope.projection(), diff --git a/rust/crates/mnote-web/src/routes/web_shell.rs b/rust/crates/mnote-web/src/routes/web_shell.rs index d9633bb7..28d675b7 100644 --- a/rust/crates/mnote-web/src/routes/web_shell.rs +++ b/rust/crates/mnote-web/src/routes/web_shell.rs @@ -6,6 +6,7 @@ use crate::routes::documents::{ load_document_content_result, load_document_meta_result, DocumentContentQuery, DocumentMetaQuery, }; +use crate::routes::query_support::execute_runtime_query_against_data; use crate::routes::snapshot_support::{ execute_kernel_query, load_projection_snapshot, projection_query, ProjectionSnapshotSpec, }; @@ -24,9 +25,10 @@ use axum::extract::{Extension, Path, Query, State}; use axum::http::{header, HeaderMap, HeaderName, HeaderValue, StatusCode}; use axum::response::{Html, IntoResponse, Response}; use axum::Json; +use bridge_runtime::RuntimeQueryEnvelopeWire; use core_protocol::KernelProjectionKind; use serde::Deserialize; -use serde_json::{json, Value}; +use serde_json::json; use std::path::{Component, Path as FsPath, PathBuf}; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; @@ -52,7 +54,7 @@ pub async fn document_page_shell( query.workspace_id.as_deref(), ) .await?; - let title = aggregate.head_title(); + let title = aggregate.head.title.as_str(); let workspace_id = aggregate.identity.workspace_id.clone(); let default_workspace_name = format!("{} 的空间", state.config().dev_user_name); let mut workspace_projection = load_workspace_shell_projection( @@ -86,6 +88,7 @@ pub async fn document_page_shell( {} {} + {} "#, escape_html(title), @@ -113,6 +117,7 @@ pub async fn document_page_shell( body_content, escape_script_json(&snapshot_json), escape_script_json(&bootstrap_json), + render_document_title_controller_script(), render_editor_island_adapter_script(), ); let mut response = Html(html).into_response(); @@ -128,6 +133,7 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte "workspaceId": aggregate.identity.workspace_id, "pageAggregateScriptId": "__MNOTE_PAGE_AGGREGATE__", "saveEndpoint": "/api/documents/save", + "titleEndpoint": "/api/documents/title", "editorHostKind": "leptos_tiptap_island", "assetMode": "rust-web-leptos-tiptap-spike-island-bundle", "requestId": context.trace.request_id, @@ -136,6 +142,123 @@ fn build_editor_bootstrap_json(aggregate: &PageAggregate, context: &RequestConte .unwrap_or_else(|_| "{}".to_string()) } +fn render_document_title_controller_script() -> &'static str { + r#""# +} + fn render_editor_island_adapter_script() -> &'static str { r#" +
@@ -225,7 +812,7 @@ pub fn PageLayout(
@@ -248,3 +835,34 @@ pub fn PageLayout(
} } + +#[cfg(test)] +mod tests { + use super::{SIDEBAR_TREE_JS, TREE_LIVE_CONTROLLER_JS}; + + #[test] + fn sidebar_tree_runtime_handles_navigation_drag_and_filetree_actions() { + assert!(SIDEBAR_TREE_JS.contains("mnoteNavigationInFlight")); + assert!(SIDEBAR_TREE_JS.contains("data-mnote-navigation-pending")); + assert!(SIDEBAR_TREE_JS.contains("application/x-mnote-page-tree-node")); + assert!(SIDEBAR_TREE_JS.contains("dragstart")); + assert!(SIDEBAR_TREE_JS.contains("drop")); + assert!(SIDEBAR_TREE_JS.contains("data-drop-feedback")); + assert!(SIDEBAR_TREE_JS.contains("action: 'move'")); + assert!(SIDEBAR_TREE_JS.contains("sidebar-file-tree-root")); + assert!(SIDEBAR_TREE_JS.contains("tree.filetree.open")); + assert!(SIDEBAR_TREE_JS.contains("tree.filetree.internal-drop")); + assert!(SIDEBAR_TREE_JS.contains("tree.filetree.external-drop")); + } + + #[test] + fn tree_live_controller_marks_transport_and_closes_source_on_pagehide() { + assert!(TREE_LIVE_CONTROLLER_JS.contains("convex-command-log-sse")); + 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("tree:snapshot")); + assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta")); + assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync")); + } +} diff --git a/rust/crates/mnote-web/src/ssr/pages/search.rs b/rust/crates/mnote-web/src/ssr/pages/search.rs index c720b4a0..8a1d7e82 100644 --- a/rust/crates/mnote-web/src/ssr/pages/search.rs +++ b/rust/crates/mnote-web/src/ssr/pages/search.rs @@ -9,7 +9,7 @@ use leptos::prelude::*; /// MNOTE 搜索页面 /// /// 渲染搜索页面的 SSR 壳结构: -/// - `
` +/// - `
` /// - 搜索标题区域 /// - 搜索岛占位区 #[component] @@ -26,10 +26,14 @@ pub fn SearchPage( /// 工作区名称(可选) #[prop(optional)] workspace_name: Option, + /// 服务端首批搜索结果 HTML(可选) + #[prop(optional)] + initial_results_html: Option, ) -> impl IntoView { + let initial_results_html = initial_results_html.unwrap_or_default(); view! { -
+

{"搜索"}

@@ -43,7 +47,8 @@ pub fn SearchPage( }}

-
+
+
} @@ -60,9 +65,9 @@ mod tests { }); assert!(html.contains("mnote-search-shell")); - assert!(html.contains("data-island-host=\"react_search_palette\"")); + assert!(html.contains("data-island-host=\"search_interaction_island\"")); assert!(html.contains("mnote-search-island")); - assert!(html.contains("data-react-island=\"search_palette\"")); + assert!(html.contains("mnote-search-results")); assert!(html.contains("搜索")); } diff --git a/rust/crates/mnote-web/src/ssr/styles.rs b/rust/crates/mnote-web/src/ssr/styles.rs index f2f6d1b1..118b2432 100644 --- a/rust/crates/mnote-web/src/ssr/styles.rs +++ b/rust/crates/mnote-web/src/ssr/styles.rs @@ -69,6 +69,30 @@ a:hover { color: var(--wolai-accent-hover); } +.mnote-symbol { + width: 18px; + height: 18px; + display: inline-flex; + flex: 0 0 auto; + color: currentColor; + fill: none; + stroke: currentColor; + stroke-width: 1.9; + stroke-linecap: round; + stroke-linejoin: round; + vertical-align: -0.18em; +} + +.mnote-symbol[data-icon="home"] { + stroke-width: 2; +} + +.mnote-symbol--document { + width: 56px; + height: 56px; + stroke-width: 1.35; +} + /* ===== 滚动条(悬停时显示) ===== */ ::-webkit-scrollbar { width: 8px; @@ -1138,6 +1162,13 @@ body { color: var(--atelier-text); } +.sidebar-tree .tree-row[data-drop-feedback="true"], +.sidebar-tree .tree-row[data-drop-target="true"], +.sidebar-tree .tree-root[data-drop-target="true"] { + background: rgba(0, 110, 40, 0.12); + box-shadow: inset 0 0 0 1px rgba(0, 110, 40, 0.28); +} + .sidebar-tree .tree-toggle, .sidebar-tree .tree-spacer { width: 16px; @@ -1371,11 +1402,11 @@ body { display: flex; align-items: center; color: #111111; - font-size: 72px; line-height: 1; margin-bottom: 12px; } +.document-title-heading, .document-shell-header h1 { padding: 0; margin: 0 0 14px; @@ -1387,6 +1418,32 @@ body { text-align: left; } +.document-title-input { + width: 100%; + min-height: 52px; + display: block; + resize: none; + overflow: hidden; + border: 0; + outline: none; + background: transparent; + color: inherit; + font: inherit; + line-height: inherit; + letter-spacing: 0; + text-align: inherit; +} + +.document-title-input::placeholder { + color: #B8B5AF; +} + +.document-title-input[data-title-save-status="error"] { + text-decoration: underline; + text-decoration-color: #D6545D; + text-underline-offset: 6px; +} + .document-shell-meta { display: flex; align-items: center; @@ -1439,6 +1496,24 @@ body { padding: 0 !important; } +#mnote-leptos-tiptap-island-editor-root .block-handle-shell { + left: -52px !important; + z-index: 5; + opacity: 0.2; + pointer-events: none; + transition: opacity 120ms ease, transform 120ms ease; +} + +#mnote-leptos-tiptap-island-editor-root .block-handle-shell:hover, +#mnote-leptos-tiptap-island-editor-root .block-handle-shell[data-dragging="true"] { + opacity: 0.6; +} + +#mnote-leptos-tiptap-island-editor-root .block-handle-shell .block-handle-insert, +#mnote-leptos-tiptap-island-editor-root .block-handle-shell .block-handle-trigger { + pointer-events: auto; +} + #mnote-leptos-tiptap-island-editor-root .ProseMirror p { margin: 0 0 24px; } @@ -1530,12 +1605,20 @@ body { .document-page-icon { height: 60px; - font-size: 54px; + } + + .document-page-icon .mnote-symbol--document { + width: 44px; + height: 44px; } .document-shell-header h1 { font-size: 32px; } + + #mnote-leptos-tiptap-island-editor-root .block-handle-shell { + left: -36px !important; + } } @media (max-width: 480px) { @@ -1613,6 +1696,16 @@ mod tests { assert!(MNOTE_CSS.contains(".ProseMirror")); } + #[test] + fn mnote_css_keeps_tiptap_handles_in_margin_and_title_editable() { + assert!(MNOTE_CSS.contains(".document-title-input")); + assert!(MNOTE_CSS.contains(".block-handle-shell")); + assert!(MNOTE_CSS.contains("left: -52px !important")); + assert!(MNOTE_CSS.contains("pointer-events: none")); + assert!(MNOTE_CSS.contains(".mnote-symbol")); + assert!(MNOTE_CSS.contains("data-icon=\"home\"")); + } + #[test] fn mnote_css_is_reasonably_sized() { // 至少 2000 字符才能包含完整样式 diff --git a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs index c0add278..bae084d8 100644 --- a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs @@ -57,18 +57,46 @@ fn render_filetree_row( row: &FileTreeRenderRow, children_by_parent: &BTreeMap, Vec>, ) { + let toggle_html = if row.expandable { + format!( + r#""#, + row_id = escape_html(&row.row_id), + label = if row.expanded { "折叠" } else { "展开" }, + title = escape_html(&row.title), + marker = if row.expanded { "▾" } else { "▸" }, + ) + } else { + r#""#.to_string() + }; + let parent_attr = row + .parent_node_id + .as_deref() + .map(|parent_id| format!(r#" data-parent-id="{}""#, escape_html(parent_id))) + .unwrap_or_default(); + let create_action_html = if row.row_kind == "document" { + format!( + r#""#, + row_id = escape_html(&row.row_id), + node_id = escape_html(&row.node_id), + ) + } else { + String::new() + }; html.push_str(&format!( - r#"
  • "#, + r#"
  • {toggle_html}
    {create_action_html}
    "#, node_id = escape_html(&row.node_id), aria_level = row.depth + 1, expanded_attr = if row.expandable && row.expanded { "true" } else { "false" }, test_id = row_test_id(&row.row_kind), row_id = escape_html(&row.row_id), row_kind = escape_html(&row.row_kind), + parent_attr = parent_attr, document_id = escape_html(row.document_id.as_deref().unwrap_or_default()), asset_id = escape_html(row.asset_id.as_deref().unwrap_or_default()), selected = row.selected, + toggle_html = toggle_html, icon_kind = escape_html(&row.icon_kind), + create_action_html = create_action_html, title = escape_html(&row.title), )); if row.expandable && row.expanded { @@ -165,6 +193,7 @@ mod tests { assert!(html.contains("data-rust-rendered-row=\"filetree\"")); assert!(html.contains("data-testid=\"filetree-doc-row\"")); assert!(html.contains("data-testid=\"filetree-index-row\"")); + assert!(html.contains("data-doc-id=\"page_root\"")); assert!(html.contains("tree-children")); assert!(html.contains("首页 <安全>")); assert!(html.contains("data-selected=\"true\"")); diff --git a/rust/crates/mnote-web/src/workspace_shell.rs b/rust/crates/mnote-web/src/workspace_shell.rs index 1487b9f4..ce86a15d 100644 --- a/rust/crates/mnote-web/src/workspace_shell.rs +++ b/rust/crates/mnote-web/src/workspace_shell.rs @@ -102,13 +102,13 @@ pub fn build_workspace_shell_projection( id: "trash".into(), label: "垃圾箱".into(), href: "/trash".into(), - icon: "⌫".into(), + icon: "delete".into(), }, WorkspaceShellEntry { id: "templates".into(), label: "模板中心".into(), href: "/templates".into(), - icon: "◇".into(), + icon: "inventory_2".into(), }, ], } @@ -321,6 +321,10 @@ mod tests { assert!(html.contains("/documents/doc_root?workspaceId=ws_demo")); assert!(html.contains("data-testid=\"wolai-sidebar-create-page\"")); assert!(html.contains("data-mnote-action=\"create-page\"")); + assert!(html.contains("class=\"mnote-symbol")); + assert!(html.contains("data-icon=\"home\"")); + assert!(html.contains("data-icon=\"star\"")); + assert!(html.contains("data-icon=\"delete\"")); } #[test] @@ -422,9 +426,9 @@ pub fn render_workspace_shell_sidebar_html( .iter() .map(|entry| { format!( - r#"{}{}"#, + r#"{}{}"#, escape_html(&entry.href), - escape_html(&entry.icon), + render_symbol(&entry.icon, "wolai-footer-icon"), escape_html(&entry.label), ) }) @@ -432,7 +436,9 @@ pub fn render_workspace_shell_sidebar_html( .join(""); format!( - r#"
    星标置顶
    {starred_rows}
    {my_pages}
    "#, + r#"
    {}星标置顶
    {starred_rows}
    {my_pages}
    "#, + render_symbol("star", "wolai-section-icon"), + render_symbol("folder_open", "wolai-folder-icon"), escape_html(&projection.workspace_id), ) } @@ -455,16 +461,55 @@ fn render_item_row(item: &WorkspaceShellItem) -> String { "" }; format!( - r#"{}{}"#, + r#"{}{}"#, escape_html(&item.href), escape_html(&item.id), + escape_html(&item.id), item.depth, item.active, - escape_html(item.icon.as_deref().unwrap_or("▣")), + render_symbol(item_icon_name(item.icon.as_deref()), "wolai-row-symbol"), escape_html(&item.title), ) } +fn item_icon_name(icon: Option<&str>) -> &'static str { + match icon.unwrap_or_default().trim() { + "star" | "★" => "star", + "folder" | "folder_open" | "▱" => "folder_open", + "delete" | "trash" | "⌫" => "delete", + "inventory_2" | "template" | "◇" => "inventory_2", + "home" | "page" | "▣" | "⌂" | "" => "home", + _ => "home", + } +} + +fn render_symbol(icon: &str, class_name: &str) -> String { + let icon = item_icon_name(Some(icon)); + let body = match icon { + "delete" => { + r#""# + } + "folder_open" => { + r#""# + } + "inventory_2" => { + r#""# + } + "star" => { + r#""# + } + _ => { + r#""# + } + }; + format!( + r#""#, + escape_html(class_name), + escape_html(icon), + body + ) +} + fn escape_html(value: &str) -> String { value .replace('&', "&") diff --git a/rust/crates/storage-convex-bridge/src/mapping.rs b/rust/crates/storage-convex-bridge/src/mapping.rs index 3f2588c6..0d2dfc2b 100644 --- a/rust/crates/storage-convex-bridge/src/mapping.rs +++ b/rust/crates/storage-convex-bridge/src/mapping.rs @@ -68,6 +68,7 @@ pub fn map_command_name_to_convex(command_name: &str) -> &'static str { "documents.options.update" => "documents:updateOptions", "page.layout.updateOptions" => "documents:updateOptions", "mindmaps.put" => "mindmaps:put", + "mindmap.command.apply" => "mindmaps:applyCommand", "mindmaps.delete" => "mindmaps:softDelete", "mindmaps.restore" => "mindmaps:restore", "mindmaps.purge" => "mindmaps:purge", @@ -97,10 +98,13 @@ pub fn map_query_name_to_convex(query_name: &str) -> &'static str { "bridge.workspace.overview" => "bridgeLogs:listWorkspaceOverview", "documents.meta.get" => "documents:getMeta", "documents.content.get" => "documents:getContent", + "page.aggregate.get" => "documents:getPageAggregate", "mindmaps.get" => "mindmaps:get", + "mindmap.projection.get" => "mindmaps:getProjection", "blocks.get" => "blocks:getById", "sidebar.dataset.list" => "sidebar:datasetList", "search.documents" => "search:documents", + "search.documents.query" => "search:documents", "search.recent" => "search:recent", "get_page" => "pages:get", "list_page_blocks" => "blocks:list_by_page", diff --git a/scripts/task116-rust-web-islands-smoke.js b/scripts/task116-rust-web-islands-smoke.js index 46c201d6..e67d714a 100644 --- a/scripts/task116-rust-web-islands-smoke.js +++ b/scripts/task116-rust-web-islands-smoke.js @@ -71,8 +71,8 @@ async function checkSearchShell() { assert(response.headers.get("x-mnote-web-owner") === "mnote-web", "Search shell 缺少 mnote-web owner header"); assert(response.headers.get("x-mnote-web-shell") === "search", "Search shell 缺少 search shell header"); assert(text.includes("mnote.search_shell.v1"), "Search shell 缺少 contract schema"); - assert(text.includes("react_search_palette"), "Search shell 缺少 search palette island"); - return { owner: "mnote-web", shell: "search", island: "react_search_palette" }; + assert(text.includes("search_interaction_island"), "Search shell 缺少 search interaction island"); + return { owner: "mnote-web", shell: "search", island: "search_interaction_island" }; } async function checkAiBridge() { diff --git a/scripts/task117-next-retirement-guard.js b/scripts/task117-next-retirement-guard.js index 3edfedd7..33b5b2b1 100644 --- a/scripts/task117-next-retirement-guard.js +++ b/scripts/task117-next-retirement-guard.js @@ -96,6 +96,11 @@ async function readText(baseUrl, path, init) { const text = await response.text(); assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`); assert.equal(response.headers.get("x-mnote-web-owner"), "mnote-web", `${path} 缺少 mnote-web owner`); + assert.equal( + response.headers.get("x-mnote-legacy-upstream"), + null, + `${path} 默认主路径不应出现 Next fallback header`, + ); return { response, text }; } @@ -116,6 +121,10 @@ async function validateSkipNextRuntimePlan() { } async function validateCoreShellsWithoutNext(baseUrl) { + const root = await readText(baseUrl, "/?workspaceId=ws_demo"); + assert.match(root.text, /data-mnote-shell="workspace"/); + assert.doesNotMatch(root.text, /next-app-router/i); + const auth = await readText(baseUrl, "/auth"); assert.match(auth.text, /data-mnote-shell="auth"/); assert.doesNotMatch(auth.text, /next-app-router/i); @@ -134,6 +143,11 @@ async function validateCoreShellsWithoutNext(baseUrl) { const search = await readText(baseUrl, "/search?workspaceId=ws_demo&q=Rust"); assert.equal(search.response.headers.get("x-mnote-web-shell"), "search"); assert.match(search.text, /mnote\.search_shell\.v1/); + + const mindmap = await readText(baseUrl, "/mindmap/doc_1/mind_1"); + assert.equal(mindmap.response.headers.get("x-mnote-web-shell"), "mindmap"); + assert.match(mindmap.text, /mnote\.mindmap_shell\.v1/); + assert.doesNotMatch(mindmap.text, /next-app-router/i); } async function validateDocs() { diff --git a/scripts/task123-rust-web-tree-live-stream-consumer-smoke.js b/scripts/task123-rust-web-tree-live-stream-consumer-smoke.js new file mode 100644 index 00000000..3c32137b --- /dev/null +++ b/scripts/task123-rust-web-tree-live-stream-consumer-smoke.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); + +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); + +async function fetchText(path) { + const response = await fetch(`${BASE_URL}${path}`); + const text = await response.text(); + assert.equal(response.status, 200, `${path} 请求失败: ${response.status} ${text.slice(0, 200)}`); + return { response, text }; +} + +async function main() { + const page = await fetchText("/documents/doc_1?workspaceId=ws_demo"); + assert.match(page.text, /__MNOTE_TREE_LIVE_BOOTSTRAP__/); + assert.match(page.text, /mnote\.tree_live_bootstrap\.v1/); + assert.match(page.text, /\/api\/tree\/events/); + assert.match(page.text, /tree:snapshot/); + assert.match(page.text, /tree:delta/); + assert.match(page.text, /tree:resync/); + + const events = await fetchText("/api/tree/events?workspaceId=ws_demo&maxPolls=0"); + assert.match(events.response.headers.get("content-type") || "", /text\/event-stream/); + assert.equal(events.response.headers.get("x-mnote-tree-stream-owner"), "rust-web"); + assert.match(events.text, /event:\s*snapshot/); + assert.match(events.text, /id:\s*/); + assert.match(events.text, /"revision"/); + + console.log(JSON.stringify({ ok: true, owner: "rust-web", stream: "/api/tree/events" }, null, 2)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task125-rust-web-search-server-first-smoke.js b/scripts/task125-rust-web-search-server-first-smoke.js new file mode 100644 index 00000000..dcd22a77 --- /dev/null +++ b/scripts/task125-rust-web-search-server-first-smoke.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); + +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); + +async function main() { + const response = await fetch(`${BASE_URL}/search?workspaceId=ws_demo&q=Rust`); + const html = await response.text(); + assert.equal(response.status, 200, `/search 请求失败: ${response.status} ${html.slice(0, 200)}`); + assert.equal(response.headers.get("x-mnote-web-shell"), "search"); + assert.match(html, /mnote\.search_shell\.v1/); + assert.match(html, /search\.documents\.query/); + assert.match(html, /data-search-results-owner="rust-kernel"/); + assert.match(html, /search-result/); + assert.doesNotMatch(html, /react_search_palette/); + + const api = await fetch(`${BASE_URL}/api/search/documents`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + workspaceId: "ws_demo", + query: "Rust", + filters: { + titleOnly: false, + exact: false, + includeOcr: false, + onlyCurrentPage: false, + timeRange: "any", + timeField: "updated", + }, + }), + }); + const payload = await api.json(); + assert.equal(api.status, 200, `/api/search/documents 请求失败: ${api.status}`); + assert.equal(payload.projectionOwner, "rust-kernel"); + assert.equal(payload.meta.queryName, "search.documents.query"); + + console.log(JSON.stringify({ ok: true, projectionOwner: payload.projectionOwner }, null, 2)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task126-rust-web-ai-bridge-structured-write-smoke.js b/scripts/task126-rust-web-ai-bridge-structured-write-smoke.js new file mode 100644 index 00000000..dd6448f7 --- /dev/null +++ b/scripts/task126-rust-web-ai-bridge-structured-write-smoke.js @@ -0,0 +1,37 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); + +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); + +async function main() { + const response = await fetch(`${BASE_URL}/api/hermes/bridge`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + stream: true, + scope: "document", + messages: [{ role: "user", content: "生成摘要" }], + context: { documentId: "doc_1", workspaceId: "ws_demo" }, + }), + }); + const payload = await response.json(); + assert.equal(response.status, 200, `/api/hermes/bridge 请求失败: ${response.status}`); + assert.equal(response.headers.get("x-mnote-ai-bridge-owner"), "rust-web-hermes"); + assert.equal(payload.canonicalRoute, "/api/hermes/bridge"); + assert.match(payload.eventStreamEndpoint || "", /\/api\/hermes\/events\//); + assert.equal(payload.contract.structuredWriteOwner, "rust-web-hermes"); + assert.deepEqual(payload.structuredWrite.allowedCommands, [ + "page.body.save", + "tree.node.create", + "kernel.edge.attach", + ]); + + console.log(JSON.stringify({ ok: true, bridgeOwner: "rust-web-hermes" }, null, 2)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/scripts/task127-rust-web-mindmap-kernel-projection-smoke.js b/scripts/task127-rust-web-mindmap-kernel-projection-smoke.js new file mode 100644 index 00000000..6d9b1198 --- /dev/null +++ b/scripts/task127-rust-web-mindmap-kernel-projection-smoke.js @@ -0,0 +1,25 @@ +#!/usr/bin/env node +"use strict"; + +const assert = require("node:assert"); + +const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/$/, ""); + +async function main() { + const response = await fetch(`${BASE_URL}/mindmap/doc_1/mind_1`); + const html = await response.text(); + assert.equal(response.status, 200, `/mindmap 请求失败: ${response.status} ${html.slice(0, 200)}`); + assert.equal(response.headers.get("x-mnote-web-shell"), "mindmap"); + assert.match(html, /mnote\.mindmap_shell\.v1/); + assert.match(html, /mindmap\.projection\.get/); + assert.match(html, /mindmap\.command\.apply/); + assert.match(html, /rust-kernel/); + assert.doesNotMatch(html, /next-app-router/); + + console.log(JSON.stringify({ ok: true, projectionOwner: "rust-kernel" }, null, 2)); +} + +main().catch((error) => { + console.error(error instanceof Error ? error.stack || error.message : String(error)); + process.exit(1); +}); diff --git a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx index 84ef66f1..dc353ef0 100644 --- a/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx +++ b/wolai-frontend/src/components/editor/DocumentAiAgentPanel.runtime.tsx @@ -692,7 +692,7 @@ export function DocumentAiAgentPanelRuntime({ } try { - const res = await fetch("/api/ai-agent/run", { + const res = await fetch(DOCUMENT_AI_BRIDGE_ISLAND_CONTRACT.runEndpoint, { method: "POST", headers: { "Content-Type": "application/json" }, signal: controller.signal, diff --git a/wolai-frontend/src/types/search.ts b/wolai-frontend/src/types/search.ts index d9a15248..0a5e0eb4 100644 --- a/wolai-frontend/src/types/search.ts +++ b/wolai-frontend/src/types/search.ts @@ -36,6 +36,7 @@ export interface DocumentSearchResult { score: number; nodeId?: string | null; subtreeRootId?: string | null; + projectionOwner?: "rust-kernel" | "compat-index"; evidence?: Array<{ kind: string; nodeId?: string | null; @@ -44,6 +45,7 @@ export interface DocumentSearchResult { } export interface DocumentSearchResponse { + projectionOwner?: "rust-kernel" | "compat-index"; results: DocumentSearchResult[]; recent: DocumentSearchResult[]; }