diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 63d669e3..96f81c5e 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1544,6 +1544,7 @@ dependencies = [ "storage-convex-bridge", "time", "tokio", + "tokio-tungstenite", "tower", "tower-http", "tracing", diff --git a/rust/crates/bridge-runtime/src/lib.rs b/rust/crates/bridge-runtime/src/lib.rs index 469a2356..2e542087 100644 --- a/rust/crates/bridge-runtime/src/lib.rs +++ b/rust/crates/bridge-runtime/src/lib.rs @@ -5459,23 +5459,22 @@ fn mindmap_tree_candidate(data: &Value) -> Value { let Some(map) = candidate.as_object() else { return candidate; }; - let next = if (map.contains_key("ok") || map.contains_key("meta")) - && map.get("data").is_some() - { - map.get("data").cloned() - } else if map - .get("data") - .map(|value| value.get("data").is_some() || value.get("children").is_some()) - .unwrap_or(false) - { - map.get("data").cloned() - } else if let Some(nested) = map.get("mindmap") { - Some(nested.clone()) - } else if let Some(nested) = map.get("result") { - Some(nested.clone()) - } else { - None - }; + let next = + if (map.contains_key("ok") || map.contains_key("meta")) && map.get("data").is_some() { + map.get("data").cloned() + } else if map + .get("data") + .map(|value| value.get("data").is_some() || value.get("children").is_some()) + .unwrap_or(false) + { + map.get("data").cloned() + } else if let Some(nested) = map.get("mindmap") { + Some(nested.clone()) + } else if let Some(nested) = map.get("result") { + Some(nested.clone()) + } else { + None + }; let Some(next) = next else { return candidate; }; @@ -6570,7 +6569,10 @@ fn merge_json_object(base: &mut Value, patch: &Value) { } fn set_json_path(root: &mut Value, path: &str, value: Value) -> bool { - let segments: Vec<&str> = path.split('.').filter(|segment| !segment.is_empty()).collect(); + let segments: Vec<&str> = path + .split('.') + .filter(|segment| !segment.is_empty()) + .collect(); if segments.is_empty() { return false; } @@ -6582,7 +6584,9 @@ fn set_json_path(root: &mut Value, path: &str, value: Value) -> bool { let Some(map) = current.as_object_mut() else { return false; }; - current = map.entry((*segment).to_string()).or_insert_with(|| json!({})); + current = map + .entry((*segment).to_string()) + .or_insert_with(|| json!({})); } if !current.is_object() { *current = json!({}); @@ -6599,7 +6603,10 @@ fn apply_mindmap_compat_payload_patch( metadata: &mut BTreeMap, command: &Value, ) -> bool { - let path = command.get("path").and_then(Value::as_str).unwrap_or_default(); + let path = command + .get("path") + .and_then(Value::as_str) + .unwrap_or_default(); if path.trim().is_empty() { return false; } @@ -11490,7 +11497,10 @@ mod tests { assert_eq!(result.applied, 2); assert!(result.errors.is_empty()); assert_eq!(result.data["data"]["data"]["text"], json!("KMIND")); - assert_eq!(result.data["data"]["data"]["generalization"]["text"], json!("概要")); + assert_eq!( + result.data["data"]["data"]["generalization"]["text"], + json!("概要") + ); assert_eq!(result.data["view"]["state"]["scale"], json!(1.2)); assert_eq!(result.data["view"]["state"]["x"], json!(10)); assert_eq!(result.data["view"]["transform"]["scaleX"], json!(1.2)); diff --git a/rust/crates/mnote-web/Cargo.toml b/rust/crates/mnote-web/Cargo.toml index 257451f9..9c819112 100644 --- a/rust/crates/mnote-web/Cargo.toml +++ b/rust/crates/mnote-web/Cargo.toml @@ -18,6 +18,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" storage-convex-bridge = { path = "../storage-convex-bridge" } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } +tokio-tungstenite = "0.29" tower-http = { version = "0.6", features = ["trace"] } tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["fmt"] } diff --git a/rust/crates/mnote-web/src/app.rs b/rust/crates/mnote-web/src/app.rs index c3ab9dc5..a5761323 100644 --- a/rust/crates/mnote-web/src/app.rs +++ b/rust/crates/mnote-web/src/app.rs @@ -1,6 +1,6 @@ +use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry; use crate::middleware::request_context::inject_request_context; use crate::routes::build_router; -use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry; use axum::Router; use std::env; use std::fs; diff --git a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs index 773bb8af..5c5f9d4b 100644 --- a/rust/crates/mnote-web/src/local_folder_watcher_registry.rs +++ b/rust/crates/mnote-web/src/local_folder_watcher_registry.rs @@ -49,11 +49,7 @@ impl LocalFolderWatcherRegistry { #[cfg(test)] pub fn active_watcher_count(&self) -> usize { - self.inner - .entries - .lock() - .expect("registry lock") - .len() + self.inner.entries.lock().expect("registry lock").len() } } @@ -67,7 +63,13 @@ impl LocalFolderWatcherRegistryInner { key: &str, canonical_root: &Path, ) -> Result, String> { - if let Some(existing) = self.entries.lock().expect("registry lock").get(key).cloned() { + if let Some(existing) = self + .entries + .lock() + .expect("registry lock") + .get(key) + .cloned() + { return Ok(existing); } @@ -107,10 +109,7 @@ struct LocalFolderWatchChannel { } impl LocalFolderWatchChannel { - fn new( - _root_uri: String, - parts: (broadcast::Sender, oneshot::Sender<()>), - ) -> Self { + fn new(_root_uri: String, parts: (broadcast::Sender, oneshot::Sender<()>)) -> Self { Self { sender: parts.0, subscriber_count: AtomicUsize::new(0), @@ -321,7 +320,9 @@ mod tests { let first_root = test_root("first"); let second_root = test_root("second"); - let first = registry.subscribe(&first_root).expect("first root subscription"); + let first = registry + .subscribe(&first_root) + .expect("first root subscription"); let second = registry .subscribe(&second_root) .expect("second root subscription"); @@ -340,9 +341,11 @@ mod tests { #[test] fn event_kind_filter_ignores_access_events() { assert!(should_emit_event_kind(&EventKind::Create(CreateKind::File))); - assert!(should_emit_event_kind(&EventKind::Modify(ModifyKind::Data( - DataChange::Content, - )))); - assert!(!should_emit_event_kind(&EventKind::Access(AccessKind::Read))); + assert!(should_emit_event_kind(&EventKind::Modify( + ModifyKind::Data(DataChange::Content,) + ))); + assert!(!should_emit_event_kind(&EventKind::Access( + AccessKind::Read + ))); } } diff --git a/rust/crates/mnote-web/src/routes/compat.rs b/rust/crates/mnote-web/src/routes/compat.rs index 00089bd4..f012ccd4 100644 --- a/rust/crates/mnote-web/src/routes/compat.rs +++ b/rust/crates/mnote-web/src/routes/compat.rs @@ -1,36 +1,22 @@ use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; -use crate::routes::query_support::resolve_effective_workspace_id; -use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec}; -use axum::body::{Body, Bytes}; -use axum::extract::Query; +use axum::body::Body; use axum::extract::{Extension, State}; use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Request, StatusCode}; use axum::response::{IntoResponse, Response}; use axum::Json; -use core_protocol::KernelProjectionKind; -use futures_util::{StreamExt, TryStreamExt}; -use serde::Deserialize; use serde_json::{json, Value}; use std::env; -use std::fs; use std::path::PathBuf; use std::process::Command; -use std::time::Duration; - -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct CompatSidebarQuery { - pub workspace_id: Option, -} pub async fn next_ai_agent_run( State(state): State, Extension(context): Extension, request: Request, ) -> Result { - let (parts, body) = request.into_parts(); + let (_parts, body) = request.into_parts(); let body = axum::body::to_bytes(body, 10 * 1024 * 1024) .await .map_err(|error| WebError::internal(format!("读取 AI 请求体失败: {error}")))?; @@ -43,24 +29,11 @@ pub async fn next_ai_agent_run( .with_header("x-mnote-web-owner", "mnote-web") })?; - if state.config().enable_legacy_next_compat { - if let Some(base_url) = state.config().legacy_next_base_url.as_deref() { - if let Ok(response) = - proxy_ai_agent_run_to_next(base_url, &context, &parts.headers, body.clone()).await - { - return Ok(response); - } - } - } - if let Some(provider) = explicit_agent_provider(&payload) { - if provider == "hermes" { - return run_direct_hermes_agent(&context, &payload).await; - } return Err(WebError::bad_gateway_code( "ai_provider_bridge_unavailable", format!( - "{provider} provider 需要可用的 Next AI bridge,不能静默降级到本地页面工具 host。" + "{provider} provider 直连链路已退场;当前仅保留 mnote-cli host 主路径,不再静默降级。" ), ) .with_context(&context) @@ -68,69 +41,7 @@ pub async fn next_ai_agent_run( .with_header("x-mnote-ai-execution-owner", "provider-bridge-unavailable")); } - if let Ok(response) = run_local_mnote_cli_ai_host(&state, &context, &payload).await { - return Ok(response); - } - - let Some(backend_url) = resolve_ai_orchestrator_backend_url() else { - return Err(WebError::bad_gateway_code( - "ai_orchestrator_unavailable", - "未配置 BACKEND_URL,无法连接 document AI orchestrator。", - ) - .with_context(&context) - .with_header("x-mnote-web-owner", "mnote-web")); - }; - - let upstream_url = reqwest::Url::parse(&format!( - "{}/api/v1/ai-agent/document/run", - backend_url.trim_end_matches('/') - )) - .map_err(|error| WebError::internal(format!("AI orchestrator URL 非法: {error}")))?; - let forward_payload = build_document_ai_orchestrator_payload(&state, &context, &payload); - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(5)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| { - WebError::internal(format!("AI orchestrator HTTP 客户端创建失败: {error}")) - })?; - - let mut upstream_request = client.post(upstream_url).json(&forward_payload); - upstream_request = apply_ai_forward_headers(upstream_request, &parts.headers, &context); - if let Some(api_key) = read_env_or_dotenv("MNOTE_AI_ORCHESTRATOR_API_KEY") { - upstream_request = upstream_request.header("x-mnote-ai-key", api_key); - } - - let upstream_response = upstream_request.send().await.map_err(|error| { - WebError::bad_gateway_code( - "ai_orchestrator_proxy_error", - format!("document AI orchestrator 请求失败: {error}"), - ) - .with_context(&context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "document-ai-orchestrator") - })?; - - if !upstream_response.status().is_success() { - let status = upstream_response.status(); - let body = upstream_response - .text() - .await - .unwrap_or_else(|error| format!("读取 upstream 错误响应失败: {error}")); - return Err(WebError::bad_gateway_code( - "ai_orchestrator_upstream_error", - format!( - "document AI orchestrator 返回 HTTP {}: {}", - status.as_u16(), - body - ), - ) - .with_context(&context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "document-ai-orchestrator")); - } - - build_ai_sse_proxy_response(upstream_response, &context) + run_local_mnote_cli_ai_host(&state, &context, &payload).await } fn explicit_agent_provider(payload: &Value) -> Option<&'static str> { @@ -149,369 +60,6 @@ fn explicit_agent_provider(payload: &Value) -> Option<&'static str> { } } -async fn run_direct_hermes_agent( - context: &RequestContext, - payload: &Value, -) -> Result { - let base_url = resolve_hermes_api_base_url(); - let api_key = read_env_or_dotenv("MNOTE_HERMES_API_KEY").ok_or_else(|| { - WebError::bad_gateway_code( - "hermes_bridge_unconfigured", - "未配置 MNOTE_HERMES_API_KEY,无法直连 Hermes API。", - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-mnote-ai-execution-owner", "hermes") - })?; - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(5)) - .timeout(Duration::from_secs(180)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| WebError::internal(format!("Hermes HTTP 客户端创建失败: {error}")))?; - - let start_url = reqwest::Url::parse(&format!("{}/v1/runs", base_url.trim_end_matches('/'))) - .map_err(|error| WebError::internal(format!("Hermes runs URL 非法: {error}")))?; - let start_response = client - .post(start_url) - .bearer_auth(&api_key) - .json(&build_hermes_run_payload(payload)) - .send() - .await - .map_err(|error| { - WebError::bad_gateway_code( - "hermes_bridge_start_error", - format!("Hermes run 启动请求失败: {error}"), - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "hermes-api") - .with_header("x-mnote-ai-execution-owner", "hermes") - })?; - - if !start_response.status().is_success() { - let status = start_response.status(); - let body = start_response - .text() - .await - .unwrap_or_else(|error| format!("读取 Hermes 错误响应失败: {error}")); - return Err(WebError::bad_gateway_code( - "hermes_bridge_start_rejected", - format!("Hermes run 启动返回 HTTP {}: {}", status.as_u16(), body), - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "hermes-api") - .with_header("x-mnote-ai-execution-owner", "hermes")); - } - - let started: Value = start_response.json().await.map_err(|error| { - WebError::bad_gateway_code( - "hermes_bridge_bad_start_response", - format!("Hermes run 启动响应不是合法 JSON: {error}"), - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "hermes-api") - .with_header("x-mnote-ai-execution-owner", "hermes") - })?; - let run_id = started - .get("run_id") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .ok_or_else(|| { - WebError::bad_gateway_code( - "hermes_bridge_missing_run_id", - "Hermes run 启动响应缺少 run_id。", - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "hermes-api") - .with_header("x-mnote-ai-execution-owner", "hermes") - })?; - - let mut events_url = - reqwest::Url::parse(&format!("{}/v1/runs/", base_url.trim_end_matches('/'))) - .map_err(|error| WebError::internal(format!("Hermes events URL 非法: {error}")))?; - events_url - .path_segments_mut() - .map_err(|_| WebError::internal("Hermes events URL 不支持路径拼接"))? - .pop_if_empty() - .push(run_id) - .push("events"); - - let events_response = client - .get(events_url) - .bearer_auth(api_key) - .send() - .await - .map_err(|error| { - WebError::bad_gateway_code( - "hermes_bridge_events_error", - format!("Hermes 事件流请求失败: {error}"), - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "hermes-api") - .with_header("x-mnote-ai-execution-owner", "hermes") - })?; - - if !events_response.status().is_success() { - let status = events_response.status(); - let body = events_response - .text() - .await - .unwrap_or_else(|error| format!("读取 Hermes 事件流错误响应失败: {error}")); - return Err(WebError::bad_gateway_code( - "hermes_bridge_events_rejected", - format!("Hermes 事件流返回 HTTP {}: {}", status.as_u16(), body), - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "hermes-api") - .with_header("x-mnote-ai-execution-owner", "hermes")); - } - - let events_text = events_response.text().await.map_err(|error| { - WebError::bad_gateway_code( - "hermes_bridge_events_read_error", - format!("读取 Hermes 事件流失败: {error}"), - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "hermes-api") - .with_header("x-mnote-ai-execution-owner", "hermes") - })?; - - build_hermes_sse_response(context, parse_sse_data_json_values(&events_text)) -} - -fn resolve_hermes_api_base_url() -> String { - read_env_or_dotenv("MNOTE_HERMES_API_BASE_URL") - .unwrap_or_else(|| "http://127.0.0.1:8642".into()) - .trim() - .trim_end_matches('/') - .to_string() -} - -fn build_hermes_run_payload(payload: &Value) -> Value { - let messages = payload - .get("messages") - .and_then(Value::as_array) - .cloned() - .unwrap_or_default(); - let input: Vec = messages - .iter() - .map(|message| { - json!({ - "role": message.get("role").and_then(Value::as_str).unwrap_or("user"), - "content": message.get("content").and_then(Value::as_str).unwrap_or(""), - }) - }) - .collect(); - let conversation_history = if input.len() > 1 { - input[..input.len() - 1].to_vec() - } else { - Vec::new() - }; - let session_id = payload - .get("options") - .and_then(|value| value.get("ai")) - .and_then(|value| value.get("sessionId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()); - - json!({ - "input": input, - "conversation_history": conversation_history, - "session_id": session_id, - }) -} - -fn parse_sse_data_json_values(text: &str) -> Vec { - text.split("\n\n") - .filter_map(|frame| { - let data = frame - .lines() - .filter_map(|line| line.strip_prefix("data:")) - .map(str::trim_start) - .collect::>() - .join("\n"); - if data.trim().is_empty() { - return None; - } - serde_json::from_str::(&data).ok() - }) - .collect() -} - -fn sse_frame(event: &str, data: Value) -> String { - format!("event: {event}\ndata: {}\n\n", data.to_string()) -} - -fn build_hermes_sse_response( - context: &RequestContext, - events: Vec, -) -> Result { - let mut body = String::new(); - let mut assistant_text = String::new(); - let mut completed = false; - body.push_str(&sse_frame( - "ready", - json!({"ok": true, "bridgeOwner": "hermes", "requestId": context.trace.request_id}), - )); - for event in events { - let event_name = event.get("event").and_then(Value::as_str).unwrap_or(""); - match event_name { - "message.delta" => { - if let Some(delta) = event.get("delta").and_then(Value::as_str) { - assistant_text.push_str(delta); - body.push_str(&sse_frame("assistant_delta", json!({"text": delta}))); - } - } - "run.completed" => { - completed = true; - let output = event - .get("output") - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| assistant_text.trim()); - let text = if output.is_empty() { - "(无输出)" - } else { - output - }; - body.push_str(&sse_frame("assistant_message", json!({"text": text}))); - body.push_str(&sse_frame( - "completion", - json!({"ok": true, "text": text, "steps": 1}), - )); - } - "run.failed" => { - completed = true; - let message = event - .get("error") - .and_then(Value::as_str) - .unwrap_or("Hermes 执行失败"); - body.push_str(&sse_frame( - "error", - json!({"ok": false, "message": message}), - )); - } - _ => {} - } - } - if !completed { - let text = assistant_text.trim(); - let text = if text.is_empty() { - "(无输出)" - } else { - text - }; - body.push_str(&sse_frame("assistant_message", json!({"text": text}))); - body.push_str(&sse_frame( - "completion", - json!({"ok": true, "text": text, "steps": 1}), - )); - } - - let mut response = Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") - .header(header::CACHE_CONTROL, "no-cache, no-transform") - .header(header::CONNECTION, "keep-alive") - .header("x-accel-buffering", "no") - .header("x-mnote-ai-execution-owner", "hermes") - .body(Body::from(body)) - .map_err(|error| WebError::internal(format!("Hermes SSE 响应构造失败: {error}")))?; - stamp_owner_header(response.headers_mut()); - Ok(response) -} - -async fn proxy_ai_agent_run_to_next( - base_url: &str, - context: &RequestContext, - headers: &HeaderMap, - body_bytes: Bytes, -) -> Result { - let upstream_url = reqwest::Url::parse(&format!( - "{}/api/ai-agent/run", - base_url.trim_end_matches('/') - )) - .map_err(|error| WebError::internal(format!("Next AI route URL 非法: {error}")))?; - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(5)) - .redirect(reqwest::redirect::Policy::none()) - .build() - .map_err(|error| WebError::internal(format!("Next AI HTTP 客户端创建失败: {error}")))?; - - let mut upstream_request = client.post(upstream_url).body(body_bytes.clone()); - for (name, value) in headers.iter() { - if is_hop_by_hop_header(name.as_str()) - || name == header::HOST - || name == header::CONTENT_LENGTH - || name == header::COOKIE - || name == header::AUTHORIZATION - { - continue; - } - upstream_request = upstream_request.header(name.as_str(), value.as_bytes()); - } - if let Some(cookie) = context.auth.cookie_header.as_deref() { - upstream_request = upstream_request.header(header::COOKIE, cookie); - } - if let Some(authorization) = context.auth.authorization.as_deref() { - upstream_request = upstream_request.header(header::AUTHORIZATION, authorization); - } - upstream_request = upstream_request.header("x-request-id", context.trace.request_id.as_str()); - upstream_request = upstream_request.header("x-trace-id", context.trace.trace_id.as_str()); - upstream_request = upstream_request.header("x-mnote-source-channel", "rust_web_route"); - upstream_request = upstream_request.header("x-mnote-source-client", "mnote-web"); - upstream_request = upstream_request.header("x-mnote-actor-id", context.auth.actor_id.as_str()); - upstream_request = - upstream_request.header("x-mnote-actor-type", context.auth.actor_type.as_str()); - if let Some(workspace_id) = context.workspace.workspace_id.as_deref() { - upstream_request = upstream_request.header("x-mnote-workspace-id", workspace_id); - } - if let Some(session_id) = context.auth.session_id.as_deref() { - upstream_request = upstream_request.header("x-mnote-session-id", session_id); - } - - let upstream_response = upstream_request.send().await.map_err(|error| { - WebError::bad_gateway_code( - "next_ai_proxy_error", - format!("Next /api/ai-agent/run 请求失败: {error}"), - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "next-ai-route") - })?; - - if !upstream_response.status().is_success() { - let status = upstream_response.status(); - let body = upstream_response - .text() - .await - .unwrap_or_else(|error| format!("读取 Next AI 错误响应失败: {error}")); - return Err(WebError::bad_gateway_code( - "next_ai_upstream_error", - format!( - "Next /api/ai-agent/run 返回 HTTP {}: {}", - status.as_u16(), - body - ), - ) - .with_context(context) - .with_header("x-mnote-web-owner", "mnote-web") - .with_header("x-upstream-service", "next-ai-route")); - } - - build_ai_sse_proxy_response(upstream_response, context) -} - fn resolve_repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") } @@ -705,241 +253,18 @@ async fn run_local_mnote_cli_ai_host( Ok(response) } -fn build_document_ai_orchestrator_payload( - state: &AppState, - context: &RequestContext, - payload: &Value, -) -> Value { - let ai_options = payload.get("options").and_then(|value| value.get("ai")); - let user_id = if context.auth.actor_id.trim().is_empty() || context.auth.actor_id == "anonymous" - { - state.config().dev_user_id.clone() - } else { - context.auth.actor_id.clone() - }; - - json!({ - "userId": user_id, - "sessionId": ai_options - .and_then(|value| value.get("sessionId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()), - "model": ai_options - .and_then(|value| value.get("model")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()), - "modelKey": ai_options - .and_then(|value| value.get("modelKey")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()), - "profileId": ai_options - .and_then(|value| value.get("profileId")) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()), - "maxSteps": payload.get("maxSteps").filter(|value| !value.is_null()).cloned().unwrap_or_else(|| json!(10)), - "messages": payload.get("messages").cloned().unwrap_or_else(|| json!([])), - "context": build_document_ai_context(payload.get("context")), - }) -} - -fn build_document_ai_context(context: Option<&Value>) -> Value { - let get = |key: &str| { - context - .and_then(|value| value.get(key)) - .cloned() - .unwrap_or(Value::Null) - }; - - json!({ - "source": get("source"), - "action": get("action"), - "documentId": get("documentId"), - "workspaceId": get("workspaceId"), - "selectedBlockId": get("selectedBlockId"), - "selectedBlockIndex": get("selectedBlockIndex"), - "selectedUids": get("selectedUids"), - "selectedText": get("selectedText"), - "selection": get("selection"), - "tiptapDocument": get("tiptapDocument"), - "documentBlocks": get("documentBlocks"), - "node": get("node"), - "subtree": get("subtree"), - "outline": get("outline"), - "evidence": get("evidence"), - "pageOptions": get("pageOptions"), - }) -} - -fn apply_ai_forward_headers( - mut request: reqwest::RequestBuilder, - headers: &HeaderMap, - context: &RequestContext, -) -> reqwest::RequestBuilder { - for (name, value) in headers.iter() { - if is_hop_by_hop_header(name.as_str()) - || name == header::HOST - || name == header::CONTENT_LENGTH - || name == header::CONTENT_TYPE - { - continue; - } - request = request.header(name.as_str(), value.as_bytes()); - } - - request = request.header(header::CONTENT_TYPE.as_str(), "application/json"); - request = request.header("x-request-id", context.trace.request_id.as_str()); - request = request.header("x-trace-id", context.trace.trace_id.as_str()); - request = request.header("x-mnote-source-channel", "rust_web_route"); - request = request.header("x-mnote-source-client", "mnote-web"); - request = request.header("x-mnote-actor-id", context.auth.actor_id.as_str()); - request.header("x-mnote-actor-type", context.auth.actor_type.as_str()) -} - -fn build_ai_sse_proxy_response( - upstream_response: reqwest::Response, - context: &RequestContext, -) -> Result { - let status = - StatusCode::from_u16(upstream_response.status().as_u16()).unwrap_or(StatusCode::OK); - let ready = Bytes::from(format!( - "event: ready\ndata: {}\n\n", - json!({"ok": true, "requestId": context.trace.request_id}).to_string() - )); - let upstream_stream = upstream_response - .bytes_stream() - .map_err(std::io::Error::other); - let body_stream = futures_util::stream::once(async move { Ok::(ready) }) - .chain(upstream_stream); - - let mut response = Response::builder() - .status(status) - .header(header::CONTENT_TYPE, "text/event-stream; charset=utf-8") - .header(header::CACHE_CONTROL, "no-cache, no-transform") - .header(header::CONNECTION, "keep-alive") - .header("x-accel-buffering", "no") - .body(Body::from_stream(body_stream)) - .map_err(|error| WebError::internal(format!("AI SSE 响应构造失败: {error}")))?; - stamp_owner_header(response.headers_mut()); - Ok(response) -} - fn stamp_owner_header(headers: &mut HeaderMap) { if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") { headers.insert(name, HeaderValue::from_static("mnote-web")); } } -fn resolve_ai_orchestrator_backend_url() -> Option { - read_env_or_dotenv("BACKEND_INTERNAL_URL") - .or_else(|| read_env_or_dotenv("BACKEND_URL")) - .map(|value| value.trim().trim_end_matches('/').to_string()) - .filter(|value| !value.is_empty()) -} - -fn read_env_or_dotenv(key: &str) -> Option { - if let Ok(value) = env::var(key) { - let trimmed = value.trim().trim_matches('"').to_string(); - if !trimmed.is_empty() { - return Some(trimmed); - } - } - - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../..") - .join(".env.all"); - let content = fs::read_to_string(root).ok()?; - for line in content.lines() { - let line = line.trim_end_matches('\r'); - if line.starts_with('#') || line.trim().is_empty() { - continue; - } - let Some((k, v)) = line.split_once('=') else { - continue; - }; - if k.trim() != key { - continue; - } - let trimmed = v.trim().trim_matches('"').to_string(); - if !trimmed.is_empty() { - return Some(trimmed); - } - } - None -} - -fn is_hop_by_hop_header(name: &str) -> bool { - matches!( - name.to_ascii_lowercase().as_str(), - "connection" - | "keep-alive" - | "proxy-authenticate" - | "proxy-authorization" - | "te" - | "trailers" - | "transfer-encoding" - | "upgrade" - ) -} - -pub async fn next_sidebar( - State(state): State, - Extension(context): Extension, - Query(query): Query, -) -> Result<(StatusCode, Json), WebError> { - let effective_workspace_id = - resolve_effective_workspace_id(&context, query.workspace_id.as_deref(), true)? - .expect("workspace_required 已确保存在"); - - let snapshot = load_projection_snapshot( - state.config(), - &context, - &ProjectionSnapshotSpec { - workspace_id: &effective_workspace_id, - root_node_id: None, - depth: None, - query: None, - max_results: None, - projection: KernelProjectionKind::SidebarTree, - }, - ) - .await?; - - let mut dataset_object = snapshot.dataset.as_object().cloned().ok_or_else(|| { - WebError::bad_gateway_code("convex_bad_response", "sidebar.dataset.list 返回值不是对象") - .with_context(&context) - .with_header("x-error-phase", "compat_sidebar_shape") - .with_header("x-upstream-service", "convex") - })?; - dataset_object.insert("kernel_sidebar_projection".into(), snapshot.projection); - - Ok(( - StatusCode::OK, - Json(json!({ - "ok": true, - "boundary": "next_sidebar_compat", - "requestId": context.trace.request_id, - "traceId": context.trace.trace_id, - "workspaceId": effective_workspace_id, - "result": Value::Object(dataset_object), - })), - )) -} - #[cfg(test)] mod tests { use crate::app::{build_app, AppConfig, AppState}; - use crate::context::RequestContext; - use crate::routes::compat::{ - build_hermes_run_payload, build_hermes_sse_response, parse_sse_data_json_values, - }; use axum::body::Body; - use axum::http::{HeaderMap, Method, Request, StatusCode, Uri}; + use axum::http::{Request, StatusCode}; use axum::response::IntoResponse; - use serde_json::json; use tokio::net::TcpListener; use tower::util::ServiceExt; @@ -966,7 +291,7 @@ mod tests { } #[tokio::test] - async fn compat_sidebar_route_returns_dataset_and_projection() { + async fn compat_next_sidebar_route_is_not_registered_by_default() { let response = app() .oneshot( Request::builder() @@ -977,7 +302,7 @@ mod tests { .await .expect("response"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } #[tokio::test] @@ -1027,60 +352,6 @@ mod tests { assert!(!text.contains("legacy_next_compat_disabled")); } - #[test] - fn hermes_payload_uses_page_messages_and_session_id() { - let payload = json!({ - "messages": [ - {"role": "system", "content": "系统约束"}, - {"role": "user", "content": "总结当前页面"} - ], - "options": { - "ai": { - "provider": "hermes", - "sessionId": "hermes-session-1" - } - } - }); - - let hermes_payload = build_hermes_run_payload(&payload); - - assert_eq!(hermes_payload["session_id"], "hermes-session-1"); - assert_eq!(hermes_payload["input"][1]["content"], "总结当前页面"); - assert_eq!(hermes_payload["conversation_history"][0]["role"], "system"); - } - - #[tokio::test] - async fn hermes_sse_is_translated_to_page_ai_events() { - let events = parse_sse_data_json_values( - "data: {\"event\":\"message.delta\",\"delta\":\"Hel\"}\n\n\ - data: {\"event\":\"message.delta\",\"delta\":\"lo\"}\n\n\ - data: {\"event\":\"run.completed\",\"output\":\"Hello\"}\n\n", - ); - let context = RequestContext::from_http_parts( - &Method::POST, - &"/api/ai-agent/run".parse::().expect("uri"), - &HeaderMap::new(), - ); - - let response = build_hermes_sse_response(&context, events).expect("response"); - - assert_eq!(response.status(), StatusCode::OK); - assert_eq!( - response - .headers() - .get("x-mnote-ai-execution-owner") - .and_then(|value| value.to_str().ok()), - Some("hermes") - ); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("body"); - let text = String::from_utf8(body.to_vec()).expect("utf8"); - assert!(text.contains("event: assistant_message")); - assert!(text.contains("event: completion")); - assert!(text.contains("Hello")); - } - #[tokio::test] async fn explicit_agent_provider_does_not_fallback_to_mnote_cli_when_next_unavailable() { let response = build_app(AppState::new(AppConfig { @@ -1126,11 +397,11 @@ mod tests { .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(text.contains("codex")); - assert!(!text.contains("mnote-cli")); + assert!(text.contains("provider 直连链路已退场")); } #[tokio::test] - async fn ai_agent_run_proxies_to_next_ai_route_when_legacy_next_compat_enabled() { + async fn explicit_hermes_provider_fails_instead_of_using_legacy_next_or_direct_bridge() { let next_app = axum::Router::new().route( "/api/ai-agent/run", axum::routing::post(|| async move { @@ -1180,20 +451,20 @@ mod tests { .await .expect("response"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); assert_eq!( response .headers() - .get("x-mnote-web-owner") + .get("x-mnote-ai-execution-owner") .and_then(|value| value.to_str().ok()), - Some("mnote-web") + Some("provider-bridge-unavailable") ); let body = axum::body::to_bytes(response.into_body(), usize::MAX) .await .expect("body"); let text = String::from_utf8(body.to_vec()).expect("utf8"); - assert!(text.contains("assistant_message")); - assert!(text.contains("hello from next ai")); + assert!(text.contains("hermes")); + assert!(!text.contains("hello from next ai")); server.abort(); } diff --git a/rust/crates/mnote-web/src/routes/gateway.rs b/rust/crates/mnote-web/src/routes/gateway.rs index d8b8fd10..de363410 100644 --- a/rust/crates/mnote-web/src/routes/gateway.rs +++ b/rust/crates/mnote-web/src/routes/gateway.rs @@ -13,12 +13,15 @@ use crate::workspace_shell::{ build_workspace_shell_projection, render_workspace_shell_sidebar_html, }; use axum::body::Body; +use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{Extension, Query, State}; -use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode}; +use axum::http::{header, HeaderName, HeaderValue, Request, StatusCode, Uri}; use axum::response::{Html, IntoResponse, Response}; +use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::time::Duration; +use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream"; @@ -482,6 +485,132 @@ pub async fn legacy_next_proxy( Ok(response) } +#[allow(dead_code)] +pub async fn legacy_next_websocket_proxy( + ws: WebSocketUpgrade, + State(state): State, + Extension(context): Extension, + uri: Uri, +) -> Result { + if !state.config().enable_legacy_next_compat { + return Err(WebError::service_unavailable_code( + "legacy_next_compat_disabled", + "Next App Router legacy compat 已关闭,当前路径未迁到 Rust Web gateway。", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); + } + + let Some(base_url) = state.config().legacy_next_base_url.as_deref() else { + return Err(WebError::service_unavailable_code( + "legacy_next_upstream_missing", + "未配置 MNOTE_WEB_LEGACY_NEXT_BASE_URL,无法代理 legacy Next WebSocket。", + ) + .with_context(&context) + .with_header(HEADER_MNOTE_WEB_OWNER, "mnote-web")); + }; + + let upstream_url = build_legacy_next_ws_url(base_url, &uri)?; + Ok(ws.on_upgrade(move |socket| async move { + if let Err(error) = proxy_legacy_next_websocket(socket, upstream_url).await { + tracing::warn!(error = %error, "legacy Next WebSocket 代理已断开"); + } + })) +} + +#[allow(dead_code)] +fn build_legacy_next_ws_url(base_url: &str, uri: &Uri) -> Result { + let upstream = reqwest::Url::parse(base_url) + .map_err(|error| WebError::internal(format!("legacy Next upstream URL 非法: {error}")))?; + let scheme = match upstream.scheme() { + "https" => "wss", + _ => "ws", + }; + let host = upstream + .host_str() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| WebError::internal("legacy Next upstream URL 缺少 host"))?; + let host_with_port = match upstream.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + let path_and_query = uri + .path_and_query() + .map(|value| value.as_str()) + .unwrap_or("/"); + Ok(format!("{scheme}://{host_with_port}{path_and_query}")) +} + +#[allow(dead_code)] +async fn proxy_legacy_next_websocket( + socket: WebSocket, + upstream_url: String, +) -> Result<(), Box> { + let (upstream, _) = tokio_tungstenite::connect_async(upstream_url.as_str()).await?; + let (mut client_tx, mut client_rx) = socket.split(); + let (mut upstream_tx, mut upstream_rx) = upstream.split(); + + let client_to_upstream = async { + while let Some(message) = client_rx.next().await { + let Ok(message) = message else { + break; + }; + if upstream_tx + .send(axum_ws_to_tungstenite(message)) + .await + .is_err() + { + break; + } + } + }; + + let upstream_to_client = async { + while let Some(message) = upstream_rx.next().await { + let Ok(message) = message else { + break; + }; + if client_tx + .send(tungstenite_to_axum_ws(message)) + .await + .is_err() + { + break; + } + } + }; + + tokio::select! { + _ = client_to_upstream => {} + _ = upstream_to_client => {} + } + + Ok(()) +} + +#[allow(dead_code)] +fn axum_ws_to_tungstenite(message: AxumWsMessage) -> TungsteniteMessage { + match message { + AxumWsMessage::Text(value) => TungsteniteMessage::Text(value.to_string().into()), + AxumWsMessage::Binary(value) => TungsteniteMessage::Binary(value), + AxumWsMessage::Ping(value) => TungsteniteMessage::Ping(value), + AxumWsMessage::Pong(value) => TungsteniteMessage::Pong(value), + AxumWsMessage::Close(_) => TungsteniteMessage::Close(None), + } +} + +#[allow(dead_code)] +fn tungstenite_to_axum_ws(message: TungsteniteMessage) -> AxumWsMessage { + match message { + TungsteniteMessage::Text(value) => AxumWsMessage::Text(value.to_string().into()), + TungsteniteMessage::Binary(value) => AxumWsMessage::Binary(value), + TungsteniteMessage::Ping(value) => AxumWsMessage::Ping(value), + TungsteniteMessage::Pong(value) => AxumWsMessage::Pong(value), + TungsteniteMessage::Close(_) => AxumWsMessage::Close(None), + TungsteniteMessage::Frame(_) => AxumWsMessage::Close(None), + } +} + async fn resolve_root_workspace_id( state: &AppState, context: &RequestContext, @@ -947,6 +1076,18 @@ mod tests { format!("http://{addr}") } + async fn spawn_legacy_unmatched_upstream() -> String { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("legacy listener"); + let addr = listener.local_addr().expect("legacy addr"); + let app = axum::Router::new().route("/unmigrated", get(|| async { "legacy-ok" })); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("legacy server"); + }); + format!("http://{addr}") + } + #[tokio::test] async fn gateway_health_declares_mnote_web_owner() { let response = app() @@ -1377,4 +1518,27 @@ mod tests { .iter() .any(|value| value.contains("__convexAuthRefreshToken=refresh-demo"))); } + + #[tokio::test] + async fn unmigrated_route_returns_not_found_instead_of_proxying_to_legacy_next() { + let legacy_base_url = spawn_legacy_unmatched_upstream().await; + let response = app_with_legacy_next_base_url(legacy_base_url) + .oneshot( + Request::builder() + .uri("/unmigrated") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response + .headers() + .get("x-mnote-legacy-upstream") + .and_then(|value| value.to_str().ok()), + None + ); + } } diff --git a/rust/crates/mnote-web/src/routes/local_folder_events.rs b/rust/crates/mnote-web/src/routes/local_folder_events.rs index 242e8bd0..9645a7d5 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_events.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_events.rs @@ -57,12 +57,12 @@ pub async fn local_folder_events( let stream = stream::unfold( (Some(initial), subscription, document_relative_path), |(initial, mut subscription, document_relative_path)| async move { - if let Some(payload) = initial { - return Some(( - Ok(stream_event("ready", &payload)), - (None, subscription, document_relative_path), - )); - } + if let Some(payload) = initial { + return Some(( + Ok(stream_event("ready", &payload)), + (None, subscription, document_relative_path), + )); + } loop { match subscription.receiver.recv().await { Ok(payload) => { @@ -152,8 +152,7 @@ mod tests { #[test] fn local_markdown_document_id_maps_to_relative_path() { assert_eq!( - local_markdown_relative_path_from_document_id("local-md:docs~2FREADME.md") - .as_deref(), + local_markdown_relative_path_from_document_id("local-md:docs~2FREADME.md").as_deref(), Some("docs/README.md") ); } diff --git a/rust/crates/mnote-web/src/routes/local_folder_source.rs b/rust/crates/mnote-web/src/routes/local_folder_source.rs index 8774a159..05b35b5c 100644 --- a/rust/crates/mnote-web/src/routes/local_folder_source.rs +++ b/rust/crates/mnote-web/src/routes/local_folder_source.rs @@ -3156,8 +3156,11 @@ fn main() {} .to_string(); std::thread::sleep(std::time::Duration::from_millis(5)); - std::fs::write(root.join("README.md"), "---\ntitle: Stale\n---\n# External\n") - .expect("external write"); + std::fs::write( + root.join("README.md"), + "---\ntitle: Stale\n---\n# External\n", + ) + .expect("external write"); let error = save_local_markdown_page( &root_uri, @@ -3168,9 +3171,7 @@ fn main() {} ]), ) .expect_err("stale save should fail"); - assert!(error - .message() - .contains("本地 Markdown 文件已被外部修改")); + assert!(error.message().contains("本地 Markdown 文件已被外部修改")); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); assert!(saved.contains("# External")); diff --git a/rust/crates/mnote-web/src/routes/mod.rs b/rust/crates/mnote-web/src/routes/mod.rs index aa45368f..c7778d58 100644 --- a/rust/crates/mnote-web/src/routes/mod.rs +++ b/rust/crates/mnote-web/src/routes/mod.rs @@ -7,8 +7,8 @@ mod gateway; mod health; mod hermes; mod kernel; -mod local_folder_source; mod local_folder_events; +mod local_folder_source; mod local_markdown_parser; mod media; mod mindmap_api; @@ -30,7 +30,6 @@ use axum::Router; pub fn build_router(state: AppState) -> Router { let hermes_base_path = state.config().hermes_base_path.clone(); - let compat_next_base_path = state.config().compat_next_base_path.clone(); let enable_debug_shell_routes = state.config().enable_debug_shell_routes; let mut router = Router::new() @@ -91,7 +90,6 @@ pub fn build_router(state: AppState) -> Router { ) .route("/api/documents/meta", get(documents::meta)) .route("/api/documents/content", get(documents::content)) - .route("/api/documents/page", get(web_shell::documents_page_compat)) .route("/api/documents/purge", post(documents::purge)) .route("/api/documents/title", post(documents::title)) .route("/api/documents/options", post(documents::options)) @@ -137,21 +135,132 @@ pub fn build_router(state: AppState) -> Router { Router::new() .route("/health", get(hermes::health)) .route("/bridge", post(hermes::bridge_runtime)), - ) - .nest( - &compat_next_base_path, - Router::new() - .route("/ai-agent/run", post(compat::next_ai_agent_run)) - .route("/sidebar", get(compat::next_sidebar)), - ) - .fallback(gateway::legacy_next_proxy); + ); if enable_debug_shell_routes { router = router .route("/tree", get(tree::tree_shell)) - .route("/document-debug", get(editor::document_editor_shell)) - .route("/document", get(editor::document_editor_shell)); + .route("/document-debug", get(editor::document_editor_shell)); } router.with_state(state) } + +#[cfg(test)] +mod tests { + use super::build_router; + use crate::app::{AppConfig, AppState}; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use axum::Router; + use tower::ServiceExt; + + fn app(enable_debug_shell_routes: bool) -> Router { + build_router(AppState::new(AppConfig { + service_name: "mnote-web".into(), + service_version: "0.1.0".into(), + bind_addr: "127.0.0.1:0".into(), + public_bind_addr: "127.0.0.1:3000".into(), + legacy_next_base_url: Some("http://127.0.0.1:3100".into()), + enable_legacy_next_compat: false, + enable_debug_shell_routes, + hermes_base_path: "/api/hermes".into(), + compat_next_base_path: "/api/compat/next".into(), + convex_url: None, + convex_admin_key: None, + allow_dev_fixtures: false, + query_fixtures_json: None, + mutation_fixtures_json: None, + dev_user_id: "dev-user".into(), + dev_user_name: "开发用户".into(), + dev_user_email: "dev@mnote.local".into(), + })) + } + + #[tokio::test] + async fn debug_shell_routes_are_not_mounted_by_default() { + for path in ["/tree", "/document-debug"] { + let response = app(false) + .oneshot( + Request::builder() + .uri(path) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_eq!( + response.status(), + StatusCode::NOT_FOUND, + "{path} 默认不应暴露" + ); + } + } + + #[tokio::test] + async fn debug_shell_routes_are_only_available_when_explicitly_enabled() { + let tree_response = app(true) + .oneshot( + Request::builder() + .uri("/tree") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_ne!( + tree_response.status(), + StatusCode::NOT_FOUND, + "/tree 显式启用 debug gate 后不应继续返回 404", + ); + + let document_debug_response = app(true) + .oneshot( + Request::builder() + .uri("/document-debug") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_ne!( + document_debug_response.status(), + StatusCode::NOT_FOUND, + "/document-debug 显式启用 debug gate 后不应继续返回 404", + ); + } + + #[tokio::test] + async fn onlyoffice_and_media_transport_routes_are_explicitly_mounted() { + for (method, path) in [ + ("GET", "/onlyoffice"), + ("POST", "/api/onlyoffice/sign"), + ("GET", "/api/onlyoffice/proxy?u=x"), + ("POST", "/api/onlyoffice/callback"), + ("POST", "/api/onlyoffice/forcesave"), + ("POST", "/api/media/upload"), + ("GET", "/api/media/sign?assetId=x"), + ("POST", "/api/tree/filetree/upload-target-preflight"), + ( + "GET", + "/onlyoffice-server/web-apps/apps/api/documents/api.js", + ), + ] { + let response = app(false) + .oneshot( + Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + assert_ne!( + response.status(), + StatusCode::NOT_FOUND, + "{method} {path} 应显式挂到 Rust 3000 单入口边界", + ); + } + } +} diff --git a/rust/crates/mnote-web/src/routes/sse.rs b/rust/crates/mnote-web/src/routes/sse.rs index e8132745..109edf34 100644 --- a/rust/crates/mnote-web/src/routes/sse.rs +++ b/rust/crates/mnote-web/src/routes/sse.rs @@ -73,7 +73,8 @@ pub async fn events( match change.kind { StreamChangeKind::Delta => { - let payload = build_stream_delta_payload( + let Ok(payload) = build_stream_delta_payload( + state.app_state.config(), &state.context, &state.query, &workspace_id, @@ -82,7 +83,11 @@ pub async fn events( change .delta .unwrap_or_else(|| serde_json::json!({ "op": "noop" })), - ); + ) + .await + else { + return None; + }; return Some((Ok(stream_event("delta", &payload)), Some(state))); } StreamChangeKind::Resync => { @@ -260,6 +265,8 @@ mod tests { let text = String::from_utf8(body.to_vec()).expect("utf8"); assert!(text.contains("event: snapshot") || text.contains("event:snapshot")); assert!(text.contains("\"kind\":\"snapshot\"")); + assert!(text.contains("\"kernel_sidebar_projection\"")); + assert!(text.contains("\"kernel_file_tree_projection\"")); } #[tokio::test] diff --git a/rust/crates/mnote-web/src/routes/stream_support.rs b/rust/crates/mnote-web/src/routes/stream_support.rs index 0187c0ac..9e2ea6f7 100644 --- a/rust/crates/mnote-web/src/routes/stream_support.rs +++ b/rust/crates/mnote-web/src/routes/stream_support.rs @@ -455,6 +455,114 @@ pub fn read_stream_cursor_from_payload(payload: &Value) -> Option { read_string_field(payload, &["cursor"]) } +fn attach_workspace_dataset_projections( + mut dataset: Value, + sidebar_projection: Value, + file_tree_projection: Value, +) -> Value { + let Some(map) = dataset.as_object_mut() else { + return dataset; + }; + map.insert( + "kernel_sidebar_projection".into(), + sidebar_projection.clone(), + ); + map.insert("kernelSidebarProjection".into(), sidebar_projection); + map.insert( + "kernel_file_tree_projection".into(), + file_tree_projection.clone(), + ); + map.insert("kernelFileTreeProjection".into(), file_tree_projection); + dataset +} + +async fn load_workspace_stream_snapshot( + config: &AppConfig, + context: &RequestContext, + workspace_id: &str, + depth: Option, +) -> Result { + let sidebar_loaded = load_projection_snapshot( + config, + context, + &ProjectionSnapshotSpec { + workspace_id, + root_node_id: None, + depth, + query: None, + max_results: None, + projection: KernelProjectionKind::SidebarTree, + }, + ) + .await?; + let file_tree_loaded = load_projection_snapshot( + config, + context, + &ProjectionSnapshotSpec { + workspace_id, + root_node_id: None, + depth, + query: None, + max_results: None, + projection: KernelProjectionKind::FileTree, + }, + ) + .await?; + let dataset = attach_workspace_dataset_projections( + sidebar_loaded.dataset, + sidebar_loaded.projection.clone(), + file_tree_loaded.projection, + ); + + Ok(json!({ + "dataset": dataset, + "tree": sidebar_loaded.projection, + })) +} + +fn delta_document_patch_is_title_only(candidate: &Value) -> bool { + let Some(map) = candidate.as_object() else { + return false; + }; + map.keys().all(|key| { + matches!( + key.as_str(), + "id" | "documentId" | "title" | "updatedAt" | "updated_at" + ) + }) +} + +fn delta_requires_projection_snapshot(delta: &Value) -> bool { + let Some(map) = delta.as_object() else { + return false; + }; + let op = map + .get("op") + .and_then(Value::as_str) + .map(str::trim) + .unwrap_or(""); + if op.is_empty() || op == "noop" { + return false; + } + if op == "upsert_document" { + return !map + .get("node") + .or_else(|| map.get("document")) + .is_some_and(delta_document_patch_is_title_only); + } + if op == "upsert_documents" { + let Some(items) = map + .get("upsertDocuments") + .or_else(|| map.get("upsert_documents")) + .and_then(Value::as_array) + else { + return true; + }; + return !items.iter().all(delta_document_patch_is_title_only); + } + true +} + pub fn with_stream_kind(payload: &Value, kind: &str) -> Value { if let Some(mut map) = payload.as_object().cloned() { map.insert("kind".into(), Value::String(kind.into())); @@ -466,16 +574,23 @@ pub fn with_stream_kind(payload: &Value, kind: &str) -> Value { }) } -pub fn build_stream_delta_payload( +pub async fn build_stream_delta_payload( + config: &AppConfig, context: &RequestContext, query: &StreamSnapshotQuery, workspace_id: &str, overview: &Value, cursor: Option, delta: Value, -) -> Value { +) -> Result { let scope = resolve_stream_scope(query); - json!({ + let snapshot = + if scope == StreamSnapshotScope::Workspace && delta_requires_projection_snapshot(&delta) { + load_workspace_stream_snapshot(config, context, workspace_id, query.depth).await? + } else { + Value::Null + }; + Ok(json!({ "kind": "delta", "revision": cursor.clone().unwrap_or_else(|| "0".into()), "stream": scope.as_str(), @@ -487,9 +602,9 @@ pub fn build_stream_delta_payload( "depth": query.depth, "cursor": cursor, "data": delta, - "snapshot": Value::Null, + "snapshot": snapshot, "overview": overview, - }) + })) } pub async fn load_stream_overview( @@ -522,24 +637,8 @@ pub async fn load_stream_snapshot( let snapshot = match scope { StreamSnapshotScope::Workspace => { - let loaded = load_projection_snapshot( - config, - context, - &ProjectionSnapshotSpec { - workspace_id: &effective_workspace_id, - root_node_id: None, - depth: query.depth, - query: None, - max_results: None, - projection: KernelProjectionKind::SidebarTree, - }, - ) - .await?; - - json!({ - "dataset": loaded.dataset, - "tree": loaded.projection, - }) + load_workspace_stream_snapshot(config, context, &effective_workspace_id, query.depth) + .await? } StreamSnapshotScope::Subtree => { let root_node_id = @@ -590,8 +689,8 @@ pub async fn load_stream_snapshot( #[cfg(test)] mod tests { use super::{ - resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind, - StreamSnapshotQuery, StreamSnapshotScope, + delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor, + resolve_stream_scope, StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope, }; use serde_json::json; @@ -1192,4 +1291,39 @@ mod tests { assert_eq!(change.kind, StreamChangeKind::Resync); assert_eq!(change.delta, None); } + + #[test] + fn title_only_delta_does_not_require_projection_snapshot() { + assert!(!delta_requires_projection_snapshot(&json!({ + "op": "upsert_document", + "document": { + "id": "page_1", + "title": "新标题" + } + }))); + assert!(!delta_requires_projection_snapshot(&json!({ + "op": "upsert_documents", + "upsertDocuments": [ + { + "id": "page_1", + "title": "新标题" + } + ] + }))); + } + + #[test] + fn structural_delta_requires_projection_snapshot() { + assert!(delta_requires_projection_snapshot(&json!({ + "op": "move_document", + "documentId": "page_1", + "parentId": "page_2" + }))); + assert!(delta_requires_projection_snapshot(&json!({ + "op": "upsert_assets", + "upsertAssets": [ + { "id": "asset_1" } + ] + }))); + } } diff --git a/rust/crates/mnote-web/src/routes/tree.rs b/rust/crates/mnote-web/src/routes/tree.rs index ef1e5dd5..f84d52f8 100644 --- a/rust/crates/mnote-web/src/routes/tree.rs +++ b/rust/crates/mnote-web/src/routes/tree.rs @@ -404,6 +404,15 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DocumentPageCompatQuery { - pub document_id: String, - pub workspace_id: Option, -} - pub async fn document_page_shell( State(state): State, Extension(context): Extension, @@ -434,30 +427,38 @@ pub(crate) fn render_document_title_controller_script() -> &'static str { inputs.forEach((input) => { input.setAttribute('data-title-controller', CONTRACT); const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title'; - const paneRole = (input.getAttribute('data-pane-role') || 'primary').trim(); - const rawDocumentId = (input.getAttribute('data-document-id') || '').trim(); - const documentId = rawDocumentId || ( - paneRole === 'primary' - ? (document.body?.dataset.documentId || '').trim() - : '' - ); - const query = new URLSearchParams(window.location.search); - const paneQueryParams = paneRole === 'secondary' - ? { sourceKindParam: 'secondarySourceKind', rootUriParam: 'secondaryRootUri' } - : { sourceKindParam: 'sourceKind', rootUriParam: 'rootUri' }; - const sourceKindParam = paneQueryParams.sourceKindParam; - const rootUriParam = paneQueryParams.rootUriParam; - const workspaceId = (input.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim(); - const sourceKind = (query.get(sourceKindParam) || '').trim(); - const rootUri = (query.get(rootUriParam) || '').trim(); - let lastSavedTitle = input.value.trim() || '无标题'; + const resolveTitleTarget = (targetInput) => { + const paneRole = (targetInput.getAttribute('data-pane-role') || 'primary').trim(); + const rawDocumentId = (targetInput.getAttribute('data-document-id') || '').trim(); + const documentId = rawDocumentId || ( + paneRole === 'primary' + ? (document.body?.dataset.documentId || '').trim() + : '' + ); + const query = new URLSearchParams(window.location.search); + const paneQueryParams = paneRole === 'secondary' + ? { sourceKindParam: 'secondarySourceKind', rootUriParam: 'secondaryRootUri' } + : { sourceKindParam: 'sourceKind', rootUriParam: 'rootUri' }; + return { + documentId, + workspaceId: (targetInput.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim(), + sourceKind: (query.get(paneQueryParams.sourceKindParam) || '').trim(), + rootUri: (query.get(paneQueryParams.rootUriParam) || '').trim(), + }; + }; + const readLastSavedTitle = () => input.getAttribute('data-title-last-saved') || '无标题'; + const writeLastSavedTitle = (title) => { + input.setAttribute('data-title-last-saved', title || '无标题'); + }; + writeLastSavedTitle(input.value.trim() || '无标题'); let saving = false; const saveTitle = async () => { const title = input.value.trim() || '无标题'; + const currentTarget = resolveTitleTarget(input); autosize(input); - if (!documentId || saving || title === lastSavedTitle) { - updateVisibleTitle(input, title, documentId); + if (!currentTarget.documentId || saving || title === readLastSavedTitle()) { + updateVisibleTitle(input, title, currentTarget.documentId); setStatus(input, 'saved'); return; } @@ -468,10 +469,10 @@ pub(crate) fn render_document_title_controller_script() -> &'static str { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ - documentId, - workspaceId: workspaceId || null, - sourceKind: sourceKind || undefined, - rootUri: rootUri || undefined, + documentId: currentTarget.documentId, + workspaceId: currentTarget.workspaceId || null, + sourceKind: currentTarget.sourceKind || undefined, + rootUri: currentTarget.rootUri || undefined, title, commandName: 'page.head.updateTitle', }), @@ -480,11 +481,11 @@ pub(crate) fn render_document_title_controller_script() -> &'static str { if (!response.ok || !payload || payload.ok !== true) { throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`); } - lastSavedTitle = title; - updateVisibleTitle(input, title, documentId); + writeLastSavedTitle(title); + updateVisibleTitle(input, title, currentTarget.documentId); setStatus(input, 'saved'); window.dispatchEvent(new CustomEvent('tree:title-updated', { - detail: { documentId, workspaceId: workspaceId || null, title, payload }, + detail: { documentId: currentTarget.documentId, workspaceId: currentTarget.workspaceId || null, title, payload }, })); } catch (error) { setStatus(input, 'error', error instanceof Error ? error.message : String(error)); @@ -495,7 +496,7 @@ pub(crate) fn render_document_title_controller_script() -> &'static str { input.addEventListener('input', () => { autosize(input); - setStatus(input, input.value.trim() === lastSavedTitle ? 'saved' : 'dirty'); + setStatus(input, (input.value.trim() || '无标题') === readLastSavedTitle() ? 'saved' : 'dirty'); }); input.addEventListener('keydown', (event) => { if (event.key === 'Enter' && !event.shiftKey) { @@ -505,7 +506,7 @@ pub(crate) fn render_document_title_controller_script() -> &'static str { }); input.addEventListener('blur', () => { void saveTitle(); }); autosize(input); - updateVisibleTitle(input, lastSavedTitle, documentId); + updateVisibleTitle(input, readLastSavedTitle(), resolveTitleTarget(input).documentId); setStatus(input, 'saved'); }); })(); @@ -681,6 +682,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { throw new Error('island runtime 导出不完整'); } await runtime.default(wasmUrl); + if (typeof runtime.mount_mindmap_shell === 'function' && typeof runtime.unmount_mindmap_shell === 'function') { + window.__MNOTE_MINDMAP_RUST_SHELL__ = { + mount: runtime.mount_mindmap_shell, + unmount: runtime.unmount_mindmap_shell, + }; + } return runtime; })(); return window.__mnoteLeptosTiptapRuntimePromise; @@ -1560,6 +1567,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str { node.value = title; node.setAttribute('data-document-id', documentId); node.setAttribute('data-workspace-id', workspaceId); + node.setAttribute('data-title-last-saved', title); node.setAttribute('data-title-save-status', 'saved'); node.style.height = 'auto'; node.style.height = `${Math.max(48, node.scrollHeight)}px`; @@ -2015,49 +2023,6 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path) -> Result, - Extension(context): Extension, - Query(query): Query, -) -> Result { - let document_id = query.document_id.trim().to_string(); - if document_id.is_empty() { - return Err( - WebError::bad_request_code("document_id_required", "缺少有效 documentId") - .with_context(&context), - ); - } - let aggregate = build_page_aggregate_snapshot( - &state, - &context, - &document_id, - query.workspace_id.as_deref(), - None, - None, - ) - .await?; - let projection_owner = aggregate.source_label(); - let mut response = ( - StatusCode::OK, - Json(json!({ - "ok": true, - "owner": "mnote-web", - "schema": "mnote.documents_page_compat.v1", - "page": aggregate, - "requestId": context.trace.request_id, - "traceId": context.trace.trace_id, - })), - ) - .into_response(); - stamp_shell_headers(response.headers_mut(), "documents-page-compat"); - if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-page-aggregate-owner") { - if let Ok(value) = HeaderValue::from_str(projection_owner) { - response.headers_mut().insert(name, value); - } - } - Ok(response) -} - pub async fn page_aggregate( State(state): State, Extension(context): Extension, @@ -2488,6 +2453,8 @@ mod tests { assert!(html.contains("data-page-title-input=\"true\"")); assert!(html.contains("data-title-endpoint=\"/api/documents/title\"")); assert!(html.contains("mnote.document_title_controller.v1")); + assert!(html.contains("const currentTarget = resolveTitleTarget(input);")); + assert!(html.contains("documentId: currentTarget.documentId")); assert!(html.contains( r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"# )); @@ -2642,6 +2609,9 @@ mod tests { assert!(html.contains("Child Page")); assert!(html.contains("asset.png")); assert!(html.contains("data-row-kind=\"markdown\"")); + assert!(html.contains("data-page-openable=\"false\"")); + assert!(html.contains("fileAction === 'open' && rowKind === 'folder'")); + assert!(html.contains("btn.getAttribute('data-page-openable') === 'false'")); assert!(html.contains("data-mnote-action=\"open-local-folder\"")); assert!(html.contains("refreshSessionFromExternalFileChange")); assert!(html.contains("/api/local-folder/events")); diff --git a/rust/crates/mnote-web/src/ssr/pages/layout.rs b/rust/crates/mnote-web/src/ssr/pages/layout.rs index 146bc321..c115cff0 100644 --- a/rust/crates/mnote-web/src/ssr/pages/layout.rs +++ b/rust/crates/mnote-web/src/ssr/pages/layout.rs @@ -23,7 +23,6 @@ const SIDEBAR_TREE_JS: &str = r##" anchorRowId: null, focusedRowId: null }; - var projectionRefreshTimer = 0; var activeTreeContextMenu = null; var activeEditorAttachmentLink = null; var attachmentActionsHideTimer = 0; @@ -854,6 +853,27 @@ const SIDEBAR_TREE_JS: &str = r##" return value; } + function readSidebarDataset(value) { + if (!value || typeof value !== 'object') return null; + if (value.snapshot && value.snapshot.dataset && typeof value.snapshot.dataset === 'object') return value.snapshot.dataset; + if (value.data && value.data.dataset && typeof value.data.dataset === 'object') return value.data.dataset; + if (value.dataset && typeof value.dataset === 'object') return value.dataset; + if (value.sidebar && typeof value.sidebar === 'object') return value.sidebar; + return null; + } + + function readDatasetProjection(value, snakeCaseKey, camelCaseKey) { + var dataset = readSidebarDataset(value); + if (!dataset || typeof dataset !== 'object') return null; + if (dataset[snakeCaseKey] && typeof dataset[snakeCaseKey] === 'object') return dataset[snakeCaseKey]; + if (dataset[camelCaseKey] && typeof dataset[camelCaseKey] === 'object') return dataset[camelCaseKey]; + return null; + } + + function setTreeLiveApplyError(reason) { + document.documentElement.setAttribute('data-mnote-tree-live-apply-error', String(reason || 'tree_live_apply_failed')); + } + function projectionItems(projection) { var resolved = readProjection(projection); return resolved && Array.isArray(resolved.items) ? resolved.items : []; @@ -901,14 +921,16 @@ const SIDEBAR_TREE_JS: &str = r##" var children = grouped.get(nodeId) || []; var expandable = Boolean(item.expandable || item.childCount > 0 || children.length); var expanded = expandable && item.expandedByDefault !== false; + var meta = item && item.resourceMeta && typeof item.resourceMeta === 'object' ? item.resourceMeta : {}; + var openable = Boolean(meta.documentId || item.documentId || !String(nodeId).startsWith('local-dir:')); var toggle = expandable ? '' : ''; - var childHtml = expandable && expanded - ? '
    ' + renderPageRows(nodeId, grouped, activeId, depth + 1) + '
' + var childHtml = expandable + ? '
    ' + renderPageRows(nodeId, grouped, activeId, depth + 1) + '
' : ''; var currentAttr = nodeId === activeId ? ' aria-current="page" aria-selected="true"' : ' aria-selected="false"'; - return '
  • ' + toggle + '
    ' + childHtml + '
  • '; + return '
  • ' + toggle + '
    ' + childHtml + '
  • '; }).join(''); } @@ -965,8 +987,8 @@ const SIDEBAR_TREE_JS: &str = r##" var createAction = rowKind === 'document' ? '' : ''; - var childHtml = expandable && expanded - ? '
      ' + renderFileRows(nodeId, grouped, activeId) + '
    ' + var childHtml = expandable + ? '
      ' + renderFileRows(nodeId, grouped, activeId) + '
    ' : ''; return '
  • ' + toggle + '
    ' + createAction + '
    ' + childHtml + '
  • '; }).join(''); @@ -981,30 +1003,41 @@ const SIDEBAR_TREE_JS: &str = r##" return true; } - async function fetchProjection(path, workspaceId) { - var url = new URL(path, window.location.origin); - url.searchParams.set('workspaceId', workspaceId || 'default'); - url.searchParams.set('depth', '99'); - var response = await fetch(url.toString(), { cache: 'no-store' }); - var payload = await response.json().catch(function(){ return null; }); - if (!response.ok || !payload || !payload.result) throw new Error('tree_projection_failed_' + response.status); - return payload.result; + function renderSidebarSnapshot(payload) { + var renderedPage = renderPageProjection(payload); + var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection'); + var renderedFile = fileProjection ? renderFileProjection(fileProjection) : false; + return renderedPage || renderedFile; } - function scheduleProjectionRefresh(workspaceId) { - if (projectionRefreshTimer) window.clearTimeout(projectionRefreshTimer); - projectionRefreshTimer = window.setTimeout(function() { - projectionRefreshTimer = 0; - var resolvedWorkspaceId = workspaceId || resolveWorkspaceId(document.body); - Promise.all([ - fetchProjection('/api/tree/projections/sidebar', resolvedWorkspaceId).then(renderPageProjection), - fetchProjection('/api/tree/projections/file', resolvedWorkspaceId).then(renderFileProjection) - ]).then(function() { - document.documentElement.setAttribute('data-mnote-tree-live-applied', 'true'); - }).catch(function(error) { - document.documentElement.setAttribute('data-mnote-tree-live-apply-error', error instanceof Error ? error.message : String(error)); - }); - }, 180); + function isTitleOnlyDocumentPatch(candidate) { + if (!candidate || typeof candidate !== 'object') return false; + var allowedKeys = { + id: true, + documentId: true, + title: true, + updatedAt: true, + updated_at: true + }; + return Object.keys(candidate).every(function(key) { + return allowedKeys[key] === true; + }); + } + + function deltaNeedsProjectionRefresh(payload) { + var data = payload && (payload.data || payload.delta || payload); + var op = data && typeof data.op === 'string' ? String(data.op).trim() : ''; + if (!op || op === 'noop') return false; + if (op === 'upsert_document') { + return !isTitleOnlyDocumentPatch(data.node || data.document || null); + } + if (op === 'upsert_documents') { + var documents = Array.isArray(data.upsertDocuments) ? data.upsertDocuments : Array.isArray(data.upsert_documents) ? data.upsert_documents : []; + if (documents.length > 0 && documents.every(isTitleOnlyDocumentPatch)) { + return false; + } + } + return true; } function toggleChildren(row, button) { @@ -1673,19 +1706,6 @@ const SIDEBAR_TREE_JS: &str = r##" return url.toString(); } - function openTreePicker(mode, detail) { - var url = new URL('/tree', window.location.origin); - url.searchParams.set('mode', 'picker'); - url.searchParams.set('allowRootPick', '1'); - url.searchParams.set('intent', mode); - if (detail.workspaceId) url.searchParams.set('workspaceId', detail.workspaceId); - if (detail.documentId) { - url.searchParams.set('sourceDocumentId', detail.documentId); - url.searchParams.set('excludeIds', detail.documentId); - } - window.location.assign(url.pathname + url.search); - } - function convertToPreviousSiblingChild(trigger, detail) { var documentId = detail.documentId || ''; var row = document.querySelector('#sidebar-tree-root .tree-row[data-node-id="' + cssEscape(documentId) + '"]'); @@ -1708,7 +1728,7 @@ const SIDEBAR_TREE_JS: &str = r##" documentId: documentId, parentId: previousId, sortOrder: children.length - }).then(function(){ scheduleProjectionRefresh(detail.workspaceId || resolveWorkspaceId(row)); }); + }); } function handleTreeContextMenuAction(action, detail, trigger) { @@ -1749,14 +1769,6 @@ const SIDEBAR_TREE_JS: &str = r##" dispatchSidebarEvent('tree.page.share', detail); return; } - if (action === 'move') { - openTreePicker('move', detail); - return; - } - if (action === 'embed') { - openTreePicker('embed', detail); - return; - } if (action === 'copy-link') { void copyTreeContextValue(documentHref(documentId, workspaceId), 'copy-link'); return; @@ -1807,7 +1819,7 @@ const SIDEBAR_TREE_JS: &str = r##" action: 'purge', workspaceId: workspaceId, documentId: documentId - }).then(function(){ scheduleProjectionRefresh(workspaceId); }); + }); } } @@ -1877,14 +1889,10 @@ const SIDEBAR_TREE_JS: &str = r##" { action: 'color', icon: 'format_paint', label: '颜色' } ] : isAsset ? [ { action: 'open-right', icon: 'open_in_new', label: '在右侧边栏打开', shortcut: 'Alt + O' }, - { action: 'move', icon: 'drive_file_move', label: '移动到...' }, { action: 'copy-id', icon: 'tag', label: '复制资源 ID' } ] : [ { action: 'open-right', icon: 'right_panel_open', label: '在右侧边栏打开', shortcut: 'Alt+' }, { separator: true }, - { action: 'move', icon: 'drive_file_move', label: '移动到...' }, - { action: 'embed', icon: 'account_tree', label: '嵌入到...' }, - { separator: true }, { action: 'copy-link', icon: 'link', label: '复制访问链接' }, { action: 'copy-reference-inline', icon: 'content_copy', label: '复制页面引用链接' }, { action: 'copy-id', icon: 'tag', label: '复制页面ID' }, @@ -3340,6 +3348,12 @@ const SIDEBAR_TREE_JS: &str = r##" openFileTreeContextMenu(fileRow, point.x, point.y, fileBtn || fileRow); return; } + if (fileAction === 'open' && rowKind === 'folder') { + e.preventDefault(); + selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey }); + toggleChildren(fileRow, fileRow.querySelector('[data-rust-action="toggle"]')); + return; + } e.preventDefault(); selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey }); dispatchSidebarEvent('tree.filetree.open', { rowId: rowId, rowKind: rowKind, documentId: documentId || null, assetId: assetId || null }); @@ -3367,6 +3381,10 @@ const SIDEBAR_TREE_JS: &str = r##" toggleChildren(row, btn); e.preventDefault(); } else if (action === 'open') { + if (btn.getAttribute('data-page-openable') === 'false') { + e.preventDefault(); + return; + } var workspaceId = resolveWorkspaceId(btn); navigateToDocument(nodeId, workspaceId, { treeView: 'page' }); e.preventDefault(); @@ -3384,7 +3402,6 @@ const SIDEBAR_TREE_JS: &str = r##" title: title.trim() }).then(function(){ updateTitleEverywhere(nodeId, title.trim()); - scheduleProjectionRefresh(resolveWorkspaceId(btn)); }); } } else if (action === 'menu') { @@ -3586,7 +3603,7 @@ const SIDEBAR_TREE_JS: &str = r##" documentId: sourceNodeId, parentId: target.parentId, sortOrder: target.sortOrder - }).then(function(){ scheduleProjectionRefresh(resolveWorkspaceId(pageRow)); }); + }); return; } var fileTree = document.getElementById('sidebar-file-tree-root'); @@ -3637,10 +3654,11 @@ const SIDEBAR_TREE_JS: &str = r##" window.addEventListener('tree:snapshot', function(event) { var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; - if (renderPageProjection(payload)) { + if (renderSidebarSnapshot(payload)) { document.documentElement.setAttribute('data-mnote-tree-live-applied', 'snapshot'); + return; } - scheduleProjectionRefresh(payload && payload.workspaceId); + setTreeLiveApplyError('tree_snapshot_missing_projection_payload'); }); window.addEventListener('tree:delta', function(event) { @@ -3652,14 +3670,24 @@ const SIDEBAR_TREE_JS: &str = r##" updateTitleEverywhere(doc.id || doc.documentId, doc.title || '无标题'); }); } + if (renderSidebarSnapshot(payload)) { + document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta'); + return; + } document.documentElement.setAttribute('data-mnote-tree-live-applied', 'delta'); - scheduleProjectionRefresh(payload && payload.workspaceId); + if (deltaNeedsProjectionRefresh(payload)) { + setTreeLiveApplyError('tree_delta_missing_projection_payload'); + } }); window.addEventListener('tree:resync', function(event) { var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; + if (renderSidebarSnapshot(payload)) { + document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync'); + return; + } document.documentElement.setAttribute('data-mnote-tree-live-applied', 'resync'); - scheduleProjectionRefresh(payload && payload.workspaceId); + setTreeLiveApplyError('tree_resync_missing_projection_payload'); }); var tree = document.getElementById('sidebar-tree-root'); @@ -3693,7 +3721,6 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##" schema: 'mnote.tree_live_bootstrap.v1', transport: 'convex-command-log-sse', endpoint: '/api/tree/events', - resyncEndpoint: '/api/tree/projections/sidebar', rootIds: [], initialRevision: null }; @@ -3790,9 +3817,6 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##" source.onerror = function(){ failures += 1; applyStatus(failures >= 3 ? 'resync-pending' : 'reconnecting'); - if (failures >= 3) { - dispatchTreeEvent('tree:resync-requested', { endpoint: bootstrap.resyncEndpoint, workspaceId: workspaceId }); - } }; } @@ -3872,7 +3896,6 @@ pub fn PageLayout( "rootIds": [], "initialRevision": null, "endpoint": "/api/tree/events", - "resyncEndpoint": "/api/tree/projections/sidebar", "views": ["page-tree", "file-tree"] }) .to_string(); @@ -3998,6 +4021,14 @@ mod tests { )); assert!(!SIDEBAR_TREE_JS .contains(r#"[data-node-id="' + cssEscape(documentId) + '"] .tree-link-title"#)); + assert!(SIDEBAR_TREE_JS.contains("renderSidebarSnapshot")); + assert!(SIDEBAR_TREE_JS.contains("kernel_file_tree_projection")); + assert!(SIDEBAR_TREE_JS.contains("deltaNeedsProjectionRefresh")); + assert!(SIDEBAR_TREE_JS.contains("upsert_documents")); + assert!(SIDEBAR_TREE_JS.contains("setTreeLiveApplyError")); + assert!(!SIDEBAR_TREE_JS.contains("scheduleProjectionRefresh")); + assert!(!SIDEBAR_TREE_JS.contains("/api/tree/projections/sidebar")); + assert!(SIDEBAR_TREE_JS.contains("/api/tree/projections/file")); } #[test] @@ -4009,5 +4040,7 @@ mod tests { assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot")); assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta")); assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync")); + assert!(!TREE_LIVE_CONTROLLER_JS.contains("resyncEndpoint")); + assert!(!TREE_LIVE_CONTROLLER_JS.contains("tree:resync-requested")); } } diff --git a/rust/crates/mnote-web/src/transport/convex.rs b/rust/crates/mnote-web/src/transport/convex.rs index f79e7c71..30c1ac62 100644 --- a/rust/crates/mnote-web/src/transport/convex.rs +++ b/rust/crates/mnote-web/src/transport/convex.rs @@ -372,6 +372,18 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value { map.remove("domainEventPlans"); } } + if plan.command_name == "mindmap.command.apply" { + if let Value::Object(map) = &mut args { + // Convex mindmaps:applyCommand 仍只接收 blob substrate 写入所需字段; + // rootNodeId/projectionRevision/canonicalCommand 属于 Rust command envelope 语义。 + map.remove("rootNodeId"); + map.remove("projectionRevision"); + map.remove("canonicalCommand"); + if let Some(document_id) = map.remove("documentId") { + map.insert("docId".into(), document_id); + } + } + } args } @@ -835,6 +847,45 @@ mod tests { ); } + #[test] + fn convex_command_args_adapts_mindmap_command_apply_for_legacy_mutation() { + let plan = RuntimeCommandExecutionPlan { + command_name: "mindmap.command.apply".into(), + command_id: "cmd_mindmap_1".into(), + function_name: "mindmaps:applyCommand".into(), + workspace_id: Some("ws_1".into()), + request_id: "req_1".into(), + trace_id: "trace_1".into(), + actor_id: "actor_1".into(), + idempotency_key: None, + source: json!({}), + payload_json: "{}".into(), + args_json: json!({ + "documentId": "doc_1", + "mindmapId": "mind_1", + "rootNodeId": "root", + "commands": [ + {"type": "renameNode", "mapId": "mind_1", "nodeId": "root", "title": "KMIND 已编辑"} + ], + "projectionRevision": 3, + "canonicalCommand": "mindmap.command.apply", + }), + }; + + let args = convex_command_args_for_plan(&plan); + + assert_eq!( + args, + json!({ + "docId": "doc_1", + "mindmapId": "mind_1", + "commands": [ + {"type": "renameNode", "mapId": "mind_1", "nodeId": "root", "title": "KMIND 已编辑"} + ], + }) + ); + } + #[test] fn build_authorization_prefers_forwarded_authorization() { let mut headers = HeaderMap::new(); 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 bae084d8..24f65358 100644 --- a/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs +++ b/rust/crates/mnote-web/src/tree_shell/filetree_renderer.rs @@ -99,9 +99,13 @@ fn render_filetree_row( create_action_html = create_action_html, title = escape_html(&row.title), )); - if row.expandable && row.expanded { + if row.expandable { if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) { - html.push_str(r#"