feat: 收口 Rust Web 3000 主链

This commit is contained in:
lix-2026
2026-05-11 13:16:34 +08:00
parent 7f3f7d4e2f
commit 17c003976b
61 changed files with 2090 additions and 2256 deletions
+1
View File
@@ -1544,6 +1544,7 @@ dependencies = [
"storage-convex-bridge", "storage-convex-bridge",
"time", "time",
"tokio", "tokio",
"tokio-tungstenite",
"tower", "tower",
"tower-http", "tower-http",
"tracing", "tracing",
+31 -21
View File
@@ -5459,23 +5459,22 @@ fn mindmap_tree_candidate(data: &Value) -> Value {
let Some(map) = candidate.as_object() else { let Some(map) = candidate.as_object() else {
return candidate; return candidate;
}; };
let next = if (map.contains_key("ok") || map.contains_key("meta")) let next =
&& map.get("data").is_some() if (map.contains_key("ok") || map.contains_key("meta")) && map.get("data").is_some() {
{ map.get("data").cloned()
map.get("data").cloned() } else if map
} else if map .get("data")
.get("data") .map(|value| value.get("data").is_some() || value.get("children").is_some())
.map(|value| value.get("data").is_some() || value.get("children").is_some()) .unwrap_or(false)
.unwrap_or(false) {
{ map.get("data").cloned()
map.get("data").cloned() } else if let Some(nested) = map.get("mindmap") {
} else if let Some(nested) = map.get("mindmap") { Some(nested.clone())
Some(nested.clone()) } else if let Some(nested) = map.get("result") {
} else if let Some(nested) = map.get("result") { Some(nested.clone())
Some(nested.clone()) } else {
} else { None
None };
};
let Some(next) = next else { let Some(next) = next else {
return candidate; 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 { 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() { if segments.is_empty() {
return false; 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 { let Some(map) = current.as_object_mut() else {
return false; 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() { if !current.is_object() {
*current = json!({}); *current = json!({});
@@ -6599,7 +6603,10 @@ fn apply_mindmap_compat_payload_patch(
metadata: &mut BTreeMap<String, Value>, metadata: &mut BTreeMap<String, Value>,
command: &Value, command: &Value,
) -> bool { ) -> 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() { if path.trim().is_empty() {
return false; return false;
} }
@@ -11490,7 +11497,10 @@ mod tests {
assert_eq!(result.applied, 2); assert_eq!(result.applied, 2);
assert!(result.errors.is_empty()); assert!(result.errors.is_empty());
assert_eq!(result.data["data"]["data"]["text"], json!("KMIND")); 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"]["scale"], json!(1.2));
assert_eq!(result.data["view"]["state"]["x"], json!(10)); assert_eq!(result.data["view"]["state"]["x"], json!(10));
assert_eq!(result.data["view"]["transform"]["scaleX"], json!(1.2)); assert_eq!(result.data["view"]["transform"]["scaleX"], json!(1.2));
+1
View File
@@ -18,6 +18,7 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
storage-convex-bridge = { path = "../storage-convex-bridge" } storage-convex-bridge = { path = "../storage-convex-bridge" }
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] } tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "signal", "sync", "time"] }
tokio-tungstenite = "0.29"
tower-http = { version = "0.6", features = ["trace"] } tower-http = { version = "0.6", features = ["trace"] }
tracing = "0.1" tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["fmt"] } tracing-subscriber = { version = "0.3", features = ["fmt"] }
+1 -1
View File
@@ -1,6 +1,6 @@
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
use crate::middleware::request_context::inject_request_context; use crate::middleware::request_context::inject_request_context;
use crate::routes::build_router; use crate::routes::build_router;
use crate::local_folder_watcher_registry::LocalFolderWatcherRegistry;
use axum::Router; use axum::Router;
use std::env; use std::env;
use std::fs; use std::fs;
@@ -49,11 +49,7 @@ impl LocalFolderWatcherRegistry {
#[cfg(test)] #[cfg(test)]
pub fn active_watcher_count(&self) -> usize { pub fn active_watcher_count(&self) -> usize {
self.inner self.inner.entries.lock().expect("registry lock").len()
.entries
.lock()
.expect("registry lock")
.len()
} }
} }
@@ -67,7 +63,13 @@ impl LocalFolderWatcherRegistryInner {
key: &str, key: &str,
canonical_root: &Path, canonical_root: &Path,
) -> Result<Arc<LocalFolderWatchChannel>, String> { ) -> Result<Arc<LocalFolderWatchChannel>, 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); return Ok(existing);
} }
@@ -107,10 +109,7 @@ struct LocalFolderWatchChannel {
} }
impl LocalFolderWatchChannel { impl LocalFolderWatchChannel {
fn new( fn new(_root_uri: String, parts: (broadcast::Sender<Value>, oneshot::Sender<()>)) -> Self {
_root_uri: String,
parts: (broadcast::Sender<Value>, oneshot::Sender<()>),
) -> Self {
Self { Self {
sender: parts.0, sender: parts.0,
subscriber_count: AtomicUsize::new(0), subscriber_count: AtomicUsize::new(0),
@@ -321,7 +320,9 @@ mod tests {
let first_root = test_root("first"); let first_root = test_root("first");
let second_root = test_root("second"); 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 let second = registry
.subscribe(&second_root) .subscribe(&second_root)
.expect("second root subscription"); .expect("second root subscription");
@@ -340,9 +341,11 @@ mod tests {
#[test] #[test]
fn event_kind_filter_ignores_access_events() { fn event_kind_filter_ignores_access_events() {
assert!(should_emit_event_kind(&EventKind::Create(CreateKind::File))); assert!(should_emit_event_kind(&EventKind::Create(CreateKind::File)));
assert!(should_emit_event_kind(&EventKind::Modify(ModifyKind::Data( assert!(should_emit_event_kind(&EventKind::Modify(
DataChange::Content, ModifyKind::Data(DataChange::Content,)
)))); )));
assert!(!should_emit_event_kind(&EventKind::Access(AccessKind::Read))); assert!(!should_emit_event_kind(&EventKind::Access(
AccessKind::Read
)));
} }
} }
+14 -743
View File
@@ -1,36 +1,22 @@
use crate::app::AppState; use crate::app::AppState;
use crate::context::RequestContext; use crate::context::RequestContext;
use crate::error::WebError; use crate::error::WebError;
use crate::routes::query_support::resolve_effective_workspace_id; use axum::body::Body;
use crate::routes::snapshot_support::{load_projection_snapshot, ProjectionSnapshotSpec};
use axum::body::{Body, Bytes};
use axum::extract::Query;
use axum::extract::{Extension, State}; use axum::extract::{Extension, State};
use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Request, StatusCode}; use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Request, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use axum::Json; use axum::Json;
use core_protocol::KernelProjectionKind;
use futures_util::{StreamExt, TryStreamExt};
use serde::Deserialize;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::env; use std::env;
use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use std::process::Command; use std::process::Command;
use std::time::Duration;
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CompatSidebarQuery {
pub workspace_id: Option<String>,
}
pub async fn next_ai_agent_run( pub async fn next_ai_agent_run(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
request: Request<Body>, request: Request<Body>,
) -> Result<Response, WebError> { ) -> Result<Response, WebError> {
let (parts, body) = request.into_parts(); let (_parts, body) = request.into_parts();
let body = axum::body::to_bytes(body, 10 * 1024 * 1024) let body = axum::body::to_bytes(body, 10 * 1024 * 1024)
.await .await
.map_err(|error| WebError::internal(format!("读取 AI 请求体失败: {error}")))?; .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") .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 let Some(provider) = explicit_agent_provider(&payload) {
if provider == "hermes" {
return run_direct_hermes_agent(&context, &payload).await;
}
return Err(WebError::bad_gateway_code( return Err(WebError::bad_gateway_code(
"ai_provider_bridge_unavailable", "ai_provider_bridge_unavailable",
format!( format!(
"{provider} provider 需要可用的 Next AI bridge,不静默降级到本地页面工具 host" "{provider} provider 直连链路已退场;当前仅保留 mnote-cli host 主路径,不静默降级。"
), ),
) )
.with_context(&context) .with_context(&context)
@@ -68,69 +41,7 @@ pub async fn next_ai_agent_run(
.with_header("x-mnote-ai-execution-owner", "provider-bridge-unavailable")); .with_header("x-mnote-ai-execution-owner", "provider-bridge-unavailable"));
} }
if let Ok(response) = run_local_mnote_cli_ai_host(&state, &context, &payload).await { 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)
} }
fn explicit_agent_provider(payload: &Value) -> Option<&'static str> { 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<Response, WebError> {
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<Value> = 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<Value> {
text.split("\n\n")
.filter_map(|frame| {
let data = frame
.lines()
.filter_map(|line| line.strip_prefix("data:"))
.map(str::trim_start)
.collect::<Vec<_>>()
.join("\n");
if data.trim().is_empty() {
return None;
}
serde_json::from_str::<Value>(&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<Value>,
) -> Result<Response, WebError> {
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<Response, WebError> {
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 { fn resolve_repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..") PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../..")
} }
@@ -705,241 +253,18 @@ async fn run_local_mnote_cli_ai_host(
Ok(response) 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<Response, WebError> {
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::<Bytes, std::io::Error>(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) { fn stamp_owner_header(headers: &mut HeaderMap) {
if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") { if let Ok(name) = HeaderName::from_lowercase(b"x-mnote-web-owner") {
headers.insert(name, HeaderValue::from_static("mnote-web")); headers.insert(name, HeaderValue::from_static("mnote-web"));
} }
} }
fn resolve_ai_orchestrator_backend_url() -> Option<String> {
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<String> {
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<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<CompatSidebarQuery>,
) -> Result<(StatusCode, Json<Value>), 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)] #[cfg(test)]
mod tests { mod tests {
use crate::app::{build_app, AppConfig, AppState}; 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::body::Body;
use axum::http::{HeaderMap, Method, Request, StatusCode, Uri}; use axum::http::{Request, StatusCode};
use axum::response::IntoResponse; use axum::response::IntoResponse;
use serde_json::json;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower::util::ServiceExt; use tower::util::ServiceExt;
@@ -966,7 +291,7 @@ mod tests {
} }
#[tokio::test] #[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() let response = app()
.oneshot( .oneshot(
Request::builder() Request::builder()
@@ -977,7 +302,7 @@ mod tests {
.await .await
.expect("response"); .expect("response");
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::NOT_FOUND);
} }
#[tokio::test] #[tokio::test]
@@ -1027,60 +352,6 @@ mod tests {
assert!(!text.contains("legacy_next_compat_disabled")); 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::<Uri>().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] #[tokio::test]
async fn explicit_agent_provider_does_not_fallback_to_mnote_cli_when_next_unavailable() { async fn explicit_agent_provider_does_not_fallback_to_mnote_cli_when_next_unavailable() {
let response = build_app(AppState::new(AppConfig { let response = build_app(AppState::new(AppConfig {
@@ -1126,11 +397,11 @@ mod tests {
.expect("body"); .expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8"); let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("codex")); assert!(text.contains("codex"));
assert!(!text.contains("mnote-cli")); assert!(text.contains("provider 直连链路已退场"));
} }
#[tokio::test] #[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( let next_app = axum::Router::new().route(
"/api/ai-agent/run", "/api/ai-agent/run",
axum::routing::post(|| async move { axum::routing::post(|| async move {
@@ -1180,20 +451,20 @@ mod tests {
.await .await
.expect("response"); .expect("response");
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!( assert_eq!(
response response
.headers() .headers()
.get("x-mnote-web-owner") .get("x-mnote-ai-execution-owner")
.and_then(|value| value.to_str().ok()), .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) let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await .await
.expect("body"); .expect("body");
let text = String::from_utf8(body.to_vec()).expect("utf8"); let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("assistant_message")); assert!(text.contains("hermes"));
assert!(text.contains("hello from next ai")); assert!(!text.contains("hello from next ai"));
server.abort(); server.abort();
} }
+165 -1
View File
@@ -13,12 +13,15 @@ use crate::workspace_shell::{
build_workspace_shell_projection, render_workspace_shell_sidebar_html, build_workspace_shell_projection, render_workspace_shell_sidebar_html,
}; };
use axum::body::Body; use axum::body::Body;
use axum::extract::ws::{Message as AxumWsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Extension, Query, State}; 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 axum::response::{Html, IntoResponse, Response};
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use std::time::Duration; use std::time::Duration;
use tokio_tungstenite::tungstenite::Message as TungsteniteMessage;
const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner"; const HEADER_MNOTE_WEB_OWNER: &str = "x-mnote-web-owner";
const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream"; const HEADER_MNOTE_LEGACY_UPSTREAM: &str = "x-mnote-legacy-upstream";
@@ -482,6 +485,132 @@ pub async fn legacy_next_proxy(
Ok(response) Ok(response)
} }
#[allow(dead_code)]
pub async fn legacy_next_websocket_proxy(
ws: WebSocketUpgrade,
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
uri: Uri,
) -> Result<Response, WebError> {
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<String, WebError> {
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<dyn std::error::Error + Send + Sync>> {
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( async fn resolve_root_workspace_id(
state: &AppState, state: &AppState,
context: &RequestContext, context: &RequestContext,
@@ -947,6 +1076,18 @@ mod tests {
format!("http://{addr}") 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] #[tokio::test]
async fn gateway_health_declares_mnote_web_owner() { async fn gateway_health_declares_mnote_web_owner() {
let response = app() let response = app()
@@ -1377,4 +1518,27 @@ mod tests {
.iter() .iter()
.any(|value| value.contains("__convexAuthRefreshToken=refresh-demo"))); .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
);
}
} }
@@ -57,12 +57,12 @@ pub async fn local_folder_events(
let stream = stream::unfold( let stream = stream::unfold(
(Some(initial), subscription, document_relative_path), (Some(initial), subscription, document_relative_path),
|(initial, mut subscription, document_relative_path)| async move { |(initial, mut subscription, document_relative_path)| async move {
if let Some(payload) = initial { if let Some(payload) = initial {
return Some(( return Some((
Ok(stream_event("ready", &payload)), Ok(stream_event("ready", &payload)),
(None, subscription, document_relative_path), (None, subscription, document_relative_path),
)); ));
} }
loop { loop {
match subscription.receiver.recv().await { match subscription.receiver.recv().await {
Ok(payload) => { Ok(payload) => {
@@ -152,8 +152,7 @@ mod tests {
#[test] #[test]
fn local_markdown_document_id_maps_to_relative_path() { fn local_markdown_document_id_maps_to_relative_path() {
assert_eq!( assert_eq!(
local_markdown_relative_path_from_document_id("local-md:docs~2FREADME.md") local_markdown_relative_path_from_document_id("local-md:docs~2FREADME.md").as_deref(),
.as_deref(),
Some("docs/README.md") Some("docs/README.md")
); );
} }
@@ -3156,8 +3156,11 @@ fn main() {}
.to_string(); .to_string();
std::thread::sleep(std::time::Duration::from_millis(5)); std::thread::sleep(std::time::Duration::from_millis(5));
std::fs::write(root.join("README.md"), "---\ntitle: Stale\n---\n# External\n") std::fs::write(
.expect("external write"); root.join("README.md"),
"---\ntitle: Stale\n---\n# External\n",
)
.expect("external write");
let error = save_local_markdown_page( let error = save_local_markdown_page(
&root_uri, &root_uri,
@@ -3168,9 +3171,7 @@ fn main() {}
]), ]),
) )
.expect_err("stale save should fail"); .expect_err("stale save should fail");
assert!(error assert!(error.message().contains("本地 Markdown 文件已被外部修改"));
.message()
.contains("本地 Markdown 文件已被外部修改"));
let saved = std::fs::read_to_string(root.join("README.md")).expect("read md"); let saved = std::fs::read_to_string(root.join("README.md")).expect("read md");
assert!(saved.contains("# External")); assert!(saved.contains("# External"));
+122 -13
View File
@@ -7,8 +7,8 @@ mod gateway;
mod health; mod health;
mod hermes; mod hermes;
mod kernel; mod kernel;
mod local_folder_source;
mod local_folder_events; mod local_folder_events;
mod local_folder_source;
mod local_markdown_parser; mod local_markdown_parser;
mod media; mod media;
mod mindmap_api; mod mindmap_api;
@@ -30,7 +30,6 @@ use axum::Router;
pub fn build_router(state: AppState) -> Router { pub fn build_router(state: AppState) -> Router {
let hermes_base_path = state.config().hermes_base_path.clone(); 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 enable_debug_shell_routes = state.config().enable_debug_shell_routes;
let mut router = Router::new() 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/meta", get(documents::meta))
.route("/api/documents/content", get(documents::content)) .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/purge", post(documents::purge))
.route("/api/documents/title", post(documents::title)) .route("/api/documents/title", post(documents::title))
.route("/api/documents/options", post(documents::options)) .route("/api/documents/options", post(documents::options))
@@ -137,21 +135,132 @@ pub fn build_router(state: AppState) -> Router {
Router::new() Router::new()
.route("/health", get(hermes::health)) .route("/health", get(hermes::health))
.route("/bridge", post(hermes::bridge_runtime)), .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 { if enable_debug_shell_routes {
router = router router = router
.route("/tree", get(tree::tree_shell)) .route("/tree", get(tree::tree_shell))
.route("/document-debug", get(editor::document_editor_shell)) .route("/document-debug", get(editor::document_editor_shell));
.route("/document", get(editor::document_editor_shell));
} }
router.with_state(state) 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 单入口边界",
);
}
}
}
+9 -2
View File
@@ -73,7 +73,8 @@ pub async fn events(
match change.kind { match change.kind {
StreamChangeKind::Delta => { StreamChangeKind::Delta => {
let payload = build_stream_delta_payload( let Ok(payload) = build_stream_delta_payload(
state.app_state.config(),
&state.context, &state.context,
&state.query, &state.query,
&workspace_id, &workspace_id,
@@ -82,7 +83,11 @@ pub async fn events(
change change
.delta .delta
.unwrap_or_else(|| serde_json::json!({ "op": "noop" })), .unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
); )
.await
else {
return None;
};
return Some((Ok(stream_event("delta", &payload)), Some(state))); return Some((Ok(stream_event("delta", &payload)), Some(state)));
} }
StreamChangeKind::Resync => { StreamChangeKind::Resync => {
@@ -260,6 +265,8 @@ mod tests {
let text = String::from_utf8(body.to_vec()).expect("utf8"); let text = String::from_utf8(body.to_vec()).expect("utf8");
assert!(text.contains("event: snapshot") || text.contains("event:snapshot")); assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
assert!(text.contains("\"kind\":\"snapshot\"")); assert!(text.contains("\"kind\":\"snapshot\""));
assert!(text.contains("\"kernel_sidebar_projection\""));
assert!(text.contains("\"kernel_file_tree_projection\""));
} }
#[tokio::test] #[tokio::test]
@@ -455,6 +455,114 @@ pub fn read_stream_cursor_from_payload(payload: &Value) -> Option<String> {
read_string_field(payload, &["cursor"]) 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<u32>,
) -> Result<Value, WebError> {
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 { pub fn with_stream_kind(payload: &Value, kind: &str) -> Value {
if let Some(mut map) = payload.as_object().cloned() { if let Some(mut map) = payload.as_object().cloned() {
map.insert("kind".into(), Value::String(kind.into())); 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, context: &RequestContext,
query: &StreamSnapshotQuery, query: &StreamSnapshotQuery,
workspace_id: &str, workspace_id: &str,
overview: &Value, overview: &Value,
cursor: Option<String>, cursor: Option<String>,
delta: Value, delta: Value,
) -> Value { ) -> Result<Value, WebError> {
let scope = resolve_stream_scope(query); 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", "kind": "delta",
"revision": cursor.clone().unwrap_or_else(|| "0".into()), "revision": cursor.clone().unwrap_or_else(|| "0".into()),
"stream": scope.as_str(), "stream": scope.as_str(),
@@ -487,9 +602,9 @@ pub fn build_stream_delta_payload(
"depth": query.depth, "depth": query.depth,
"cursor": cursor, "cursor": cursor,
"data": delta, "data": delta,
"snapshot": Value::Null, "snapshot": snapshot,
"overview": overview, "overview": overview,
}) }))
} }
pub async fn load_stream_overview( pub async fn load_stream_overview(
@@ -522,24 +637,8 @@ pub async fn load_stream_snapshot(
let snapshot = match scope { let snapshot = match scope {
StreamSnapshotScope::Workspace => { StreamSnapshotScope::Workspace => {
let loaded = load_projection_snapshot( load_workspace_stream_snapshot(config, context, &effective_workspace_id, query.depth)
config, .await?
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,
})
} }
StreamSnapshotScope::Subtree => { StreamSnapshotScope::Subtree => {
let root_node_id = let root_node_id =
@@ -590,8 +689,8 @@ pub async fn load_stream_snapshot(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
resolve_stream_change, resolve_stream_cursor, resolve_stream_scope, StreamChangeKind, delta_requires_projection_snapshot, resolve_stream_change, resolve_stream_cursor,
StreamSnapshotQuery, StreamSnapshotScope, resolve_stream_scope, StreamChangeKind, StreamSnapshotQuery, StreamSnapshotScope,
}; };
use serde_json::json; use serde_json::json;
@@ -1192,4 +1291,39 @@ mod tests {
assert_eq!(change.kind, StreamChangeKind::Resync); assert_eq!(change.kind, StreamChangeKind::Resync);
assert_eq!(change.delta, None); 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" }
]
})));
}
} }
+11 -10
View File
@@ -404,6 +404,15 @@ pub(crate) fn collect_page_tree_render_rows(projection: &Value) -> Vec<PageTreeR
.get("expandedByDefault") .get("expandedByDefault")
.and_then(Value::as_bool) .and_then(Value::as_bool)
.unwrap_or(false), .unwrap_or(false),
openable: item
.get("resourceMeta")
.and_then(Value::as_object)
.and_then(|meta| meta.get("documentId"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some()
|| !node_id.starts_with("local-dir:"),
}) })
}) })
.collect() .collect()
@@ -7475,7 +7484,7 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn tree_route_contracts_compat_sidebar_keeps_boundary_and_trace_fields() { async fn tree_route_contracts_compat_sidebar_route_is_not_registered_by_default() {
let response = app() let response = app()
.oneshot( .oneshot(
Request::builder() Request::builder()
@@ -7487,15 +7496,7 @@ mod tests {
.await .await
.expect("response"); .expect("response");
assert_eq!(response.status(), StatusCode::OK); assert_eq!(response.status(), StatusCode::NOT_FOUND);
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
let payload: Value = serde_json::from_slice(&body).expect("json");
assert_eq!(payload["boundary"], "next_sidebar_compat");
assert_eq!(payload["workspaceId"], "ws_demo");
assert!(payload["requestId"].as_str().is_some());
assert!(payload["traceId"].as_str().is_some());
} }
#[test] #[test]
+48 -78
View File
@@ -50,13 +50,6 @@ pub struct DocumentShellQuery {
pub secondary_root_uri: Option<String>, pub secondary_root_uri: Option<String>,
} }
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DocumentPageCompatQuery {
pub document_id: String,
pub workspace_id: Option<String>,
}
pub async fn document_page_shell( pub async fn document_page_shell(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -434,30 +427,38 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
inputs.forEach((input) => { inputs.forEach((input) => {
input.setAttribute('data-title-controller', CONTRACT); input.setAttribute('data-title-controller', CONTRACT);
const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title'; const endpoint = input.getAttribute('data-title-endpoint') || '/api/documents/title';
const paneRole = (input.getAttribute('data-pane-role') || 'primary').trim(); const resolveTitleTarget = (targetInput) => {
const rawDocumentId = (input.getAttribute('data-document-id') || '').trim(); const paneRole = (targetInput.getAttribute('data-pane-role') || 'primary').trim();
const documentId = rawDocumentId || ( const rawDocumentId = (targetInput.getAttribute('data-document-id') || '').trim();
paneRole === 'primary' const documentId = rawDocumentId || (
? (document.body?.dataset.documentId || '').trim() paneRole === 'primary'
: '' ? (document.body?.dataset.documentId || '').trim()
); : ''
const query = new URLSearchParams(window.location.search); );
const paneQueryParams = paneRole === 'secondary' const query = new URLSearchParams(window.location.search);
? { sourceKindParam: 'secondarySourceKind', rootUriParam: 'secondaryRootUri' } const paneQueryParams = paneRole === 'secondary'
: { sourceKindParam: 'sourceKind', rootUriParam: 'rootUri' }; ? { sourceKindParam: 'secondarySourceKind', rootUriParam: 'secondaryRootUri' }
const sourceKindParam = paneQueryParams.sourceKindParam; : { sourceKindParam: 'sourceKind', rootUriParam: 'rootUri' };
const rootUriParam = paneQueryParams.rootUriParam; return {
const workspaceId = (input.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim(); documentId,
const sourceKind = (query.get(sourceKindParam) || '').trim(); workspaceId: (targetInput.getAttribute('data-workspace-id') || query.get('workspaceId') || '').trim(),
const rootUri = (query.get(rootUriParam) || '').trim(); sourceKind: (query.get(paneQueryParams.sourceKindParam) || '').trim(),
let lastSavedTitle = input.value.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; let saving = false;
const saveTitle = async () => { const saveTitle = async () => {
const title = input.value.trim() || ''; const title = input.value.trim() || '';
const currentTarget = resolveTitleTarget(input);
autosize(input); autosize(input);
if (!documentId || saving || title === lastSavedTitle) { if (!currentTarget.documentId || saving || title === readLastSavedTitle()) {
updateVisibleTitle(input, title, documentId); updateVisibleTitle(input, title, currentTarget.documentId);
setStatus(input, 'saved'); setStatus(input, 'saved');
return; return;
} }
@@ -468,10 +469,10 @@ pub(crate) fn render_document_title_controller_script() -> &'static str {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
documentId, documentId: currentTarget.documentId,
workspaceId: workspaceId || null, workspaceId: currentTarget.workspaceId || null,
sourceKind: sourceKind || undefined, sourceKind: currentTarget.sourceKind || undefined,
rootUri: rootUri || undefined, rootUri: currentTarget.rootUri || undefined,
title, title,
commandName: 'page.head.updateTitle', 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) { if (!response.ok || !payload || payload.ok !== true) {
throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`); throw new Error(payload?.error?.message || payload?.message || `title_failed_${response.status}`);
} }
lastSavedTitle = title; writeLastSavedTitle(title);
updateVisibleTitle(input, title, documentId); updateVisibleTitle(input, title, currentTarget.documentId);
setStatus(input, 'saved'); setStatus(input, 'saved');
window.dispatchEvent(new CustomEvent('tree:title-updated', { 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) { } catch (error) {
setStatus(input, 'error', error instanceof Error ? error.message : String(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', () => { input.addEventListener('input', () => {
autosize(input); autosize(input);
setStatus(input, input.value.trim() === lastSavedTitle ? 'saved' : 'dirty'); setStatus(input, (input.value.trim() || '') === readLastSavedTitle() ? 'saved' : 'dirty');
}); });
input.addEventListener('keydown', (event) => { input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) { 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(); }); input.addEventListener('blur', () => { void saveTitle(); });
autosize(input); autosize(input);
updateVisibleTitle(input, lastSavedTitle, documentId); updateVisibleTitle(input, readLastSavedTitle(), resolveTitleTarget(input).documentId);
setStatus(input, 'saved'); setStatus(input, 'saved');
}); });
})(); })();
@@ -681,6 +682,12 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
throw new Error('island runtime '); throw new Error('island runtime ');
} }
await runtime.default(wasmUrl); 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 runtime;
})(); })();
return window.__mnoteLeptosTiptapRuntimePromise; return window.__mnoteLeptosTiptapRuntimePromise;
@@ -1560,6 +1567,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
node.value = title; node.value = title;
node.setAttribute('data-document-id', documentId); node.setAttribute('data-document-id', documentId);
node.setAttribute('data-workspace-id', workspaceId); node.setAttribute('data-workspace-id', workspaceId);
node.setAttribute('data-title-last-saved', title);
node.setAttribute('data-title-save-status', 'saved'); node.setAttribute('data-title-save-status', 'saved');
node.style.height = 'auto'; node.style.height = 'auto';
node.style.height = `${Math.max(48, node.scrollHeight)}px`; node.style.height = `${Math.max(48, node.scrollHeight)}px`;
@@ -2015,49 +2023,6 @@ pub async fn leptos_tiptap_asset(Path(asset_path): Path<String>) -> Result<Respo
Ok(response) Ok(response)
} }
pub async fn documents_page_compat(
State(state): State<AppState>,
Extension(context): Extension<RequestContext>,
Query(query): Query<DocumentPageCompatQuery>,
) -> Result<Response, WebError> {
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( pub async fn page_aggregate(
State(state): State<AppState>, State(state): State<AppState>,
Extension(context): Extension<RequestContext>, Extension(context): Extension<RequestContext>,
@@ -2488,6 +2453,8 @@ mod tests {
assert!(html.contains("data-page-title-input=\"true\"")); assert!(html.contains("data-page-title-input=\"true\""));
assert!(html.contains("data-title-endpoint=\"/api/documents/title\"")); assert!(html.contains("data-title-endpoint=\"/api/documents/title\""));
assert!(html.contains("mnote.document_title_controller.v1")); assert!(html.contains("mnote.document_title_controller.v1"));
assert!(html.contains("const currentTarget = resolveTitleTarget(input);"));
assert!(html.contains("documentId: currentTarget.documentId"));
assert!(html.contains( assert!(html.contains(
r#".tree-row[data-shell-mode="page"][data-node-id="' + escaped + '"] > .tree-link > .tree-link-title"# 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("Child Page"));
assert!(html.contains("asset.png")); assert!(html.contains("asset.png"));
assert!(html.contains("data-row-kind=\"markdown\"")); 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("data-mnote-action=\"open-local-folder\""));
assert!(html.contains("refreshSessionFromExternalFileChange")); assert!(html.contains("refreshSessionFromExternalFileChange"));
assert!(html.contains("/api/local-folder/events")); assert!(html.contains("/api/local-folder/events"));
+99 -66
View File
@@ -23,7 +23,6 @@ const SIDEBAR_TREE_JS: &str = r##"
anchorRowId: null, anchorRowId: null,
focusedRowId: null focusedRowId: null
}; };
var projectionRefreshTimer = 0;
var activeTreeContextMenu = null; var activeTreeContextMenu = null;
var activeEditorAttachmentLink = null; var activeEditorAttachmentLink = null;
var attachmentActionsHideTimer = 0; var attachmentActionsHideTimer = 0;
@@ -854,6 +853,27 @@ const SIDEBAR_TREE_JS: &str = r##"
return value; 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) { function projectionItems(projection) {
var resolved = readProjection(projection); var resolved = readProjection(projection);
return resolved && Array.isArray(resolved.items) ? resolved.items : []; return resolved && Array.isArray(resolved.items) ? resolved.items : [];
@@ -901,14 +921,16 @@ const SIDEBAR_TREE_JS: &str = r##"
var children = grouped.get(nodeId) || []; var children = grouped.get(nodeId) || [];
var expandable = Boolean(item.expandable || item.childCount > 0 || children.length); var expandable = Boolean(item.expandable || item.childCount > 0 || children.length);
var expanded = expandable && item.expandedByDefault !== false; 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 toggle = expandable
? '<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="' + escapeHtml(nodeId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '" aria-expanded="' + String(expanded) + '">' + pageTreeChevronSvg() + '</button>' ? '<button type="button" class="tree-toggle" data-testid="tree-node-toggle" data-rust-action="toggle" data-node-id="' + escapeHtml(nodeId) + '" aria-label="' + (expanded ? '折叠 ' : '展开 ') + escapeHtml(title) + '" aria-expanded="' + String(expanded) + '">' + pageTreeChevronSvg() + '</button>'
: '<span class="tree-spacer" aria-hidden="true"></span>'; : '<span class="tree-spacer" aria-hidden="true"></span>';
var childHtml = expandable && expanded var childHtml = expandable
? '<ul class="tree-children">' + renderPageRows(nodeId, grouped, activeId, depth + 1) + '</ul>' ? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderPageRows(nodeId, grouped, activeId, depth + 1) + '</ul>'
: ''; : '';
var currentAttr = nodeId === activeId ? ' aria-current="page" aria-selected="true"' : ' aria-selected="false"'; var currentAttr = nodeId === activeId ? ' aria-current="page" aria-selected="true"' : ' aria-selected="false"';
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '"' + currentAttr + ' data-focused="false" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作"></button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button></div></div>' + childHtml + '</li>'; return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="tree-node-open" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-depth="' + depth + '" data-shell-mode="page" data-active="' + String(nodeId === activeId) + '"' + currentAttr + ' data-focused="false" data-page-openable="' + String(openable) + '" data-draggable="true" draggable="true" tabindex="-1">' + toggle + '<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="' + escapeHtml(nodeId) + '" data-page-openable="' + String(openable) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="' + escapeHtml(nodeId) + '" aria-label="更多操作"></button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="' + escapeHtml(nodeId) + '" aria-label="新建子页面">+</button></div></div>' + childHtml + '</li>';
}).join(''); }).join('');
} }
@@ -965,8 +987,8 @@ const SIDEBAR_TREE_JS: &str = r##"
var createAction = rowKind === 'document' var createAction = rowKind === 'document'
? '<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="' + escapeHtml(rowId) + '" data-node-id="' + escapeHtml(documentId || nodeId) + '" aria-label="新建子页面">+</button>' ? '<button type="button" class="tree-action" data-testid="filetree-create" data-rust-action="create" data-row-id="' + escapeHtml(rowId) + '" data-node-id="' + escapeHtml(documentId || nodeId) + '" aria-label="新建子页面">+</button>'
: ''; : '';
var childHtml = expandable && expanded var childHtml = expandable
? '<ul class="tree-children">' + renderFileRows(nodeId, grouped, activeId) + '</ul>' ? '<ul class="tree-children' + (expanded ? '' : ' tree-children--collapsed') + '">' + renderFileRows(nodeId, grouped, activeId) + '</ul>'
: ''; : '';
return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKindOf(item)) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作"></button></div></div>' + childHtml + '</li>'; return '<li class="tree-node" data-node-id="' + escapeHtml(nodeId) + '"><div class="tree-row" role="treeitem" aria-level="' + (depth + 1) + '" aria-expanded="' + String(expandable && expanded) + '" data-rust-rendered-row="filetree" data-testid="' + testId + '" data-row-id="' + escapeHtml(rowId) + '" data-row-kind="' + escapeHtml(rowKind) + '" data-node-id="' + escapeHtml(nodeId) + '"' + (parent ? ' data-parent-id="' + escapeHtml(parent) + '"' : '') + ' data-document-id="' + escapeHtml(documentId) + '" data-doc-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '" data-shell-mode="filetree" data-selected="' + String(selected) + '" data-active="false" draggable="true">' + toggle + '<span class="tree-kind-badge" data-kind="' + escapeHtml(iconKindOf(item)) + '" aria-hidden="true"></span><button type="button" class="tree-link" data-rust-action="open" data-row-id="' + escapeHtml(rowId) + '" data-document-id="' + escapeHtml(documentId) + '" data-asset-id="' + escapeHtml(assetId) + '"><span class="tree-link-title">' + escapeHtml(title) + '</span></button><div class="tree-actions">' + createAction + '<button type="button" class="tree-action" data-testid="filetree-action-menu" data-rust-action="menu" data-row-id="' + escapeHtml(rowId) + '" aria-label="更多操作"></button></div></div>' + childHtml + '</li>';
}).join(''); }).join('');
@@ -981,30 +1003,41 @@ const SIDEBAR_TREE_JS: &str = r##"
return true; return true;
} }
async function fetchProjection(path, workspaceId) { function renderSidebarSnapshot(payload) {
var url = new URL(path, window.location.origin); var renderedPage = renderPageProjection(payload);
url.searchParams.set('workspaceId', workspaceId || 'default'); var fileProjection = readDatasetProjection(payload, 'kernel_file_tree_projection', 'kernelFileTreeProjection');
url.searchParams.set('depth', '99'); var renderedFile = fileProjection ? renderFileProjection(fileProjection) : false;
var response = await fetch(url.toString(), { cache: 'no-store' }); return renderedPage || renderedFile;
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 scheduleProjectionRefresh(workspaceId) { function isTitleOnlyDocumentPatch(candidate) {
if (projectionRefreshTimer) window.clearTimeout(projectionRefreshTimer); if (!candidate || typeof candidate !== 'object') return false;
projectionRefreshTimer = window.setTimeout(function() { var allowedKeys = {
projectionRefreshTimer = 0; id: true,
var resolvedWorkspaceId = workspaceId || resolveWorkspaceId(document.body); documentId: true,
Promise.all([ title: true,
fetchProjection('/api/tree/projections/sidebar', resolvedWorkspaceId).then(renderPageProjection), updatedAt: true,
fetchProjection('/api/tree/projections/file', resolvedWorkspaceId).then(renderFileProjection) updated_at: true
]).then(function() { };
document.documentElement.setAttribute('data-mnote-tree-live-applied', 'true'); return Object.keys(candidate).every(function(key) {
}).catch(function(error) { return allowedKeys[key] === true;
document.documentElement.setAttribute('data-mnote-tree-live-apply-error', error instanceof Error ? error.message : String(error)); });
}); }
}, 180);
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) { function toggleChildren(row, button) {
@@ -1673,19 +1706,6 @@ const SIDEBAR_TREE_JS: &str = r##"
return url.toString(); 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) { function convertToPreviousSiblingChild(trigger, detail) {
var documentId = detail.documentId || ''; var documentId = detail.documentId || '';
var row = document.querySelector('#sidebar-tree-root .tree-row[data-node-id="' + cssEscape(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, documentId: documentId,
parentId: previousId, parentId: previousId,
sortOrder: children.length sortOrder: children.length
}).then(function(){ scheduleProjectionRefresh(detail.workspaceId || resolveWorkspaceId(row)); }); });
} }
function handleTreeContextMenuAction(action, detail, trigger) { function handleTreeContextMenuAction(action, detail, trigger) {
@@ -1749,14 +1769,6 @@ const SIDEBAR_TREE_JS: &str = r##"
dispatchSidebarEvent('tree.page.share', detail); dispatchSidebarEvent('tree.page.share', detail);
return; return;
} }
if (action === 'move') {
openTreePicker('move', detail);
return;
}
if (action === 'embed') {
openTreePicker('embed', detail);
return;
}
if (action === 'copy-link') { if (action === 'copy-link') {
void copyTreeContextValue(documentHref(documentId, workspaceId), 'copy-link'); void copyTreeContextValue(documentHref(documentId, workspaceId), 'copy-link');
return; return;
@@ -1807,7 +1819,7 @@ const SIDEBAR_TREE_JS: &str = r##"
action: 'purge', action: 'purge',
workspaceId: workspaceId, workspaceId: workspaceId,
documentId: documentId documentId: documentId
}).then(function(){ scheduleProjectionRefresh(workspaceId); }); });
} }
} }
@@ -1877,14 +1889,10 @@ const SIDEBAR_TREE_JS: &str = r##"
{ action: 'color', icon: 'format_paint', label: '' } { action: 'color', icon: 'format_paint', label: '' }
] : isAsset ? [ ] : isAsset ? [
{ action: 'open-right', icon: 'open_in_new', label: '', shortcut: 'Alt + O' }, { 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: 'copy-id', icon: 'tag', label: ' ID' }
] : [ ] : [
{ action: 'open-right', icon: 'right_panel_open', label: '', shortcut: 'Alt+' }, { action: 'open-right', icon: 'right_panel_open', label: '', shortcut: 'Alt+' },
{ separator: true }, { 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-link', icon: 'link', label: '访' },
{ action: 'copy-reference-inline', icon: 'content_copy', label: '' }, { action: 'copy-reference-inline', icon: 'content_copy', label: '' },
{ action: 'copy-id', icon: 'tag', label: 'ID' }, { 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); openFileTreeContextMenu(fileRow, point.x, point.y, fileBtn || fileRow);
return; 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(); e.preventDefault();
selectSidebarFileTreeRow(fileRow, { ctrlKey: e.ctrlKey, metaKey: e.metaKey, shiftKey: e.shiftKey }); 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 }); 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); toggleChildren(row, btn);
e.preventDefault(); e.preventDefault();
} else if (action === 'open') { } else if (action === 'open') {
if (btn.getAttribute('data-page-openable') === 'false') {
e.preventDefault();
return;
}
var workspaceId = resolveWorkspaceId(btn); var workspaceId = resolveWorkspaceId(btn);
navigateToDocument(nodeId, workspaceId, { treeView: 'page' }); navigateToDocument(nodeId, workspaceId, { treeView: 'page' });
e.preventDefault(); e.preventDefault();
@@ -3384,7 +3402,6 @@ const SIDEBAR_TREE_JS: &str = r##"
title: title.trim() title: title.trim()
}).then(function(){ }).then(function(){
updateTitleEverywhere(nodeId, title.trim()); updateTitleEverywhere(nodeId, title.trim());
scheduleProjectionRefresh(resolveWorkspaceId(btn));
}); });
} }
} else if (action === 'menu') { } else if (action === 'menu') {
@@ -3586,7 +3603,7 @@ const SIDEBAR_TREE_JS: &str = r##"
documentId: sourceNodeId, documentId: sourceNodeId,
parentId: target.parentId, parentId: target.parentId,
sortOrder: target.sortOrder sortOrder: target.sortOrder
}).then(function(){ scheduleProjectionRefresh(resolveWorkspaceId(pageRow)); }); });
return; return;
} }
var fileTree = document.getElementById('sidebar-file-tree-root'); var fileTree = document.getElementById('sidebar-file-tree-root');
@@ -3637,10 +3654,11 @@ const SIDEBAR_TREE_JS: &str = r##"
window.addEventListener('tree:snapshot', function(event) { window.addEventListener('tree:snapshot', function(event) {
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; 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'); 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) { window.addEventListener('tree:delta', function(event) {
@@ -3652,14 +3670,24 @@ const SIDEBAR_TREE_JS: &str = r##"
updateTitleEverywhere(doc.id || doc.documentId, doc.title || ''); 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'); 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) { window.addEventListener('tree:resync', function(event) {
var payload = event.detail && event.detail.payload ? event.detail.payload : event.detail; 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'); 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'); var tree = document.getElementById('sidebar-tree-root');
@@ -3693,7 +3721,6 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
schema: 'mnote.tree_live_bootstrap.v1', schema: 'mnote.tree_live_bootstrap.v1',
transport: 'convex-command-log-sse', transport: 'convex-command-log-sse',
endpoint: '/api/tree/events', endpoint: '/api/tree/events',
resyncEndpoint: '/api/tree/projections/sidebar',
rootIds: [], rootIds: [],
initialRevision: null initialRevision: null
}; };
@@ -3790,9 +3817,6 @@ const TREE_LIVE_CONTROLLER_JS: &str = r##"
source.onerror = function(){ source.onerror = function(){
failures += 1; failures += 1;
applyStatus(failures >= 3 ? 'resync-pending' : 'reconnecting'); 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": [], "rootIds": [],
"initialRevision": null, "initialRevision": null,
"endpoint": "/api/tree/events", "endpoint": "/api/tree/events",
"resyncEndpoint": "/api/tree/projections/sidebar",
"views": ["page-tree", "file-tree"] "views": ["page-tree", "file-tree"]
}) })
.to_string(); .to_string();
@@ -3998,6 +4021,14 @@ mod tests {
)); ));
assert!(!SIDEBAR_TREE_JS assert!(!SIDEBAR_TREE_JS
.contains(r#"[data-node-id="' + cssEscape(documentId) + '"] .tree-link-title"#)); .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] #[test]
@@ -4009,5 +4040,7 @@ mod tests {
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot")); assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:snapshot"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta")); assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:delta"));
assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync")); assert!(TREE_LIVE_CONTROLLER_JS.contains("tree:resync"));
assert!(!TREE_LIVE_CONTROLLER_JS.contains("resyncEndpoint"));
assert!(!TREE_LIVE_CONTROLLER_JS.contains("tree:resync-requested"));
} }
} }
@@ -372,6 +372,18 @@ fn convex_command_args_for_plan(plan: &RuntimeCommandExecutionPlan) -> Value {
map.remove("domainEventPlans"); 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 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] #[test]
fn build_authorization_prefers_forwarded_authorization() { fn build_authorization_prefers_forwarded_authorization() {
let mut headers = HeaderMap::new(); let mut headers = HeaderMap::new();
@@ -99,9 +99,13 @@ fn render_filetree_row(
create_action_html = create_action_html, create_action_html = create_action_html,
title = escape_html(&row.title), 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())) { if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
html.push_str(r#"<ul class="tree-children">"#); html.push_str(if row.expanded {
r#"<ul class="tree-children">"#
} else {
r#"<ul class="tree-children tree-children--collapsed">"#
});
for child in children { for child in children {
render_filetree_row(html, child, children_by_parent); render_filetree_row(html, child, children_by_parent);
} }
@@ -9,6 +9,7 @@ pub struct PageTreeRenderRow {
pub depth: u32, pub depth: u32,
pub expandable: bool, pub expandable: bool,
pub expanded: bool, pub expanded: bool,
pub openable: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -21,6 +22,7 @@ pub struct PageTreeDomRow {
pub depth: u32, pub depth: u32,
pub title: String, pub title: String,
pub expandable: bool, pub expandable: bool,
pub openable: bool,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -41,6 +43,7 @@ pub fn build_page_tree_dom_rows(rows: &[PageTreeRenderRow]) -> Vec<PageTreeDomRo
depth: row.depth, depth: row.depth,
title: row.title.clone(), title: row.title.clone(),
expandable: row.expandable, expandable: row.expandable,
openable: row.openable,
}) })
.collect() .collect()
} }
@@ -121,7 +124,7 @@ fn render_page_row(
.unwrap_or_default(); .unwrap_or_default();
let render_depth = render_depth_for_node(&input.rows, &row.node_id, row.depth); let render_depth = render_depth_for_node(&input.rows, &row.node_id, row.depth);
html.push_str(&format!( html.push_str(&format!(
r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" aria-selected="{selected}"{current_attr} data-focused="{focused}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button></div></div>"#, r#"<li class="tree-node" data-node-id="{node_id}"><div class="tree-row" role="treeitem" aria-level="{aria_level}" aria-expanded="{expanded_attr}" data-rust-rendered-row="page" data-testid="wolai-sidebar-row" data-tree-testid="{test_id}" data-node-id="{node_id}"{parent_attr} data-depth="{depth}" data-shell-mode="page" data-active="{active}" aria-selected="{selected}"{current_attr} data-focused="{focused}" data-page-openable="{openable}" data-draggable="true" draggable="true" tabindex="{tab_index}">{toggle_html}<button type="button" class="tree-link" data-testid="tree-node-open" data-rust-action="open" data-node-id="{node_id}" data-page-openable="{openable}"><span class="tree-link-title">{title}</span></button><div class="tree-actions"><button type="button" class="tree-action" data-testid="tree-action-menu" data-rust-action="menu" data-node-id="{node_id}" aria-label="更多操作">…</button><button type="button" class="tree-action" data-testid="tree-action-create" data-rust-action="create" data-node-id="{node_id}" aria-label="新建子页面">+</button></div></div>"#,
node_id = escape_html(&row.node_id), node_id = escape_html(&row.node_id),
aria_level = render_depth + 1, aria_level = render_depth + 1,
expanded_attr = if row.expandable && expanded { "true" } else { "false" }, expanded_attr = if row.expandable && expanded { "true" } else { "false" },
@@ -132,13 +135,18 @@ fn render_page_row(
selected = active, selected = active,
current_attr = if active { r#" aria-current="page""# } else { "" }, current_attr = if active { r#" aria-current="page""# } else { "" },
focused = focused, focused = focused,
openable = row.openable,
tab_index = if focused { "0" } else { "-1" }, tab_index = if focused { "0" } else { "-1" },
toggle_html = toggle_html, toggle_html = toggle_html,
title = escape_html(&row.title), title = escape_html(&row.title),
)); ));
if row.expandable && expanded { if row.expandable {
if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) { if let Some(children) = children_by_parent.get(&Some(row.node_id.clone())) {
html.push_str(r#"<ul class="tree-children">"#); html.push_str(if expanded {
r#"<ul class="tree-children">"#
} else {
r#"<ul class="tree-children tree-children--collapsed">"#
});
for child in children { for child in children {
render_page_row(html, child, children_by_parent, input); render_page_row(html, child, children_by_parent, input);
} }
@@ -204,6 +212,7 @@ mod tests {
depth: 0, depth: 0,
expandable: true, expandable: true,
expanded: false, expanded: false,
openable: true,
}, },
PageTreeRenderRow { PageTreeRenderRow {
node_id: "page_child".into(), node_id: "page_child".into(),
@@ -212,6 +221,7 @@ mod tests {
depth: 1, depth: 1,
expandable: false, expandable: false,
expanded: false, expanded: false,
openable: true,
}, },
]); ]);
@@ -234,6 +244,7 @@ mod tests {
depth: 0, depth: 0,
expandable: true, expandable: true,
expanded: true, expanded: true,
openable: true,
}, },
PageTreeRenderRow { PageTreeRenderRow {
node_id: "page_child".into(), node_id: "page_child".into(),
@@ -242,6 +253,7 @@ mod tests {
depth: 1, depth: 1,
expandable: false, expandable: false,
expanded: false, expanded: false,
openable: true,
}, },
], ],
active_node_id: Some("page_root".into()), active_node_id: Some("page_root".into()),
@@ -836,6 +836,26 @@ mod tests {
assert!(request.payload_json.contains("\"name\":\"mindmaps.get\"")); assert!(request.payload_json.contains("\"name\":\"mindmaps.get\""));
} }
#[test]
fn mindmap_editor_scene_query_maps_to_mindmaps_get_editor_scene() {
let query = QueryEnvelope {
name: "mindmap.editor_scene.get".into(),
payload: GetMindmap {
document_id: "page_1".into(),
mindmap_id: "mind_1".into(),
workspace_id: Some("ws_1".into()),
},
};
let request =
build_query_request(&demo_context(), &query).expect("query request should build");
assert_eq!(request.function_name, "mindmaps:getEditorScene");
assert_eq!(request.workspace_id.as_deref(), Some("ws_1"));
assert!(request
.payload_json
.contains("\"name\":\"mindmap.editor_scene.get\""));
}
#[test] #[test]
fn sidebar_dataset_query_maps_to_sidebar_dataset_list() { fn sidebar_dataset_query_maps_to_sidebar_dataset_list() {
let query = QueryEnvelope { let query = QueryEnvelope {
@@ -101,6 +101,7 @@ pub fn map_query_name_to_convex(query_name: &str) -> &'static str {
"page.aggregate.get" => "documents:getPageAggregate", "page.aggregate.get" => "documents:getPageAggregate",
"mindmaps.get" => "mindmaps:get", "mindmaps.get" => "mindmaps:get",
"mindmap.projection.get" => "mindmaps:getProjection", "mindmap.projection.get" => "mindmaps:getProjection",
"mindmap.editor_scene.get" => "mindmaps:getEditorScene",
"blocks.get" => "blocks:getById", "blocks.get" => "blocks:getById",
"sidebar.dataset.list" => "sidebar:datasetList", "sidebar.dataset.list" => "sidebar:datasetList",
"search.documents" => "search:documents", "search.documents" => "search:documents",
+336
View File
@@ -0,0 +1,336 @@
# mnote 测试参考
这份文档面向 `/mnt/Data1T/mnote/scripts` 目录下的现有测试脚本,目标不是重新设计测试体系,而是把当前已经在用的 smoke、回归脚本、截图取证和人工复核方式整理成一套可执行参考。
当前结论先说在前面:
- `scripts/` 里的主力仍然是 `Playwright smoke + Node 断言`
- 对 `mnote` 来说,最有价值的不是把所有测试都换成“浏览器插件式控制”,而是把已有 smoke 的结果变得更可看、更易复核。
- `/doko` 适合作为 smoke 之后的“真实页面直观复核层”,不是用来替代 smoke。
## 1. 当前测试分层
按目录里的现状,脚本大致分成四层。
### 1.1 入口与网关层
这一层主要验证路由、网关、owner、认证入口、基础响应是否正确,特点是快、稳定、适合作为最早执行的守门测试。
代表脚本:
- `task097-homepage-entry-smoke.js`
- `task114-rust-web-gateway-entry-smoke.js`
- `task117-next-retirement-guard.js`
- `task159-auth-entry-smoke.js`
适用场景:
- 刚切流 Rust Web
- 改了入口路由、鉴权、兼容代理
- 需要先判断“服务有没有活着、入口是不是对的”
### 1.2 页面交互 smoke 层
这一层是当前最核心的 UI 验证方式:启动页面、登录测试账号、进入真实文档页或工作区,然后通过 Playwright 操作页面并做断言。
代表脚本:
- `task110-page-title-single-truth-smoke.js`
- `task112-tree-rust-family-regression-smoke.js`
- `task120-rust-web-tree-integration-smoke.js`
- `task122-rust-web-create-page-ui-smoke.js`
- `task163-local-folder-unified-tree-browser-smoke.js`
- `task164-page-options-visible-effect-smoke.js`
- `task165-rust-web-dual-pane-smoke.js`
这一层已经覆盖了当前主线里最重要的部分:
- tree / sidebar / create page
- document shell / island hydration
- 页面设置、双栏、AI、local folder
- Rust Web 主路径
### 1.3 Wolai 对标与视觉取证层
这一层不是单纯判断“是否通过”,而是要保留截图、矩阵、对比结论,方便和 Wolai 体验做真实核对。
代表脚本:
- `task119-rust-web-wolai-visual-regression-smoke.js`
- `task129-wolai-aline-baseline-smoke.js`
- `task130``task137` 一系列 `wolai-aline-*`
- `task160-wolai-page-settings-shell-smoke.js`
- `task161-wolai-page-ai-shell-smoke.js`
这一层最重要的原则不是“文案命中”,而是:
- 必须有截图
- 必须能说明视口
- 必须能说明对比对象
- 最终仍需要人工看图
### 1.4 共享 helper 与登录/临时文档支撑层
这部分决定了 smoke 是否能稳定复用。
关键文件:
- `tree-shell-smoke-helpers.js`
- `task114-rust-web-gateway-entry-smoke.js`
目前 helper 已经沉淀了几个稳定约定:
- 默认前端入口:`MNOTE_UI_BASE_URL`,缺省 `http://127.0.0.1:3000`
- 默认测试账号:
- 邮箱:`mnote.e2e@example.com`
- 密码:`MnoteE2E123!`
- 快捷按钮:`测试账号快速登录`
- 通过 API 或 UI 兜底完成认证
- 为 smoke 自动创建、重命名、清理临时页面
后续新脚本优先复用这些 helper,不要在每个任务里再复制一套登录和清理逻辑。
## 2. 当前推荐测试顺序
对本项目,推荐不要一上来就跑最重的 UI 对标脚本,而是按下面顺序推进。
### 2.1 第一步:入口守门
先跑入口和网关类 smoke,确认不是服务没起或路由错了。
建议优先:
```bash
node scripts/task114-rust-web-gateway-entry-smoke.js
node scripts/task159-auth-entry-smoke.js
node scripts/task097-homepage-entry-smoke.js
```
适用时机:
- 本地服务刚启动
- 刚切换分支
- 改了认证、入口、gateway、compat
### 2.2 第二步:主路径交互 smoke
入口没问题后,再跑和当前改动最相关的页面交互脚本。
例如:
- tree / sidebar 改动:`task112``task120``task122`
- document shell / island`task110``task121`
- 页面设置:`task160``task164-page-options-visible-effect-smoke.js`
- 双栏:`task165`
- AI`task155``task156``task161``task162`
原则:
- 只跑和当前改动相关的脚本
- 不要无差别全量跑
- 先窄后宽
### 2.3 第三步:截图证据复核
如果脚本本身已经输出:
- `screenshot`
- `screenshotDir`
- `measure`
- `matrixPath`
- `resultPath`
那这些产物不应只当“附带文件”,而应作为测试结果的一部分。
推荐做法:
- 成功时也保留关键截图
- 失败时至少保留失败前最后一张截图
- 重要 UI 任务保留整组截图目录
### 2.4 第四步:`/doko` 真实页面复核
当 smoke 已经告诉你“逻辑大体通过”,但你仍想确认页面是否真的像人看到的一样时,再补 `/doko`
`/doko` 更适合回答这类问题:
- 首屏布局是不是顺眼
- Sidebar 层级、图标、文案是否真可读
- 页面设置弹层是否真的展开到位
- 登录后工作区是否像预期渲染
`/doko` 不擅长替代:
- 稳定断言
- 自动点击链路
- 大量重复回归
所以推荐组合是:
`smoke 先筛出通过/失败 -> 看截图 -> 必要时再用 /doko 复核真实页面`
## 3. 为什么当前不建议把主线转成“浏览器插件测试”
当前讨论过 `codex cli + 浏览器插件` 的方向,但对 `mnote` 现阶段的主要收益并不明显。
原因有三点:
1. 你当前真正缺的是“页面直观证据”,不是“替换掉 Playwright”
2. `scripts/` 里的 smoke 已经能覆盖多数关键交互,只是证据层不够统一
3. 浏览器插件路线更适合复用真实 Chrome 当前标签页和真实登录环境,不是当前项目测试的首要瓶颈
因此,现阶段更推荐:
- 保留 Playwright smoke 作为自动化骨架
- 补足截图、trace、结果摘要
- 用 `/doko` 做人工视角复核
## 4. 新增或修改 smoke 的写法建议
这里不是要求统一重构旧脚本,而是约束新增脚本尽量沿着同一条路走。
### 4.1 优先复用 helper
优先从下面两个文件拿能力:
- `tree-shell-smoke-helpers.js`
- `task114-rust-web-gateway-entry-smoke.js`
尤其是:
- 登录
- 创建临时文档
- 清理临时文档
- `fetchWithTimeout`
- `findFreePort`
- `waitForGateway`
### 4.2 让脚本输出结构化结果
推荐脚本结束时输出 JSON,至少包含:
```json
{
"ok": true,
"baseUrl": "http://127.0.0.1:3000",
"task": "taskxxx-name",
"screenshot": "/abs/path/to/file.png",
"screenshotDir": "/abs/path/to/dir"
}
```
如果没有截图,也应该至少输出:
- `ok`
- `baseUrl`
- `task`
- 关键对象 id(如 `documentId``workspaceId`
### 4.3 对 UI 任务默认保留截图
满足下面任一条件时,建议默认保留截图:
- 首屏入口变化
- Sidebar / tree 结构变化
- modal / popover / dropdown 变化
- editor block 行为变化
- Wolai 对标
推荐命名方式延续现有风格:
- `01-before-*`
- `02-after-*`
- `03-after-reload-*`
这样后续人工复核时不需要重新猜步骤。
### 4.4 失败信息要面向定位
断言报错要直接描述“哪个状态不对”,不要只写泛化失败。
推荐:
- `开启宽版后 htmlWide 应为 true,实际 false`
- `测试账号登录后仍停留在 /auth`
- `本地首屏未捕获工作区特征`
不推荐:
- `smoke failed`
- `assert error`
## 5. 针对当前 mnote 的推荐组合
如果后续只是做日常开发验证,推荐这样选:
### 5.1 快速检查
适合日常改完立刻看:
```bash
node scripts/task114-rust-web-gateway-entry-smoke.js
node scripts/task159-auth-entry-smoke.js
```
### 5.2 主路径回归
按当前改动挑 1 到 3 个最相关脚本,例如:
```bash
node scripts/task120-rust-web-tree-integration-smoke.js
node scripts/task122-rust-web-create-page-ui-smoke.js
node scripts/task164-page-options-visible-effect-smoke.js
```
### 5.3 视觉复核
如果任务明显涉及视觉或交互形态:
- 先看 smoke 产出的截图
- 再用 `/doko` 打开真实页面复核
### 5.4 Wolai 对标任务
对标任务不要只看断言结果,至少补:
- 本地 smoke
- Wolai 基线截图
- 本地截图
- 必要时 comparison matrix
## 6. 对后续演进的建议
这部分不是要求本轮立刻实现,而是后续维护时优先考虑。
### 6.1 在现有 smoke 上统一证据产物
优先级最高的增强不是“换工具”,而是统一输出:
- `tmp/.../screenshots`
- `tmp/.../trace.zip`
- `tmp/.../result.json`
这样 smoke 从“脚本 pass/fail”升级成“脚本 + 可看证据”。
### 6.2 把 `/doko` 固化成复核步骤
适合写进任务流程的话术是:
- 先跑 smoke
- 再看截图
- 仍有疑问时用 `/doko` 看真实渲染
不要让 `/doko` 承担自动化断言职责。
### 6.3 新脚本优先沿当前命名方式继续
目前 `taskNNN-...-smoke.js` 已经形成了可追踪的任务链,后续建议继续沿用,避免引入第二套命名体系。
---
如果后续要继续推进,最值得做的不是重写测试体系,而是:
1. 给最常用的 smoke 补统一截图/trace 产物
2. 再补一份“如何读取 smoke 结果”的轻量脚本或汇总器
这样能直接解决当前最真实的痛点:`A + C`,也就是“结果更直观、测试更像真的看过页面”。
+1 -1
View File
@@ -2,7 +2,7 @@
// 说明: // 说明:
// - 这是 task-019 的最小真实浏览器回归脚本。 // - 这是 task-019 的最小真实浏览器回归脚本。
// - 目标只覆盖文档页元信息、Sidebar、BlockNote 保存链,不扩大到 Mindmap / OnlyOffice。 // - 目标只覆盖文档页元信息、Sidebar、标题/正文保存链,不扩大到 Mindmap / OnlyOffice。
// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。 // - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。
const { chromium } = require("playwright"); const { chromium } = require("playwright");
+1 -1
View File
@@ -130,7 +130,7 @@ async function createTempDocument(requestContext) {
async function renameDocument(requestContext, documentId, workspaceId, title) { async function renameDocument(requestContext, documentId, workspaceId, title) {
return await requestJson(requestContext, "/api/documents/title", { return await requestJson(requestContext, "/api/documents/title", {
method: "POST", method: "POST",
data: { documentId, workspaceId, title }, data: { documentId, workspaceId, title, commandName: "page.head.updateTitle" },
}); });
} }
@@ -15,7 +15,7 @@ const {
} = require("./tree-shell-smoke-helpers"); } = require("./tree-shell-smoke-helpers");
async function waitForPageTitleInput(page) { async function waitForPageTitleInput(page) {
const input = page.getByLabel("页面标题"); const input = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await input.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
return input; return input;
} }
@@ -27,7 +27,12 @@ async function waitForTitleSave(page, documentId, expectedTitle) {
return false; return false;
} }
const payload = response.request().postDataJSON(); const payload = response.request().postDataJSON();
return payload?.documentId === documentId && payload?.title === expectedTitle && response.ok(); return (
payload?.documentId === documentId &&
payload?.title === expectedTitle &&
payload?.commandName === "page.head.updateTitle" &&
response.ok()
);
}, },
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
); );
@@ -56,7 +61,9 @@ async function waitForBreadcrumbTitle(page, title) {
async function waitForSidebarRowTitle(page, documentId, title) { async function waitForSidebarRowTitle(page, documentId, title) {
await page.waitForFunction( await page.waitForFunction(
({ docId, expectedTitle }) => { ({ docId, expectedTitle }) => {
const row = document.querySelector(`aside a[href="/documents/${docId}"]`); const row = document.querySelector(
`[data-node-id="${docId}"] .tree-link-title, [data-testid="page-tree-row"][data-node-id="${docId}"], .wolai-page-row[data-node-id="${docId}"] > .wolai-row-title`,
);
return (row?.textContent ?? "").includes(expectedTitle); return (row?.textContent ?? "").includes(expectedTitle);
}, },
{ docId: documentId, expectedTitle: title }, { docId: documentId, expectedTitle: title },
@@ -66,12 +73,10 @@ async function waitForSidebarRowTitle(page, documentId, title) {
async function waitForPageTreeTitle(page, documentId, title) { async function waitForPageTreeTitle(page, documentId, title) {
await openSectionView(page); await openSectionView(page);
const shellHost = page.getByTestId("sidebar-page-tree-shell");
await shellHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction( await page.waitForFunction(
({ nodeId, expectedTitle }) => { ({ nodeId, expectedTitle }) => {
const row = document.querySelector( const row = document.querySelector(
`[data-testid="page-tree-row"][data-node-id="${nodeId}"]`, `#sidebar-tree-root .tree-row[data-shell-mode="page"][data-node-id="${nodeId}"] .tree-link-title, [data-testid="page-tree-row"][data-node-id="${nodeId}"], .wolai-page-row[data-node-id="${nodeId}"] > .wolai-row-title`,
); );
return (row?.textContent ?? "").includes(expectedTitle); return (row?.textContent ?? "").includes(expectedTitle);
}, },
@@ -82,12 +87,10 @@ async function waitForPageTreeTitle(page, documentId, title) {
async function waitForFileTreeTitle(page, documentId, title) { async function waitForFileTreeTitle(page, documentId, title) {
await openFilesystemView(page); await openFilesystemView(page);
const shellHost = page.getByTestId("sidebar-file-tree-shell");
await shellHost.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
await page.waitForFunction( await page.waitForFunction(
({ docId, expectedTitle }) => { ({ docId, expectedTitle }) => {
const row = document.querySelector( const row = document.querySelector(
`[data-testid="filetree-doc-row"][data-doc-id="${docId}"]`, `#sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-doc-id="${docId}"] .tree-link-title, #sidebar-file-tree-root .tree-row[data-shell-mode="filetree"][data-document-id="${docId}"] .tree-link-title, [data-testid="filetree-doc-row"][data-doc-id="${docId}"]`,
); );
return (row?.textContent ?? "").includes(expectedTitle); return (row?.textContent ?? "").includes(expectedTitle);
}, },
@@ -97,7 +100,7 @@ async function waitForFileTreeTitle(page, documentId, title) {
} }
async function readVisibleTitle(page) { async function readVisibleTitle(page) {
const titleInput = page.getByLabel("页面标题"); const titleInput = page.locator('[data-page-title-input="true"][data-pane-role="primary"]').first();
if (await titleInput.isVisible().catch(() => false)) { if (await titleInput.isVisible().catch(() => false)) {
return (await titleInput.inputValue()).trim(); return (await titleInput.inputValue()).trim();
} }
@@ -84,7 +84,7 @@ async function requestSseEvents(requestContext, path, body) {
async function fetchPageAggregate(requestContext, documentId, workspaceId) { async function fetchPageAggregate(requestContext, documentId, workspaceId) {
return await requestJson( return await requestJson(
requestContext, requestContext,
`/api/documents/page?documentId=${encodeURIComponent(documentId)}&workspaceId=${encodeURIComponent(workspaceId)}`, `/api/page-aggregate/${encodeURIComponent(documentId)}?workspaceId=${encodeURIComponent(workspaceId)}`,
{ method: "GET" }, { method: "GET" },
); );
} }
@@ -106,7 +106,7 @@ async function fetchDocumentContent(requestContext, documentId, workspaceId) {
} }
function buildAiContextFromPageAggregate(pagePayload) { function buildAiContextFromPageAggregate(pagePayload) {
const page = pagePayload?.page ?? null; const page = pagePayload?.result ?? null;
const pageSubtree = page?.tree?.pageSubtree ?? null; const pageSubtree = page?.tree?.pageSubtree ?? null;
const subtreeNodes = Array.isArray(pageSubtree?.subtree?.nodes) const subtreeNodes = Array.isArray(pageSubtree?.subtree?.nodes)
? pageSubtree.subtree.nodes.slice(0, 160) ? pageSubtree.subtree.nodes.slice(0, 160)
@@ -353,7 +353,12 @@ async function renameThroughPageHead(page, documentId, title) {
return false; return false;
} }
const payload = response.request().postDataJSON(); const payload = response.request().postDataJSON();
return payload?.documentId === documentId && payload?.title === title && response.ok(); return (
payload?.documentId === documentId &&
payload?.title === title &&
payload?.commandName === "page.head.updateTitle" &&
response.ok()
);
}, },
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
); );
+1 -1
View File
@@ -119,7 +119,7 @@ async function validateSkipNextRuntimePlan() {
const plan = resolveRuntimePlan({ const plan = resolveRuntimePlan({
FRONTEND_PORT: "3000", FRONTEND_PORT: "3000",
NEXT_LEGACY_PORT: "3100", NEXT_LEGACY_PORT: "3100",
SKIP_NEXT_LEGACY: "1", MNOTE_WEB_ENABLE_LEGACY_NEXT_COMPAT: "1",
}); });
assert.equal(plan.skipGateway, false); assert.equal(plan.skipGateway, false);
assert.equal(plan.skipNextLegacy, true); assert.equal(plan.skipNextLegacy, true);
@@ -4,6 +4,7 @@
const assert = require("node:assert"); const assert = require("node:assert");
const { chromium } = require("playwright"); const { chromium } = require("playwright");
const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js"); const { fetchWithTimeout } = require("./task114-rust-web-gateway-entry-smoke.js");
const { createTempDocument, ensureAuthenticated, openDocument } = require("./tree-shell-smoke-helpers");
const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, ""); const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").replace(/\/+$/, "");
const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000); const UI_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_UI_TIMEOUT_MS || 30_000);
@@ -36,13 +37,17 @@ async function main() {
const browser = await chromium.launch({ headless: true }); const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({ viewport: { width: 1440, height: 960 } }); const context = await browser.newContext({ viewport: { width: 1440, height: 960 } });
const page = await context.newPage(); const page = await context.newPage();
let seed = null;
let created = null; let created = null;
try { try {
const response = await page.goto(`${BASE_URL}/`, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS }); await ensureAuthenticated(page, context.request);
seed = await createTempDocument(context.request, null);
const targetUrl = await openDocument(page, seed.workspaceId, seed.documentId);
const response = await page.goto(targetUrl, { waitUntil: "domcontentloaded", timeout: UI_TIMEOUT_MS });
assert(response, "根页没有返回响应"); assert(response, "根页没有返回响应");
assert.equal(response.status(), 200, `根页状态码异常: ${response.status()}`); assert.equal(response.status(), 200, `根页状态码异常: ${response.status()}`);
assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "页必须由 mnote-web 拥有"); assert.equal(response.headers()["x-mnote-web-owner"], "mnote-web", "文档页必须由 mnote-web 拥有");
const createButton = page.getByTestId("wolai-sidebar-create-page").first(); const createButton = page.getByTestId("wolai-sidebar-create-page").first();
await createButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS }); await createButton.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
@@ -59,7 +64,7 @@ async function main() {
assert(workspaceId, "新建后 URL 缺少 workspaceId"); assert(workspaceId, "新建后 URL 缺少 workspaceId");
created = { documentId, workspaceId }; created = { documentId, workspaceId };
await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"]').waitFor({ await page.locator('[data-testid="mnote-leptos-tiptap-island-editor-root"][data-pane-role="primary"]').waitFor({
state: "visible", state: "visible",
timeout: UI_TIMEOUT_MS, timeout: UI_TIMEOUT_MS,
}); });
@@ -90,6 +95,12 @@ async function main() {
`清理 UI 新建页面 ${created.documentId}`, `清理 UI 新建页面 ${created.documentId}`,
).catch(() => undefined); ).catch(() => undefined);
} }
if (seed?.documentId && seed?.workspaceId) {
await postTreeCommand(
{ action: "purge", workspaceId: seed.workspaceId, documentId: seed.documentId },
`清理种子页面 ${seed.documentId}`,
).catch(() => undefined);
}
await page.close().catch(() => undefined); await page.close().catch(() => undefined);
await context.close().catch(() => undefined); await context.close().catch(() => undefined);
await browser.close().catch(() => undefined); await browser.close().catch(() => undefined);
@@ -331,7 +331,11 @@ async function run() {
const titleResponse = await fetch("/api/documents/title", { const titleResponse = await fetch("/api/documents/title", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ ...common, title: "Browser Saved Title" }), body: JSON.stringify({
...common,
title: "Browser Saved Title",
commandName: "page.head.updateTitle",
}),
}); });
if (!titleResponse.ok) throw new Error(`title_failed_${titleResponse.status}`); if (!titleResponse.ok) throw new Error(`title_failed_${titleResponse.status}`);
const saveResponse = await fetch("/api/documents/save", { const saveResponse = await fetch("/api/documents/save", {
+76 -12
View File
@@ -4,7 +4,7 @@ const BASE_URL = (process.env.MNOTE_UI_BASE_URL || "http://127.0.0.1:3000").repl
const DOCUMENT_UI_BASE_URL = ( const DOCUMENT_UI_BASE_URL = (
process.env.MNOTE_TREE_SMOKE_DOCUMENT_BASE_URL || process.env.MNOTE_TREE_SMOKE_DOCUMENT_BASE_URL ||
process.env.MNOTE_LEGACY_UI_BASE_URL || process.env.MNOTE_LEGACY_UI_BASE_URL ||
"http://127.0.0.1:3100" BASE_URL
).replace(/\/+$/, ""); ).replace(/\/+$/, "");
const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "").replace(/\/+$/, ""); const MNOTE_WEB_BASE_URL = (process.env.MNOTE_WEB_SMOKE_BASE_URL || "").replace(/\/+$/, "");
const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000); const REQUEST_TIMEOUT_MS = Number(process.env.MNOTE_SMOKE_TIMEOUT_MS || 20_000);
@@ -109,6 +109,7 @@ async function renameDocument(requestContext, workspaceId, documentId, title) {
workspaceId, workspaceId,
documentId, documentId,
title, title,
commandName: "page.head.updateTitle",
}, },
}); });
} }
@@ -339,9 +340,26 @@ async function cleanupDocuments(requestContext, createdIds) {
async function openDocument(page, workspaceId, documentId) { async function openDocument(page, workspaceId, documentId) {
const url = `${DOCUMENT_UI_BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`; const url = `${DOCUMENT_UI_BASE_URL}/documents/${documentId}?workspaceId=${encodeURIComponent(workspaceId)}`;
const hasSidebarControls = async () => { const hasSidebarControls = async () => {
const pageTab = page.locator('[data-mnote-sidebar-tree-tab="page"]').first();
const filetreeTab = page.locator('[data-mnote-sidebar-tree-tab="filetree"]').first();
const groupButton = page.getByRole("button", { name: "分组" }); const groupButton = page.getByRole("button", { name: "分组" });
const fileButton = page.getByRole("button", { name: "文件" }); const fileButton = page.getByRole("button", { name: "文件" });
return (await isVisible(groupButton)) || (await isVisible(fileButton)); if ((await isVisible(pageTab)) || (await isVisible(filetreeTab))) {
return true;
}
if ((await isVisible(groupButton)) || (await isVisible(fileButton))) {
return true;
}
return await page.evaluate(() => {
const pageShell = document.querySelector('[data-testid="wolai-sidebar-page-tree-shell"]');
const legacyShell = document.querySelector('[data-testid="sidebar-page-tree-shell"]');
return [pageShell, legacyShell].some((node) => {
if (!(node instanceof HTMLElement)) return false;
const style = window.getComputedStyle(node);
const rect = node.getBoundingClientRect();
return !node.hidden && style.display !== "none" && style.visibility !== "hidden" && rect.width > 0 && rect.height > 0;
});
});
}; };
const gotoDocument = async () => { const gotoDocument = async () => {
@@ -379,13 +397,20 @@ async function openDocument(page, workspaceId, documentId) {
} }
async function openSectionView(page) { async function openSectionView(page) {
const button = page.getByRole("button", { name: "分组" });
await button.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const waitForHost = () => const waitForHost = () =>
page.waitForFunction( page.waitForFunction(
() => { () => {
const pageHost = document.querySelector('[data-testid="sidebar-page-tree-shell"]'); const pagePanel = document.querySelector('[data-mnote-sidebar-tree-panel="page"]');
return pageHost instanceof HTMLElement && pageHost.getClientRects().length > 0; const pageRoot = document.getElementById("sidebar-tree-root");
const legacyHost = document.querySelector('[data-testid="sidebar-page-tree-shell"]');
const isVisible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
window.getComputedStyle(node).display !== "none" &&
window.getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (isVisible(legacyHost)) return true;
return isVisible(pageRoot) && (!pagePanel || isVisible(pagePanel));
}, },
undefined, undefined,
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
@@ -399,7 +424,23 @@ async function openSectionView(page) {
} }
for (let attempt = 0; attempt < 2; attempt += 1) { for (let attempt = 0; attempt < 2; attempt += 1) {
await clickButtonByExactText(page, "分组"); const switched = await page.evaluate(() => {
const selectors = [
'[data-mnote-sidebar-tree-tab="page"]',
'button[aria-label="分组"]',
];
for (const selector of selectors) {
const node = document.querySelector(selector);
if (node instanceof HTMLElement) {
node.click();
return true;
}
}
return false;
});
if (!switched) {
throw new Error("未找到页面树切换入口");
}
try { try {
await waitForHost(); await waitForHost();
return; return;
@@ -412,13 +453,20 @@ async function openSectionView(page) {
} }
async function openFilesystemView(page) { async function openFilesystemView(page) {
const button = page.getByRole("button", { name: "文件" });
await button.waitFor({ state: "visible", timeout: UI_TIMEOUT_MS });
const waitForHost = () => const waitForHost = () =>
page.waitForFunction( page.waitForFunction(
() => { () => {
const fileHost = document.querySelector('[data-testid="sidebar-file-tree-shell"]'); const filePanel = document.querySelector('[data-mnote-sidebar-tree-panel="filetree"]');
return fileHost instanceof HTMLElement && fileHost.getClientRects().length > 0; const fileRoot = document.getElementById("sidebar-file-tree-root");
const legacyHost = document.querySelector('[data-testid="sidebar-file-tree-shell"]');
const isVisible = (node) =>
node instanceof HTMLElement &&
!node.hidden &&
window.getComputedStyle(node).display !== "none" &&
window.getComputedStyle(node).visibility !== "hidden" &&
node.getClientRects().length > 0;
if (isVisible(legacyHost)) return true;
return isVisible(fileRoot) && (!filePanel || isVisible(filePanel));
}, },
undefined, undefined,
{ timeout: UI_TIMEOUT_MS }, { timeout: UI_TIMEOUT_MS },
@@ -432,7 +480,23 @@ async function openFilesystemView(page) {
} }
for (let attempt = 0; attempt < 2; attempt += 1) { for (let attempt = 0; attempt < 2; attempt += 1) {
await clickButtonByExactText(page, "文件"); const switched = await page.evaluate(() => {
const selectors = [
'[data-mnote-sidebar-tree-tab="filetree"]',
'button[aria-label="文件"]',
];
for (const selector of selectors) {
const node = document.querySelector(selector);
if (node instanceof HTMLElement) {
node.click();
return true;
}
}
return false;
});
if (!switched) {
throw new Error("未找到文件树切换入口");
}
try { try {
await waitForHost(); await waitForHost();
return; return;
+3
View File
@@ -25,6 +25,9 @@ function loadEnvAll() {
loadEnvAll(); loadEnvAll();
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
// 说明:3000 Rust 网关会把 /_next/* dev 资源代理到 3100,浏览器 Origin 仍是 127.0.0.1:3000。
// Next dev 默认会拦截这类跨 origin HMR 请求,导致客户端 hydration 不执行。
allowedDevOrigins: ["127.0.0.1", "localhost"],
// 供桌面端打包使用(Electron 内置 Next server.js + 最小依赖)。 // 供桌面端打包使用(Electron 内置 Next server.js + 最小依赖)。
// 说明:`pnpm run build:desktop:next` 会依赖该产物。 // 说明:`pnpm run build:desktop:next` 会依赖该产物。
output: "standalone", output: "standalone",
+9 -18
View File
@@ -412,14 +412,12 @@ async function main() {
// 说明:3000 主链需要直接挂载正式 Leptos island,因此在 Next dev 启动前先生成 lib.rs 的 wasm-bindgen 产物。 // 说明:3000 主链需要直接挂载正式 Leptos island,因此在 Next dev 启动前先生成 lib.rs 的 wasm-bindgen 产物。
await buildLeptosTiptapIsland(); await buildLeptosTiptapIsland();
const app = next({ dev, dir: path.join(__dirname, "..") }); const app = next({ dev, dir: path.join(__dirname, ".."), hostname, port });
const handle = app.getRequestHandler(); const handle = app.getRequestHandler();
await app.prepare(); await app.prepare();
// 说明:Next dev 的 HMR 依赖 WebSocket/_next/webpack-hmr),需要交给 Next 自己处理 upgrade。
const handleUpgrade = typeof app.getUpgradeHandler === "function" ? app.getUpgradeHandler() : null; const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
try { try {
res.setHeader("x-mnote-dev-server", "1"); res.setHeader("x-mnote-dev-server", "1");
res.setHeader("x-mnote-onlyoffice-ws-proxy", "1"); res.setHeader("x-mnote-onlyoffice-ws-proxy", "1");
@@ -472,16 +470,9 @@ async function main() {
proxyOnlyOfficeUpgrade(req, socket, head); proxyOnlyOfficeUpgrade(req, socket, head);
return; return;
} }
if (handleUpgrade) { // 说明:Next custom server 会在首个 HTTP 请求时自动给同一个 http.Server 绑定 HMR upgrade listener。
handleUpgrade(req, socket, head); // 非 Convex / ONLYOFFICE 的 Upgrade 交给 Next 自己的 listener,避免同一 socket 被处理两次。
return; });
}
try {
socket.destroy();
} catch {
// ignore
}
});
server.listen(port, hostname, () => { server.listen(port, hostname, () => {
resolveOnlyOfficeInternalUrl() resolveOnlyOfficeInternalUrl()
@@ -30,14 +30,11 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
? queryHostAliasRaw ? queryHostAliasRaw
: undefined; : undefined;
const runtimeHostRaw = runtimeConfig.documentEditorHost; const runtimeHostRaw = runtimeConfig.documentEditorHost;
const runtimeBlocknoteKillSwitch =
runtimeConfig.documentEditorBlocknoteKillSwitch === true;
// 说明:优先级保持稳定且可预期: // 说明:优先级保持稳定且可预期:
// 1) query(兼容 editorHost,并支持 host 别名); // 1) query(兼容 editorHost,并支持 host 别名);
// 2) 运行时配置 documentEditorHost // 2) 运行时配置 documentEditorHost
// 3) kill switchruntime/env)回退到 blocknote // 3) 默认正式主链 leptos_tiptap_island。
// 4) 默认正式主链 leptos_tiptap_island。
const editorHostKind: EditorHostKind = (() => { const editorHostKind: EditorHostKind = (() => {
if (typeof queryHostRaw === "string") { if (typeof queryHostRaw === "string") {
return resolveEditorHostKind({ return resolveEditorHostKind({
@@ -45,9 +42,6 @@ export default async function DocumentPage({ params, searchParams }: DocumentPag
runtimeDefault: runtimeHostRaw, runtimeDefault: runtimeHostRaw,
}); });
} }
if (runtimeBlocknoteKillSwitch) {
return "blocknote";
}
return normalizeEditorHostKind(runtimeHostRaw, DEFAULT_EDITOR_HOST_KIND); return normalizeEditorHostKind(runtimeHostRaw, DEFAULT_EDITOR_HOST_KIND);
})(); })();
@@ -219,18 +219,19 @@ describe("/api/ai-agent/run route", () => {
}, },
); );
it("provider=codex 应进入 Codex host 并返回 codex_session", async () => { it.each(["codex", "hermes", "claudecode"] as const)(
"provider=%s 已退场,必须明确失败且不能静默进入 mnote-cli",
async (provider) => {
mockSafeGetJsonBody.mockResolvedValue({ mockSafeGetJsonBody.mockResolvedValue({
stream: true, stream: true,
scope: "document", scope: "document",
messages: [{ role: "user", content: "#chat 继续检查" }], messages: [{ role: "user", content: "继续检查" }],
context: { context: {
documentId: "doc-1", documentId: "doc-1",
}, },
options: { options: {
ai: { ai: {
provider: "codex", provider,
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2",
}, },
}, },
}); });
@@ -241,104 +242,18 @@ describe("/api/ai-agent/run route", () => {
userId: "user-1", userId: "user-1",
}, },
}); });
mockStartCodexJsonRun.mockImplementation(({ onJsonLine }) => {
onJsonLine?.({ type: "thread.started", thread_id: "codex-thread-1" });
return {
done: Promise.resolve({ ok: true, threadId: "codex-thread-1", text: "Codex 已回复" }),
kill: vi.fn(),
};
});
const { POST } = await import("./route"); const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" })); const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text(); const text = await response.text();
expect(response.status).toBe(200); expect(response.status).toBe(410);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled(); expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(mockStartCodexJsonRun).toHaveBeenCalledWith( expect(response.headers.get("x-mnote-ai-execution-owner")).toBe(`${provider}-retired`);
expect.objectContaining({ expect(mockStartCodexJsonRun).not.toHaveBeenCalled();
sessionId: "019dfbb6-9219-7861-a621-f6d77d9462f2", expect(mockStartHermesRun).not.toHaveBeenCalled();
}), expect(text).toContain(provider);
); },
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("codex"); );
expect(text).toContain("event: codex_session");
expect(text).toContain("codex-thread-1");
expect(text).toContain("Codex 已回复");
});
it("provider=hermes 应进入 Hermes API bridge", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "总结当前页面" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "hermes",
sessionId: "hermes-session-1",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
mockStartHermesRun.mockResolvedValue({ runId: "hermes-run-1" });
mockStreamHermesRunEvents.mockImplementation(async (_runId, onEvent) => {
await onEvent({ event: "message.delta", delta: "Hermes " });
await onEvent({ event: "run.completed", output: "Hermes 已回复" });
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(200);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(mockStartHermesRun).toHaveBeenCalledWith(
expect.objectContaining({
session_id: "hermes-session-1",
}),
);
expect(response.headers.get("x-mnote-ai-execution-owner")).toBe("hermes");
expect(text).toContain("Hermes 已回复");
});
it("provider=claudecode 未接桥时必须明确报错,不能静默进入 mnote-cli", async () => {
mockSafeGetJsonBody.mockResolvedValue({
stream: true,
scope: "document",
messages: [{ role: "user", content: "ping" }],
context: {
documentId: "doc-1",
},
options: {
ai: {
provider: "claudecode",
},
},
});
mockValidateRequestBody.mockReturnValue(null);
mockIsConvexEnabled.mockReturnValue(true);
mockGetAuthedConvexClient.mockResolvedValue({
auth: {
userId: "user-1",
},
});
const { POST } = await import("./route");
const response = await POST(new Request("http://127.0.0.1:3000/api/ai-agent/run", { method: "POST" }));
const text = await response.text();
expect(response.status).toBe(501);
expect(mockStartMnoteCliAgentHostRun).not.toHaveBeenCalled();
expect(text).toContain("ClaudeCode");
});
it("未登录时不应启动 mnote-cli host", async () => { it("未登录时不应启动 mnote-cli host", async () => {
mockSafeGetJsonBody.mockResolvedValue({ mockSafeGetJsonBody.mockResolvedValue({
@@ -1,6 +1,4 @@
import { errorResponses, safeGetJsonBody, validateRequestBody } from "@/lib/api-utils"; import { errorResponses, safeGetJsonBody, validateRequestBody } from "@/lib/api-utils";
import { codexMessagesToPrompt, findWorkspaceRoot, startCodexJsonRun } from "@/lib/ai/codex/codexExec";
import { startHermesRun, streamHermesRunEvents } from "@/lib/ai-agent/hermes/bridge";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route"; import { getAuthedConvexClient } from "@/lib/convex/route";
import { import {
@@ -11,120 +9,6 @@ import { NextResponse } from "next/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
const toSseFrame = (event: string, data: unknown) => {
const json = JSON.stringify(data ?? null);
return `event: ${event}\ndata: ${json}\n\n`;
};
const lastUserMessage = (payload: MnoteCliAgentRunPayload) => {
const found = [...payload.messages].reverse().find((message) => message.role === "user");
return String(found?.content ?? "");
};
const codexSandboxForPayload = (payload: MnoteCliAgentRunPayload) =>
/^\s*#dev\b/i.test(lastUserMessage(payload)) ? "workspace-write" : "read-only";
async function startCodexAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
send("ready", { ok: true, bridgeOwner: "codex" });
try {
const cwd = await findWorkspaceRoot(process.cwd());
const prompt = codexMessagesToPrompt(payload.messages);
const run = startCodexJsonRun({
cwd,
sandbox: codexSandboxForPayload(payload),
prompt,
model: payload.options?.ai?.model,
sessionId: payload.options?.ai?.sessionId,
onJsonLine: (line) => {
if (line.type === "thread.started" && typeof line.thread_id === "string" && line.thread_id.trim()) {
send("codex_session", { sessionId: line.thread_id.trim() });
}
},
});
const result = await run.done;
if (!result.ok) {
send("error", { ok: false, message: result.error || "Codex 执行失败" });
return;
}
const sessionId = result.threadId.trim();
if (sessionId) send("codex_session", { sessionId });
send("assistant_message", { text: result.text || "(无输出)" });
send("completion", { ok: true, text: result.text || "(无输出)", steps: 1 });
} catch (error) {
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "codex",
},
});
}
async function startHermesAgentRun(payload: MnoteCliAgentRunPayload): Promise<Response> {
const body = new ReadableStream<Uint8Array>({
async start(controller) {
const encoder = new TextEncoder();
const send = (event: string, data: unknown) => controller.enqueue(encoder.encode(toSseFrame(event, data)));
send("ready", { ok: true, bridgeOwner: "hermes" });
try {
const started = await startHermesRun({
input: payload.messages.map((message) => ({
role: message.role,
content: message.content,
})),
conversation_history: payload.messages.slice(0, -1).map((message) => ({
role: message.role,
content: message.content,
})),
session_id: payload.options?.ai?.sessionId,
});
let assistantText = "";
await streamHermesRunEvents(started.runId, (event) => {
if (event.event === "message.delta" && typeof event.delta === "string") {
assistantText += event.delta;
send("assistant_delta", { text: event.delta });
}
if (event.event === "run.completed") {
const output = typeof event.output === "string" && event.output.trim() ? event.output.trim() : assistantText.trim();
send("assistant_message", { text: output || "(无输出)" });
send("completion", { ok: true, text: output || "(无输出)", steps: 1 });
}
if (event.event === "run.failed") {
send("error", { ok: false, message: event.error || "Hermes 执行失败" });
}
});
} catch (error) {
send("error", { ok: false, message: error instanceof Error ? error.message : String(error) });
} finally {
controller.close();
}
},
});
return new Response(body, {
headers: {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
"x-mnote-ai-execution-owner": "hermes",
},
});
}
export async function POST(request: Request) { export async function POST(request: Request) {
const payload = await safeGetJsonBody<MnoteCliAgentRunPayload>(request); const payload = await safeGetJsonBody<MnoteCliAgentRunPayload>(request);
if (!payload) { if (!payload) {
@@ -150,19 +34,15 @@ export async function POST(request: Request) {
} }
const provider = String(payload.options?.ai?.provider ?? "").trim().toLowerCase(); const provider = String(payload.options?.ai?.provider ?? "").trim().toLowerCase();
if (provider === "codex") { if (provider === "codex" || provider === "hermes" || provider === "claudecode") {
return startCodexAgentRun(payload);
}
if (provider === "hermes") {
return startHermesAgentRun(payload);
}
if (provider === "claudecode") {
return NextResponse.json( return NextResponse.json(
{ error: "ClaudeCode bridge 尚未接入,不能静默降级到 mnote-cli。" },
{ {
status: 501, error: `${provider} 已退出默认系统组件,当前只保留 mnote-cli host 主执行入口。`,
},
{
status: 410,
headers: { headers: {
"x-mnote-ai-execution-owner": "claudecode-unavailable", "x-mnote-ai-execution-owner": `${provider}-retired`,
}, },
}, },
); );
@@ -1,266 +1,18 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { describe, expect, it } from "vitest";
vi.mock("@/lib/convex/enabled", () => ({
isConvexEnabled: vi.fn(() => true),
}));
vi.mock("@/lib/auth/authContext", () => ({
HttpError: class HttpError extends Error {
status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
}
},
requireAuthContext: vi.fn(async () => ({
userId: "user_1",
})),
}));
vi.mock("@/lib/convex/route", () => ({
getAuthedConvexClient: vi.fn(async () => ({
auth: { userId: "user_1" },
client: {
query: vi.fn(async () => ({
id: "doc_1",
workspace_id: "ws_1",
title: "Next fallback 页面",
updated_at: null,
can_edit: true,
})),
},
})),
}));
vi.mock("@/lib/documents/rust-runtime", () => ({
resolveRustBridgeQueryPlan: vi.fn(async () => ({
kind: "query",
queryName: "documents.content.get",
functionName: "documents:getContent",
workspaceId: "ws_1",
requestId: "req_next",
traceId: "trace_next",
actorId: "user_1",
payloadJson: "{}",
argsJson: { id: "doc_1" },
})),
executeRustBridgeQueryTransport: vi.fn(async () => ({
content: [{ id: "block_1", type: "paragraph", content: [] }],
revision: 1,
conflict_detection_key: "doc_1:1",
pageSubtree: null,
})),
}));
vi.mock("@/lib/mnote-web/internal-url", () => ({
resolveMnoteWebInternalUrl: vi.fn(async () => "http://127.0.0.1:3104"),
}));
import { GET } from "@/app/api/documents/page/route"; import { GET } from "@/app/api/documents/page/route";
import { getAuthedConvexClient } from "@/lib/convex/route";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
function rustPageAggregateSnapshot() {
return {
schema: "mnote.page_aggregate.v1" as const,
projectionVersion: 1,
source: "KernelProjection",
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: {
title: "Rust 聚合页面",
updatedAt: null,
permissions: {
readOnly: false,
disableDownload: false,
disableCopy: false,
},
},
layout: { pageOptions: { wideLayout: false } },
body: { content: [], revision: 7, conflictDetectionKey: "doc_1:7" },
tree: { pageSubtree: null },
stats: { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 },
};
}
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
});
describe("documents/page route", () => { describe("documents/page route", () => {
it("优先返回 Rust page aggregate snapshot,而不是重新组装 meta + content", async () => { it("compat 读链应明确返回 410,并指向 page aggregate 正式路由", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
schema: "mnote.page_aggregate.v1",
result: rustPageAggregateSnapshot(),
requestId: "req_rust",
traceId: "trace_rust",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await GET( const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", { new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
headers: {
"authorization": "Bearer route-token",
"cookie": "convex-auth=test-cookie",
"x-request-id": "req_route",
"x-trace-id": "trace_route",
"x-session-id": "sess_route",
"x-source-channel": "next-route",
"x-source-client": "vitest",
},
}),
); );
const payload = await response.json() as { const payload = await response.json() as {
page: { schema?: string; identity: { documentId: string }; head: { title: string } }; error: string;
meta: { requestId: string; traceId: string; queryName: string }; redirectTo: string;
}; };
expect(response.status).toBe(200); expect(response.status).toBe(410);
expect(resolveMnoteWebInternalUrl).toHaveBeenCalled(); expect(payload.error).toContain("/api/page-aggregate/:documentId");
expect(fetchMock).toHaveBeenCalledWith( expect(payload.redirectTo).toBe("/api/page-aggregate/doc_1?workspaceId=ws_1");
"http://127.0.0.1:3104/api/page-aggregate/doc_1?workspaceId=ws_1",
expect.objectContaining({
method: "GET",
cache: "no-store",
signal: expect.any(AbortSignal),
headers: expect.any(Headers),
}),
);
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers;
expect(fetchHeaders.get("accept")).toBe("application/json");
expect(fetchHeaders.get("authorization")).toBe("Bearer route-token");
expect(fetchHeaders.get("cookie")).toBe("convex-auth=test-cookie");
expect(fetchHeaders.get("x-request-id")).toBe("req_route");
expect(fetchHeaders.get("x-mnote-request-id")).toBe("req_route");
expect(fetchHeaders.get("x-trace-id")).toBe("trace_route");
expect(fetchHeaders.get("x-mnote-trace-id")).toBe("trace_route");
expect(fetchHeaders.get("x-session-id")).toBe("sess_route");
expect(fetchHeaders.get("x-mnote-session-id")).toBe("sess_route");
expect(fetchHeaders.get("x-source-channel")).toBe("next-route");
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next-route");
expect(fetchHeaders.get("x-source-client")).toBe("vitest");
expect(fetchHeaders.get("x-mnote-source-client")).toBe("vitest");
expect(getAuthedConvexClient).not.toHaveBeenCalled();
expect(payload.page.schema).toBe("mnote.page_aggregate.v1");
expect(payload.page.identity.documentId).toBe("doc_1");
expect(payload.page.head.title).toBe("Rust 聚合页面");
expect(payload.meta).toEqual({
requestId: "req_rust",
traceId: "trace_rust",
queryName: "documents.page.get",
});
});
it("Rust snapshot 返回畸形 projection 时保留 TS builder fallback", async () => {
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
ok: true,
schema: "mnote.page_aggregate.v1",
result: {
schema: "mnote.page_aggregate.v1",
projectionVersion: 1,
source: "KernelProjection",
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: { title: "错误页面", updatedAt: null },
layout: {},
body: { content: [], revision: "bad_revision", conflictDetectionKey: 7 },
tree: { pageSubtree: null },
stats: null,
},
requestId: "req_bad",
traceId: "trace_bad",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"x-request-id": "req_next",
"x-trace-id": "trace_next",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string }; body: { revision: number | null } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.page.body.revision).toBe(1);
expect(payload.meta).toEqual({
requestId: "req_next",
traceId: "trace_next",
queryName: "documents.page.get",
});
});
it("Rust internal base 不可信时直接 fallback,且不会外发凭据", async () => {
vi.mocked(resolveMnoteWebInternalUrl).mockResolvedValueOnce("https://example.com");
const fetchMock = vi.spyOn(globalThis, "fetch");
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"authorization": "Bearer route-token",
"cookie": "convex-auth=test-cookie",
"x-request-id": "req_untrusted",
"x-trace-id": "trace_untrusted",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(fetchMock).not.toHaveBeenCalled();
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.meta).toEqual({
requestId: "req_untrusted",
traceId: "trace_untrusted",
queryName: "documents.page.get",
});
});
it("Rust snapshot fetch 抛错时保留 TS builder fallback", async () => {
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
const response = await GET(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
headers: {
"x-request-id": "req_timeout",
"x-trace-id": "trace_timeout",
},
}),
);
const payload = await response.json() as {
page: { schema?: string; head: { title: string } };
meta: { requestId: string; traceId: string; queryName: string };
};
expect(response.status).toBe(200);
expect(getAuthedConvexClient).toHaveBeenCalled();
expect(payload.page.schema).toBeUndefined();
expect(payload.page.head.title).toBe("Next fallback 页面");
expect(payload.meta).toEqual({
requestId: "req_timeout",
traceId: "trace_timeout",
queryName: "documents.page.get",
});
}); });
}); });
@@ -1,48 +1,24 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import {
assertDocumentId,
documentBridgeErrorResponse,
} from "@/lib/documents/bridge";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
export async function GET(request: Request) { export async function GET(request: Request) {
if (isConvexEnabled()) { const url = new URL(request.url);
try { const documentId = url.searchParams.get("documentId")?.trim() || "";
const url = new URL(request.url); const workspaceId = url.searchParams.get("workspaceId")?.trim() || "";
const documentId = assertDocumentId(url.searchParams.get("documentId")); const redirectTo = new URL(
const workspaceId = url.searchParams.get("workspaceId")?.trim() || null; `/api/page-aggregate/${encodeURIComponent(documentId || ":documentId")}`,
const loaded = await loadPageAggregate({ request.url,
request, );
documentId, if (workspaceId) {
workspaceId, redirectTo.searchParams.set("workspaceId", workspaceId);
});
if (!loaded) {
return NextResponse.json(
{
error: "页面不存在",
meta: {
requestId: "unknown",
traceId: "unknown",
queryName: "documents.page.get",
},
},
{ status: 404 },
);
}
return NextResponse.json({
page: loaded.page,
meta: loaded.bridge,
});
} catch (error) {
return documentBridgeErrorResponse(error);
}
} }
return NextResponse.json(
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); {
error: "Next /api/documents/page compat 读链已退场,请直接使用 /api/page-aggregate/:documentId。",
redirectTo: redirectTo.pathname + redirectTo.search,
},
{ status: 410 },
);
} }
@@ -43,46 +43,18 @@ vi.mock("@/lib/documents/page-write-command-adapter", () => ({
})), })),
})); }));
vi.mock("@/lib/documents/page-aggregate-loader", () => ({
loadPageAggregate: vi.fn(async () => ({
page: {
identity: { documentId: "doc_1", workspaceId: "ws_1" },
head: {
title: "页面标题",
updatedAt: null,
permissions: {
readOnly: false,
disableDownload: false,
disableCopy: false,
},
},
layout: { pageOptions: { wideLayout: false } },
body: { content: null, revision: 0, conflictDetectionKey: "doc_1:0" },
tree: { pageSubtree: null },
stats: null,
},
bridge: {
requestId: "req_1",
traceId: "trace_1",
queryName: "documents.page.get",
},
})),
}));
import { POST as postCreateChild } from "@/app/api/documents/create-child/route"; import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
import { POST as postTemplate } from "@/app/api/documents/template/route"; import { POST as postTemplate } from "@/app/api/documents/template/route";
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route"; import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
import { POST as postTitle } from "@/app/api/documents/title/route"; import { POST as postTitle } from "@/app/api/documents/title/route";
import { POST as postOptions } from "@/app/api/documents/options/route"; import { POST as postOptions } from "@/app/api/documents/options/route";
import { POST as postSave } from "@/app/api/documents/save/route"; import { POST as postSave } from "@/app/api/documents/save/route";
import { GET as getPage } from "@/app/api/documents/page/route";
import { import {
executeDocumentCreateChildBridgeCommand, executeDocumentCreateChildBridgeCommand,
executeDocumentTemplateBridgeCommand, executeDocumentTemplateBridgeCommand,
executeDocumentEmptyTrashBridgeCommand, executeDocumentEmptyTrashBridgeCommand,
} from "@/lib/documents/page-command-adapter"; } from "@/lib/documents/page-command-adapter";
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter"; import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
afterEach(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
@@ -90,62 +62,26 @@ afterEach(() => {
}); });
describe("documents route adapters", () => { describe("documents route adapters", () => {
it("title route 在树重命名兼容请求下委托 tree commands 主路径", async () => { it("title route 在缺少 page head commandName 时返回校验错误", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({
requestId: "req_tree_rename_1",
traceId: "trace_tree_rename_1",
result: {
action: "rename",
workspaceId: "ws_1",
documentId: "doc_1",
title: "新标题",
updatedAt: "2026-04-23T00:00:00Z",
},
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
const response = await postTitle(new Request("http://localhost/api/documents/title", { const response = await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST", method: "POST",
headers: { headers: {
"authorization": "Bearer test-token",
"content-type": "application/json", "content-type": "application/json",
"cookie": "convex-auth=test-cookie",
}, },
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }), body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1", title: "新标题" }),
})); })) as {
const payload = await response.json() as { message: string;
ok: boolean; status: number;
meta: { commandName: string }; details?: { code?: string; details?: Array<{ field: string; reason: string }> };
}; };
expect(fetchMock).toHaveBeenCalledWith( expect(response.status).toBe(400);
"http://localhost/api/tree/commands", expect(response.message).toBe("标题保存仅支持 page.head.updateTitle");
expect.objectContaining({ expect(response.details).toEqual({
method: "POST", code: "VALIDATION_ERROR",
headers: expect.any(Headers), details: [{ field: "commandName", reason: "expected page.head.updateTitle" }],
body: JSON.stringify({
action: "rename",
documentId: "doc_1",
workspaceId: "ws_1",
title: "新标题",
}),
}),
);
const renameCall = fetchMock.mock.calls.find(([, init]) => {
if (!init || typeof init.body !== "string") {
return false;
}
return init.body.includes('"action":"rename"');
}); });
const forwardedHeaders = renameCall?.[1]?.headers as Headers; expect(executePageWriteBridgeCommand).not.toHaveBeenCalled();
expect(forwardedHeaders.get("authorization")).toBe("Bearer test-token");
expect(forwardedHeaders.get("cookie")).toBe("convex-auth=test-cookie");
expect(payload.ok).toBe(true);
expect(payload.meta.commandName).toBe("tree.node.rename");
}); });
it("creates child route delegates to unified adapter", async () => { it("creates child route delegates to unified adapter", async () => {
@@ -172,21 +108,6 @@ describe("documents route adapters", () => {
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled(); expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
}); });
it("page route delegates to unified aggregate loader", async () => {
const response = await getPage(
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
);
const payload = await response.json() as {
page: { identity: { documentId: string } };
meta: { queryName: string };
};
expect(loadPageAggregate).toHaveBeenCalled();
expect(response.status).toBe(200);
expect(payload.page.identity.documentId).toBe("doc_1");
expect(payload.meta.queryName).toBe("documents.page.get");
});
it("title route 在 page head 请求下仍委托 unified page write adapter", async () => { it("title route 在 page head 请求下仍委托 unified page write adapter", async () => {
await postTitle(new Request("http://localhost/api/documents/title", { await postTitle(new Request("http://localhost/api/documents/title", {
method: "POST", method: "POST",
@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled"; import { isConvexEnabled } from "@/lib/convex/enabled";
import { import {
DocumentBridgeError,
assertDocumentId, assertDocumentId,
assertTitle, assertTitle,
buildDocumentBridgeContext, buildDocumentBridgeContext,
@@ -20,10 +21,16 @@ interface RenamePayload {
commandName?: string | null; commandName?: string | null;
} }
type TreeRenameResponse = { function assertPageHeadCommandName(commandName: string | null | undefined) {
requestId?: string; if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
traceId?: string; throw new DocumentBridgeError("标题保存仅支持 page.head.updateTitle", 400, "VALIDATION_ERROR", [
}; {
field: "commandName",
reason: `expected ${PAGE_COMMAND_NAMES.updateTitle}`,
},
]);
}
}
export async function POST(request: Request) { export async function POST(request: Request) {
if (isConvexEnabled()) { if (isConvexEnabled()) {
@@ -32,53 +39,7 @@ export async function POST(request: Request) {
const normalizedDocumentId = assertDocumentId(documentId); const normalizedDocumentId = assertDocumentId(documentId);
const normalizedTitle = assertTitle(title); const normalizedTitle = assertTitle(title);
const normalizedWorkspaceId = workspaceId?.trim() || null; const normalizedWorkspaceId = workspaceId?.trim() || null;
assertPageHeadCommandName(commandName);
if (commandName !== PAGE_COMMAND_NAMES.updateTitle) {
const upstreamUrl = new URL("/api/tree/commands", request.url);
const upstreamHeaders = new Headers({
"content-type": "application/json",
});
const authorization = request.headers.get("authorization");
const cookie = request.headers.get("cookie");
if (authorization) {
upstreamHeaders.set("authorization", authorization);
}
if (cookie) {
upstreamHeaders.set("cookie", cookie);
}
const response = await fetch(upstreamUrl.toString(), {
method: "POST",
headers: upstreamHeaders,
body: JSON.stringify({
action: "rename",
documentId: normalizedDocumentId,
workspaceId: normalizedWorkspaceId,
title: normalizedTitle,
}),
});
const payload = (await response.json().catch(() => null)) as TreeRenameResponse | { error?: string } | null;
if (!response.ok) {
return NextResponse.json(
{
error:
payload && typeof payload === "object" && "error" in payload && typeof payload.error === "string"
? payload.error
: "重命名失败,请稍后再试",
},
{ status: response.status },
);
}
return NextResponse.json({
ok: true,
meta: {
requestId: payload?.requestId,
traceId: payload?.traceId,
commandName: "tree.node.rename",
},
});
}
const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId }); const bridgeContext = await buildDocumentBridgeContext({ request, workspaceId: normalizedWorkspaceId });
const envelope = buildDocumentCommandEnvelope({ const envelope = buildDocumentCommandEnvelope({
@@ -119,7 +119,7 @@ describe("/api/mnote-web/stream route", () => {
mockStreamTreeFrames.mockReset().mockReturnValue(makeFrames()); mockStreamTreeFrames.mockReset().mockReturnValue(makeFrames());
}); });
it("应在 3000 route 内直接生成 SSE,不再代理 mnote-web:3104", async () => { it("应返回 410,明确要求改用 /api/tree/events,不再代理 mnote-web:3104", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch"); const fetchSpy = vi.spyOn(globalThis, "fetch");
const { GET } = await import("./route"); const { GET } = await import("./route");
@@ -130,27 +130,21 @@ describe("/api/mnote-web/stream route", () => {
), ),
); );
expect(response.status).toBe(200); expect(response.status).toBe(410);
expect(response.headers.get("content-type")).toContain("text/event-stream"); expect(response.headers.get("x-mnote-compat-boundary")).toBe(
expect(response.headers.get("cache-control")).toBe("no-store"); "mnote-web-stream-alias-retired",
expect(response.headers.get("x-mnote-web-owner")).toBe("mnote-web"); );
expect(response.headers.get("x-mnote-tree-stream-owner")).toBe("rust-web"); expect(await response.json()).toMatchObject({
expect(response.headers.get("x-mnote-compat-boundary")).toBe("mnote-web-stream-alias"); error: "Next /api/mnote-web/stream compat alias 已退场,请直接使用 /api/tree/events。",
expect(await response.text()).toContain("event: snapshot"); redirectTo:
"http://127.0.0.1:3000/api/tree/events?workspaceId=ws_1&cursor=evt_9&rootNodeId=page_root&pollMs=500&maxPolls=0",
});
expect(fetchSpy).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled();
expect(mockExecuteRustBridgeQuery).not.toHaveBeenCalled(); expect(mockExecuteRustBridgeQuery).not.toHaveBeenCalled();
expect(mockStreamTreeFrames).toHaveBeenCalledWith( expect(mockStreamTreeFrames).not.toHaveBeenCalled();
expect.objectContaining({
workspaceId: "ws_1",
rootNodeId: "page_root",
initialCursor: "evt_9",
pollMs: 500,
maxPolls: 0,
}),
);
}); });
it("Convex 未启用时返回 501,而不是探测 3104", async () => { it("Convex 未启用时返回 retired alias,不探测 3104", async () => {
mockIsConvexEnabled.mockReturnValue(false); mockIsConvexEnabled.mockReturnValue(false);
const fetchSpy = vi.spyOn(globalThis, "fetch"); const fetchSpy = vi.spyOn(globalThis, "fetch");
@@ -161,8 +155,11 @@ describe("/api/mnote-web/stream route", () => {
}), }),
); );
expect(response.status).toBe(501); expect(response.status).toBe(410);
expect(fetchSpy).not.toHaveBeenCalled(); expect(fetchSpy).not.toHaveBeenCalled();
expect(await response.json()).toEqual({ error: "当前仅支持 Convex 模式" }); expect(await response.json()).toMatchObject({
error: "Next /api/mnote-web/stream compat alias 已退场,请直接使用 /api/tree/events。",
redirectTo: "http://127.0.0.1:3000/api/tree/events?workspaceId=ws_1",
});
}); });
}); });
@@ -1,165 +1,21 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { isConvexEnabled } from "@/lib/convex/enabled";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContextWithActor,
buildDocumentQueryEnvelope,
} from "@/lib/documents/bridge";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
import { attachKernelFileTreeProjection, resolveKernelFileTreeProjection } from "@/lib/server/kernel-file-tree";
import {
streamTreeFrames,
type TreeStreamOverview,
type TreeStreamSnapshotPayload,
} from "@/lib/tree-stream/server";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
export const runtime = "nodejs"; export const runtime = "nodejs";
function readNumberParam(url: URL, name: string): number | null {
const raw = url.searchParams.get(name);
if (!raw?.trim()) {
return null;
}
const parsed = Number(raw);
return Number.isFinite(parsed) ? parsed : null;
}
function encodeSseFrame(event: string, payload: unknown) {
return `event: ${event}\ndata: ${JSON.stringify(payload)}\n\n`;
}
export async function GET(request: Request) { export async function GET(request: Request) {
if (!isConvexEnabled()) { const directUrl = new URL("/api/tree/events", request.url);
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 }); directUrl.search = new URL(request.url).search;
} return NextResponse.json(
{
const requestUrl = new URL(request.url); error: "Next /api/mnote-web/stream compat alias 已退场,请直接使用 /api/tree/events。",
const workspaceId = requestUrl.searchParams.get("workspaceId")?.trim(); redirectTo: directUrl.toString(),
if (!workspaceId) {
return NextResponse.json({ error: "缺少 workspaceId" }, { status: 400 });
}
const { auth, client } = await getAuthedConvexClient();
const actor = {
actorType: "user",
actorId: auth.userId,
sessionId: null,
};
const context = buildDocumentBridgeContextWithActor({
request,
actor,
workspaceId,
source: {
channel: "next_mnote_web_stream",
client: "wolai-frontend",
}, },
}); {
status: 410,
const loadOverview = async (): Promise<TreeStreamOverview> => { headers: {
const envelope = buildDocumentQueryEnvelope({ "x-mnote-compat-boundary": "mnote-web-stream-alias-retired",
name: "bridge.workspace.overview",
payload: {
workspaceId,
limit: 50,
cursor: null,
commandStatus: null,
eventStatus: null,
targetPageId: null,
targetBlockId: null,
aggregateType: null,
aggregateId: null,
}, },
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
return executeRustBridgeQueryTransport<TreeStreamOverview>({
client,
plan,
});
};
const loadSnapshot = async (): Promise<TreeStreamSnapshotPayload> => {
const envelope = buildDocumentQueryEnvelope({
name: "sidebar.dataset.list",
payload: {
workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context,
envelope,
});
const dataset = await executeRustBridgeQueryTransport<SidebarDatasetListQueryResult>({
client,
plan,
});
const datasetWithFileTree = attachKernelFileTreeProjection({
dataset,
projection: await resolveKernelFileTreeProjection({
client,
request,
workspaceId,
actor,
dataset,
rootNodeId: requestUrl.searchParams.get("rootNodeId")?.trim() || null,
depth: readNumberParam(requestUrl, "depth"),
}),
});
return {
requestId: context.requestId,
traceId: context.traceId,
data: datasetWithFileTree,
snapshot: {
dataset: datasetWithFileTree,
},
};
};
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
async start(controller) {
try {
for await (const frame of streamTreeFrames({
workspaceId,
rootNodeId: requestUrl.searchParams.get("rootNodeId"),
initialCursor: requestUrl.searchParams.get("cursor"),
pollMs: readNumberParam(requestUrl, "pollMs") ?? undefined,
maxPolls: readNumberParam(requestUrl, "maxPolls"),
loadOverview,
loadSnapshot,
})) {
if (request.signal.aborted) {
break;
}
controller.enqueue(encoder.encode(encodeSseFrame(frame.event, frame.payload)));
}
controller.close();
} catch (error) {
controller.error(error);
}
}, },
cancel() { );
return undefined;
},
});
return new NextResponse(stream, {
status: 200,
headers: {
"content-type": "text/event-stream; charset=utf-8",
"cache-control": "no-store",
connection: "keep-alive",
"x-upstream": "next-tree-stream-compat",
"x-mnote-web-owner": "mnote-web",
"x-mnote-tree-stream-owner": "rust-web",
"x-mnote-compat-boundary": "mnote-web-stream-alias",
},
});
} }
@@ -208,7 +208,8 @@ describe("/api/tree/commands route", () => {
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
id: "tree.commands", id: "tree.commands",
role: "next-thin-proxy", role: "rust-owned",
owner: "rust-web-gateway",
}), }),
]), ]),
); );
@@ -59,7 +59,7 @@ export const readAiPanelPrefs = (
const parsedSteps = Number(stepsRaw); const parsedSteps = Number(stepsRaw);
const provider: AiProvider = const provider: AiProvider =
providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama" || providerRaw === "codex" providerRaw === "online" || providerRaw === "local" || providerRaw === "ollama"
? providerRaw ? providerRaw
: defaults.provider; : defaults.provider;
@@ -0,0 +1,85 @@
import { act } from "react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createRoot, type Root } from "react-dom/client";
import { DocumentReadView } from "./document-read-view";
import type { PageOptionsState } from "@/types/page-options";
function buildOptions(overrides: Partial<PageOptionsState> = {}): PageOptionsState {
return {
wideLayout: false,
smallText: false,
showHeadingNumbers: false,
showToc: false,
showStructure: false,
protectEditing: false,
showWordCount: false,
collapseBacklinks: false,
pageFont: "default",
layoutDensity: "normal",
hideChildPages: false,
showBlockRefCount: false,
embedDefaultBlockId: null,
...overrides,
};
}
describe("DocumentReadView media attachments", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
vi.stubGlobal("location", new URL("https://mnote.example.com/documents/doc_1"));
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => {
root.unmount();
});
vi.unstubAllGlobals();
container.remove();
});
it("renders Office media as a Wolai-like attachment row that opens OnlyOffice in a new window", () => {
act(() => {
root.render(
<DocumentReadView
documentId="doc_1"
options={buildOptions()}
content={{
blocks: [
{
id: "block_asset_1",
type: "media",
props: {
assetType: "file",
assetId: "asset_ppt_1",
documentId: "doc_1",
fileName: "2023自我介绍PPT_李爱波0831.pptx",
fileUrl: "https://storage.example.com/demo.pptx?token=abc",
mimeType: "application/vnd.openxmlformats-officedocument.presentationml.presentation",
fileSize: 12_110_520,
},
},
],
}}
/>,
);
});
const row = container.querySelector<HTMLAnchorElement>('[data-testid="mnote-office-attachment-row"]');
expect(row).not.toBeNull();
expect(row?.target).toBe("_blank");
expect(row?.rel).toContain("noreferrer");
expect(row?.textContent).toContain("2023自我介绍PPT_李爱波0831.pptx");
expect(row?.textContent).toContain("11.55 MB");
expect(row?.getAttribute("href")).toContain("/onlyoffice?");
expect(row?.getAttribute("href")).toContain("fileType=pptx");
expect(row?.getAttribute("href")).toContain("assetId=asset_ppt_1");
expect(row?.getAttribute("href")).toContain("documentId=doc_1");
expect(row?.querySelector('[aria-label="预览"]')).not.toBeNull();
});
});
@@ -2,8 +2,13 @@
import Link from "next/link"; import Link from "next/link";
import type { CSSProperties, ReactNode } from "react"; import type { CSSProperties, ReactNode } from "react";
import { Eye } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { TocEntry } from "@/components/editor/document-toc"; import type { TocEntry } from "@/components/editor/document-toc";
import {
buildOnlyOfficeAssetOpenUrl,
inferOnlyOfficeFileType,
} from "@/lib/onlyoffice/client-session";
import { import {
clampHeadingLevel, clampHeadingLevel,
extractPageBlocks, extractPageBlocks,
@@ -225,7 +230,51 @@ const renderChildren = (
); );
}; };
const renderMediaBlock = (block: PageSubtreeBlock) => { const formatFileSize = (size: unknown): string => {
const bytes = typeof size === "number" ? size : Number(size);
if (!Number.isFinite(bytes) || bytes <= 0) return "";
if (bytes < 1024) return `${Math.round(bytes)} B`;
const units = ["KB", "MB", "GB", "TB"];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(2)} ${units[unitIndex]}`;
};
const getOfficeAttachmentTone = (fileType: string | null) => {
switch (fileType) {
case "ppt":
case "pptx":
case "odp":
return {
badge: "P",
badgeClassName: "bg-[#f97316] text-white",
};
case "xls":
case "xlsx":
case "ods":
case "csv":
return {
badge: "X",
badgeClassName: "bg-[#16a34a] text-white",
};
case "pdf":
return {
badge: "PDF",
badgeClassName: "bg-[#dc2626] text-white",
};
default:
return {
badge: "W",
badgeClassName: "bg-[#2563eb] text-white",
};
}
};
const renderMediaBlock = (block: PageSubtreeBlock, currentDocumentId: string) => {
const props = block.props ?? {}; const props = block.props ?? {};
const assetType = String(props.assetType ?? "image"); const assetType = String(props.assetType ?? "image");
const fileUrl = typeof props.fileUrl === "string" ? props.fileUrl : ""; const fileUrl = typeof props.fileUrl === "string" ? props.fileUrl : "";
@@ -234,6 +283,11 @@ const renderMediaBlock = (block: PageSubtreeBlock) => {
const fileName = const fileName =
typeof props.fileName === "string" && props.fileName.trim() ? props.fileName : "未命名资源"; typeof props.fileName === "string" && props.fileName.trim() ? props.fileName : "未命名资源";
const caption = typeof props.caption === "string" ? props.caption.trim() : ""; const caption = typeof props.caption === "string" ? props.caption.trim() : "";
const mimeType = typeof props.mimeType === "string" ? props.mimeType : "";
const assetId = typeof props.assetId === "string" ? props.assetId : "";
const documentId = typeof props.documentId === "string" && props.documentId.trim() ? props.documentId : currentDocumentId;
const fileType = inferOnlyOfficeFileType(fileName, mimeType);
const sizeLabel = formatFileSize(props.fileSize ?? props.size ?? props.file_size);
if (!fileUrl) { if (!fileUrl) {
return ( return (
@@ -283,6 +337,41 @@ const renderMediaBlock = (block: PageSubtreeBlock) => {
); );
} }
if (fileType) {
const tone = getOfficeAttachmentTone(fileType);
const officeHref = buildOnlyOfficeAssetOpenUrl({
origin: typeof window !== "undefined" ? window.location.origin : "http://localhost",
fileUrl,
fileName,
fileType,
assetId,
documentId,
mode: "edit",
});
return (
<a
href={officeHref}
target="_blank"
rel="noopener noreferrer"
data-testid="mnote-office-attachment-row"
className="inline-flex max-w-full items-center gap-2 rounded px-1 py-0.5 text-[#27272a] transition hover:bg-[#f8fafc]"
>
<span
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-[4px] text-[10px] font-semibold leading-none",
tone.badgeClassName,
)}
aria-hidden="true"
>
{tone.badge}
</span>
<span className="min-w-0 truncate text-[15px] leading-6">{caption || fileName}</span>
<Eye className="h-4 w-4 shrink-0 text-[#a1a1aa]" aria-label="预览" />
{sizeLabel ? <span className="shrink-0 text-[12px] text-[#a1a1aa]">{sizeLabel}</span> : null}
</a>
);
}
return ( return (
<a <a
href={fileUrl} href={fileUrl}
@@ -465,7 +554,7 @@ const renderBlock = (
case "media": case "media":
return ( return (
<div key={key} className="space-y-2"> <div key={key} className="space-y-2">
{renderMediaBlock(block)} {renderMediaBlock(block, documentId)}
{children} {children}
</div> </div>
); );
@@ -7,6 +7,7 @@ import {
pageAggregateClientStateReducer, pageAggregateClientStateReducer,
selectPageAggregateClientAiSnapshot, selectPageAggregateClientAiSnapshot,
selectPageAggregateClientPageSubtree, selectPageAggregateClientPageSubtree,
selectPageAggregateClientTitleState,
} from "@/components/editor/page-aggregate-client-state"; } from "@/components/editor/page-aggregate-client-state";
import type { PageOptionsState } from "@/types/page-options"; import type { PageOptionsState } from "@/types/page-options";
@@ -103,11 +104,13 @@ describe("page-aggregate-client-state", () => {
const state = createPageAggregateClientState(page); const state = createPageAggregateClientState(page);
expect(state.serverPageTitle).toBe("页面标题");
expect(state.persistedPageTitle).toBeNull();
expect(state.draftPageTitle).toBeNull();
expect(state.options).toEqual(page.layout.pageOptions); expect(state.options).toEqual(page.layout.pageOptions);
expect(state.content).toBe(page.body.content); expect(state.content).toBe(page.body.content);
expect(state.serverContentSnapshot).toBe(page.body.content); expect(state.serverContentSnapshot).toBe(page.body.content);
expect(state.serverPageSubtreeSnapshot).toBe(page.tree.pageSubtree); expect(state.serverPageSubtreeSnapshot).toBe(page.tree.pageSubtree);
expect(state.serverPageSubtreeTitle).toBe("页面标题");
expect(state.contentRevision).toBe(3); expect(state.contentRevision).toBe(3);
expect(state.conflictDetectionKey).toBe("doc_1:3"); expect(state.conflictDetectionKey).toBe("doc_1:3");
}); });
@@ -135,12 +138,14 @@ describe("page-aggregate-client-state", () => {
page: reloadedPage, page: reloadedPage,
}); });
expect(next.serverPageTitle).toBe("刷新后的标题");
expect(next.persistedPageTitle).toBeNull();
expect(next.draftPageTitle).toBeNull();
expect(next.options.wideLayout).toBe(true); expect(next.options.wideLayout).toBe(true);
expect(next.options.showToc).toBe(true); expect(next.options.showToc).toBe(true);
expect(next.content).toBe(reloadedContent); expect(next.content).toBe(reloadedContent);
expect(next.serverContentSnapshot).toBe(reloadedContent); expect(next.serverContentSnapshot).toBe(reloadedContent);
expect(next.serverPageSubtreeSnapshot).toBe(reloadedPage.tree.pageSubtree); expect(next.serverPageSubtreeSnapshot).toBe(reloadedPage.tree.pageSubtree);
expect(next.serverPageSubtreeTitle).toBe("刷新后的标题");
expect(next.contentRevision).toBe(9); expect(next.contentRevision).toBe(9);
expect(next.conflictDetectionKey).toBe("doc_1:9"); expect(next.conflictDetectionKey).toBe("doc_1:9");
}); });
@@ -173,24 +178,89 @@ describe("page-aggregate-client-state", () => {
expect(next.conflictDetectionKey).toBe("doc_1:10"); expect(next.conflictDetectionKey).toBe("doc_1:10");
}); });
it("标题 committed/draft 应收口到同一份 page aggregate client state", () => {
const page = createPageAggregate();
const initialState = createPageAggregateClientState(page);
expect(
selectPageAggregateClientTitleState(initialState, {
liveSidebarTitle: "树标题",
}),
).toEqual({
displayTitle: "树标题",
committedTitle: "树标题",
hasDraft: false,
});
const draftState = pageAggregateClientStateReducer(initialState, {
type: "set_draft_page_title",
title: " 新标题 ",
});
expect(
selectPageAggregateClientTitleState(draftState, {
liveSidebarTitle: "树标题",
}),
).toEqual({
displayTitle: " 新标题 ",
committedTitle: "树标题",
hasDraft: true,
});
const persistedState = pageAggregateClientStateReducer(draftState, {
type: "commit_persisted_page_title",
title: "新标题",
});
expect(
selectPageAggregateClientTitleState(persistedState, {
liveSidebarTitle: "树标题",
}),
).toEqual({
displayTitle: "新标题",
committedTitle: "新标题",
hasDraft: false,
});
});
it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => { it("本地标题或正文与服务端快照不一致时,不应继续复用旧 pageSubtree", () => {
const page = createPageAggregate(); const page = createPageAggregate();
const initialState = createPageAggregateClientState(page); const initialState = createPageAggregateClientState(page);
expect(selectPageAggregateClientPageSubtree(initialState, "页面标题")).toBe(page.tree.pageSubtree); expect(
selectPageAggregateClientPageSubtree(initialState, {
liveSidebarTitle: "页面标题",
}),
).toBe(page.tree.pageSubtree);
const localContentState = pageAggregateClientStateReducer(initialState, { const localContentState = pageAggregateClientStateReducer(initialState, {
type: "apply_local_content_snapshot", type: "apply_local_content_snapshot",
content: [{ id: "block_local", type: "paragraph", content: [] }], content: [{ id: "block_local", type: "paragraph", content: [] }],
}); });
expect(selectPageAggregateClientPageSubtree(localContentState, "页面标题")).toBeNull(); expect(
selectPageAggregateClientPageSubtree(localContentState, {
liveSidebarTitle: "页面标题",
}),
).toBeNull();
const retitledState = pageAggregateClientStateReducer(initialState, { const draftTitleState = pageAggregateClientStateReducer(initialState, {
type: "update_server_page_subtree_title", type: "set_draft_page_title",
title: "草稿标题",
});
expect(
selectPageAggregateClientPageSubtree(draftTitleState, {
liveSidebarTitle: "页面标题",
}),
).toBeNull();
const persistedTitleState = pageAggregateClientStateReducer(initialState, {
type: "commit_persisted_page_title",
title: "持久化后的标题", title: "持久化后的标题",
}); });
expect(selectPageAggregateClientPageSubtree(retitledState, "页面标题")).toBeNull(); const retitledSubtree = selectPageAggregateClientPageSubtree(persistedTitleState, {
expect(selectPageAggregateClientPageSubtree(retitledState, "持久化后的标题")).toBe(page.tree.pageSubtree); liveSidebarTitle: "页面标题",
});
expect(retitledSubtree).not.toBeNull();
expect(retitledSubtree?.rootNode.metadata.title).toBe("持久化后的标题");
expect(retitledSubtree?.outline).toBe(page.tree.pageSubtree?.outline);
}); });
it("页面设置 patch 应只合并局部字段,不重建整份页面状态", () => { it("页面设置 patch 应只合并局部字段,不重建整份页面状态", () => {
@@ -223,7 +293,7 @@ describe("page-aggregate-client-state", () => {
const snapshot = selectPageAggregateClientAiSnapshot(createPageAggregateClientState(page), { const snapshot = selectPageAggregateClientAiSnapshot(createPageAggregateClientState(page), {
workspaceId: "ws_1", workspaceId: "ws_1",
pageTitle: "页面标题", liveSidebarTitle: "页面标题",
}); });
expect(snapshot).toEqual({ expect(snapshot).toEqual({
@@ -5,11 +5,13 @@ import type { PageOptionsState } from "@/types/page-options";
import type { Json } from "@/types/supabase"; import type { Json } from "@/types/supabase";
export type PageAggregateClientState = { export type PageAggregateClientState = {
serverPageTitle: string;
persistedPageTitle: string | null;
draftPageTitle: string | null;
options: PageOptionsState; options: PageOptionsState;
content: unknown; content: unknown;
serverContentSnapshot: unknown; serverContentSnapshot: unknown;
serverPageSubtreeSnapshot: PageSubtreeProjection | null; serverPageSubtreeSnapshot: PageSubtreeProjection | null;
serverPageSubtreeTitle: string;
contentRevision: number | null; contentRevision: number | null;
conflictDetectionKey: string | null; conflictDetectionKey: string | null;
}; };
@@ -19,6 +21,14 @@ export type PageAggregateClientStateAction =
type: "hydrate_from_page"; type: "hydrate_from_page";
page: PageAggregateProjection; page: PageAggregateProjection;
} }
| {
type: "set_draft_page_title";
title: string;
}
| {
type: "commit_persisted_page_title";
title: string;
}
| { | {
type: "patch_page_options"; type: "patch_page_options";
patch: Partial<PageOptionsState>; patch: Partial<PageOptionsState>;
@@ -30,10 +40,6 @@ export type PageAggregateClientStateAction =
| { | {
type: "apply_persisted_body_meta"; type: "apply_persisted_body_meta";
meta: PageBodyPersistedMeta; meta: PageBodyPersistedMeta;
}
| {
type: "update_server_page_subtree_title";
title: string;
}; };
function normalizePageTitle(title: string | null | undefined): string { function normalizePageTitle(title: string | null | undefined): string {
@@ -41,23 +47,37 @@ function normalizePageTitle(title: string | null | undefined): string {
return normalized || "无标题"; return normalized || "无标题";
} }
function resolveServerPageSubtreeTitle(page: PageAggregateProjection): string { function withResolvedPageSubtreeTitle(
const subtreeTitle = page.tree.pageSubtree?.rootNode.metadata.title; pageSubtree: PageSubtreeProjection,
if (typeof subtreeTitle === "string" && subtreeTitle.trim()) { title: string,
return subtreeTitle.trim(); ): PageSubtreeProjection {
const normalizedTitle = normalizePageTitle(title);
if (normalizePageTitle(pageSubtree.rootNode.metadata.title) === normalizedTitle) {
return pageSubtree;
} }
return normalizePageTitle(page.head.title); return {
...pageSubtree,
rootNode: {
...pageSubtree.rootNode,
metadata: {
...pageSubtree.rootNode.metadata,
title: normalizedTitle,
},
},
};
} }
export function createPageAggregateClientState( export function createPageAggregateClientState(
page: PageAggregateProjection, page: PageAggregateProjection,
): PageAggregateClientState { ): PageAggregateClientState {
return { return {
serverPageTitle: normalizePageTitle(page.head.title),
persistedPageTitle: null,
draftPageTitle: null,
options: page.layout.pageOptions, options: page.layout.pageOptions,
content: page.body.content, content: page.body.content,
serverContentSnapshot: page.body.content, serverContentSnapshot: page.body.content,
serverPageSubtreeSnapshot: page.tree.pageSubtree, serverPageSubtreeSnapshot: page.tree.pageSubtree,
serverPageSubtreeTitle: resolveServerPageSubtreeTitle(page),
contentRevision: page.body.revision, contentRevision: page.body.revision,
conflictDetectionKey: page.body.conflictDetectionKey, conflictDetectionKey: page.body.conflictDetectionKey,
}; };
@@ -70,6 +90,19 @@ export function pageAggregateClientStateReducer(
switch (action.type) { switch (action.type) {
case "hydrate_from_page": case "hydrate_from_page":
return createPageAggregateClientState(action.page); return createPageAggregateClientState(action.page);
case "set_draft_page_title":
return {
...state,
draftPageTitle: action.title,
};
case "commit_persisted_page_title": {
const normalizedTitle = normalizePageTitle(action.title);
return {
...state,
persistedPageTitle: normalizedTitle,
draftPageTitle: normalizedTitle,
};
}
case "patch_page_options": case "patch_page_options":
return { return {
...state, ...state,
@@ -89,26 +122,49 @@ export function pageAggregateClientStateReducer(
contentRevision: action.meta.revision, contentRevision: action.meta.revision,
conflictDetectionKey: action.meta.conflictDetectionKey, conflictDetectionKey: action.meta.conflictDetectionKey,
}; };
case "update_server_page_subtree_title":
return {
...state,
serverPageSubtreeTitle: normalizePageTitle(action.title),
};
default: default:
return state; return state;
} }
} }
export function selectPageAggregateClientTitleState(
state: PageAggregateClientState,
input: {
liveSidebarTitle: string | null;
},
): {
displayTitle: string;
committedTitle: string;
hasDraft: boolean;
} {
const liveCommittedTitle = normalizePageTitle(input.liveSidebarTitle ?? state.serverPageTitle);
const committedTitle =
state.persistedPageTitle != null &&
normalizePageTitle(state.persistedPageTitle) !== liveCommittedTitle
? normalizePageTitle(state.persistedPageTitle)
: liveCommittedTitle;
const hasDraft =
state.draftPageTitle != null &&
normalizePageTitle(state.draftPageTitle) !== committedTitle;
return {
displayTitle: hasDraft ? state.draftPageTitle ?? committedTitle : committedTitle,
committedTitle,
hasDraft,
};
}
export function selectPageAggregateClientPageSubtree( export function selectPageAggregateClientPageSubtree(
state: PageAggregateClientState, state: PageAggregateClientState,
pageTitle: string, input: {
liveSidebarTitle: string | null;
},
): PageSubtreeProjection | null { ): PageSubtreeProjection | null {
const hasServerPageSubtree = Boolean(state.serverPageSubtreeSnapshot); const titleState = selectPageAggregateClientTitleState(state, input);
const titleUnchanged = normalizePageTitle(pageTitle) === state.serverPageSubtreeTitle;
const contentUnchanged = state.content === state.serverContentSnapshot; const contentUnchanged = state.content === state.serverContentSnapshot;
if (hasServerPageSubtree && titleUnchanged && contentUnchanged) { if (state.serverPageSubtreeSnapshot && !titleState.hasDraft && contentUnchanged) {
return state.serverPageSubtreeSnapshot; return withResolvedPageSubtreeTitle(state.serverPageSubtreeSnapshot, titleState.committedTitle);
} }
return null; return null;
} }
@@ -117,7 +173,7 @@ export function selectPageAggregateClientAiSnapshot(
state: PageAggregateClientState, state: PageAggregateClientState,
input: { input: {
workspaceId: string | null; workspaceId: string | null;
pageTitle: string; liveSidebarTitle: string | null;
}, },
): { ): {
blocks: Json | null; blocks: Json | null;
@@ -133,7 +189,9 @@ export function selectPageAggregateClientAiSnapshot(
return { return {
blocks, blocks,
pageSubtree: selectPageAggregateClientPageSubtree(state, input.pageTitle), pageSubtree: selectPageAggregateClientPageSubtree(state, {
liveSidebarTitle: input.liveSidebarTitle,
}),
persistedMeta: { persistedMeta: {
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
revision: state.contentRevision, revision: state.contentRevision,
@@ -35,6 +35,10 @@ import { Button } from "@/components/ui/button";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer"; import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@/components/ui/drawer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { getMnoteRuntimeConfig } from "@/lib/runtime-config"; import { getMnoteRuntimeConfig } from "@/lib/runtime-config";
import {
buildOnlyOfficeAssetOpenUrl,
inferOnlyOfficeFileType,
} from "@/lib/onlyoffice/client-session";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar"; import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import { useSidebarStore } from "@/store/sidebar"; import { useSidebarStore } from "@/store/sidebar";
import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types"; import type { SidebarInitialData, SidebarSectionId } from "@/components/sidebar/types";
@@ -141,21 +145,6 @@ const SECTION_ICONS: Record<Exclude<SidebarSectionId, "private">, React.ReactNod
templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />, templates: <LayoutGrid className="h-4 w-4 text-[#34d399]" />,
}; };
const officeFileTypeFromAsset = (fileName: string | null, mimeType: string | null) => {
const name = (fileName ?? "").trim().toLowerCase();
const mt = (mimeType ?? "").trim().toLowerCase();
const ext = name.includes(".") ? name.split(".").pop() : null;
if (ext && ["doc", "docx", "odt", "rtf"].includes(ext)) return ext;
if (ext && ["ppt", "pptx", "odp"].includes(ext)) return ext;
if (ext && ["xls", "xlsx", "ods", "csv"].includes(ext)) return ext;
if (ext && ["pdf"].includes(ext)) return ext;
if (mt.includes("wordprocessingml")) return "docx";
if (mt.includes("presentationml")) return "pptx";
if (mt.includes("spreadsheetml")) return "xlsx";
if (mt.includes("pdf")) return "pdf";
return null;
};
interface SidebarProps { interface SidebarProps {
initialData: SidebarInitialData; initialData: SidebarInitialData;
sidebarData?: SidebarInitialData; sidebarData?: SidebarInitialData;
@@ -898,7 +887,7 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
return; return;
} }
const officeFileType = officeFileTypeFromAsset(asset.file_name ?? null, asset.mime_type ?? null); const officeFileType = inferOnlyOfficeFileType(asset.file_name ?? null, asset.mime_type ?? null);
if (officeFileType) { if (officeFileType) {
const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl; const officeBase = getMnoteRuntimeConfig().onlyofficeBaseUrl;
if (!officeBase) { if (!officeBase) {
@@ -913,14 +902,16 @@ function SidebarContent({ initialData, sidebarData: externalSidebarData, sidebar
throw new Error(payload?.error ?? "生成签名链接失败"); throw new Error(payload?.error ?? "生成签名链接失败");
} }
const { signedUrl } = (await res.json()) as { signedUrl: string }; const { signedUrl } = (await res.json()) as { signedUrl: string };
const target = new URL("/onlyoffice", window.location.origin); const target = buildOnlyOfficeAssetOpenUrl({
target.searchParams.set("fileUrl", signedUrl); origin: window.location.origin,
target.searchParams.set("fileName", asset.file_name ?? `未命名.${officeFileType}`); fileUrl: signedUrl,
target.searchParams.set("fileType", officeFileType); fileName: asset.file_name ?? `未命名.${officeFileType}`,
target.searchParams.set("assetId", asset.id); fileType: officeFileType,
target.searchParams.set("documentId", asset.document_id); assetId: asset.id,
target.searchParams.set("mode", "edit"); documentId: asset.document_id,
window.open(target.toString(), "_blank", "noopener,noreferrer"); mode: "edit",
});
window.open(target, "_blank", "noopener,noreferrer");
setOpen(false); setOpen(false);
} catch (error) { } catch (error) {
window.alert((error as Error).message); window.alert((error as Error).message);
@@ -37,7 +37,7 @@ function buildSidebarData(documents: DocumentRecord[]): SidebarInitialData {
function Harness(props: { function Harness(props: {
initialData: SidebarInitialData; initialData: SidebarInitialData;
sidebarQueryData: SidebarInitialData; sidebarQueryData: SidebarInitialData | null;
treeStreamData: SidebarInitialData | null; treeStreamData: SidebarInitialData | null;
treeStreamStatus?: "idle" | "connecting" | "live" | "fallback"; treeStreamStatus?: "idle" | "connecting" | "live" | "fallback";
onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void; onState: (state: ReturnType<typeof usePreferredSidebarSnapshot>) => void;
@@ -174,4 +174,45 @@ describe("usePreferredSidebarSnapshot", () => {
}), }),
}); });
}); });
it("fallback 且 query 不可用时应回到 initial 快照,而不是继续复用旧 stream 数据", async () => {
const initialData = buildSidebarData([buildDocument({ title: "初始标题" })]);
const staleTreeStream = buildSidebarData([
buildDocument({
title: "旧 stream 标题",
updated_at: "2026-04-21T00:00:01.000Z",
}),
]);
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={initialData}
treeStreamData={staleTreeStream}
treeStreamStatus="live"
onState={onState}
/>,
);
});
await act(async () => {
root.render(
<Harness
initialData={initialData}
sidebarQueryData={null}
treeStreamData={staleTreeStream}
treeStreamStatus="fallback"
onState={onState}
/>,
);
});
expect(onState.mock.lastCall?.[0]).toMatchObject({
source: "initial",
data: expect.objectContaining({
kernelSidebarTree: [expect.objectContaining({ title: "初始标题" })],
}),
});
});
}); });
@@ -40,7 +40,7 @@ export function usePreferredSidebarSnapshot(input: {
? input.treeStreamData ? input.treeStreamData
: source === "query" && input.sidebarQueryData : source === "query" && input.sidebarQueryData
? input.sidebarQueryData ? input.sidebarQueryData
: input.treeStreamData ?? input.sidebarQueryData ?? input.initialData; : input.initialData;
const syncKey = const syncKey =
source === "tree_stream" source === "tree_stream"
? treeStreamSyncKey ?? querySyncKey ?? initialSyncKey ? treeStreamSyncKey ?? querySyncKey ?? initialSyncKey
@@ -123,65 +123,29 @@ describe("useSidebarData", () => {
expect(secondState).toBe(firstState); expect(secondState).toBe(firstState);
}); });
it("Convex live 模式下手动 refetch 应强制刷新一份 HTTP sidebar snapshot", async () => { it("Convex live 模式下手动 refetch 不应再走 HTTP snapshot 补偿链", async () => {
const initialData = buildInitialData(); const initialData = buildInitialData();
const refreshedData: SidebarInitialData = {
...buildInitialData(),
documents: [
{
access_scope: "private",
id: "doc-1",
workspace_id: "ws_1",
title: "新标题",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:01.000Z",
},
],
kernelSidebarTree: [
{
id: "doc-1",
workspace_id: "ws_1",
title: "新标题",
parent_id: null,
sort_order: 0,
access_scope: "private",
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:01.000Z",
children: [],
},
],
};
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true,
json: async () => refreshedData,
});
await act(async () => { await act(async () => {
root.render(<Harness initialData={initialData} onState={onState} />); root.render(<Harness initialData={initialData} onState={onState} />);
}); });
const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>; const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
stableRefetch.mockClear();
await act(async () => { await act(async () => {
await state.refetch(); await state.refetch();
}); });
expect(global.fetch).toHaveBeenCalledWith("/api/sidebar?workspaceId=ws_1", { expect(global.fetch).not.toHaveBeenCalled();
method: "GET", expect(stableRefetch).toHaveBeenCalledTimes(1);
credentials: "include",
});
const refreshedState = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>; const refreshedState = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
expect(refreshedState.data.documents[0]?.title).toBe("新标题"); expect(refreshedState.data).toStrictEqual(initialData);
}); });
it("手动 refetch 只应临时覆盖主链,底层 live 数据变化后应回到新的 live snapshot", async () => { it("无 live 订阅且允许 HTTP fallback 时应继续走 HTTP refetch", async () => {
const initialData = buildInitialData(); const initialData = buildInitialData();
const manualSnapshot: SidebarInitialData = { const httpRefetch = vi.fn(async () => undefined);
const httpSnapshot: SidebarInitialData = {
...buildInitialData(), ...buildInitialData(),
documents: [ documents: [
{ {
@@ -213,69 +177,41 @@ describe("useSidebarData", () => {
}, },
], ],
}; };
const nextLiveData: SidebarInitialData = {
...buildInitialData(),
documents: [
{
access_scope: "private",
id: "doc-1",
workspace_id: "ws_1",
title: "Live 标题",
parent_id: null,
sort_order: 0,
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
},
],
kernelSidebarTree: [
{
id: "doc-1",
workspace_id: "ws_1",
title: "Live 标题",
parent_id: null,
sort_order: 0,
access_scope: "private",
is_starred: false,
is_template: false,
created_at: "2026-04-21T00:00:00.000Z",
updated_at: "2026-04-21T00:00:00.000Z",
children: [],
},
],
};
let liveData = initialData;
mockUseConvexSidebarData.mockImplementation(() => ({ mockUseConvexSidebarData.mockImplementation(() => ({
data: liveData, data: null,
isLoading: false, isLoading: false,
isAuthLoading: false, isAuthLoading: false,
isAuthenticated: true, isAuthenticated: false,
hasLiveSubscription: true, hasLiveSubscription: false,
canUseHttpFallback: false, canUseHttpFallback: true,
error: null, error: null,
refetch: stableRefetch, refetch: stableRefetch,
})); }));
(global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({ (global.fetch as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
ok: true, ok: true,
json: async () => manualSnapshot, json: async () => httpSnapshot,
}); });
mockUseQuery.mockImplementation(() => ({
data: initialData,
isLoading: false,
error: null,
refetch: httpRefetch,
}));
await act(async () => { await act(async () => {
root.render(<Harness initialData={initialData} onState={onState} />); root.render(<Harness initialData={initialData} onState={onState} />);
}); });
const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>; const state = onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>;
stableRefetch.mockClear();
httpRefetch.mockClear();
await act(async () => { await act(async () => {
await state.refetch(); await state.refetch();
}); });
expect((onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>).data.documents[0]?.title).toBe("HTTP 标题");
liveData = nextLiveData; expect(global.fetch).not.toHaveBeenCalled();
await act(async () => { expect(stableRefetch).not.toHaveBeenCalled();
root.render(<Harness initialData={initialData} onState={onState} />); expect(httpRefetch).toHaveBeenCalledTimes(1);
}); expect((onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>).data).toStrictEqual(initialData);
expect((onState.mock.lastCall?.[0] as ReturnType<typeof useSidebarData>).data.documents[0]?.title).toBe("Live 标题");
}); });
}); });
+1 -57
View File
@@ -50,15 +50,6 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
const convexSidebar = useConvexSidebarData(workspaceId); const convexSidebar = useConvexSidebarData(workspaceId);
const shouldUseHttpFallback = const shouldUseHttpFallback =
!convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback; !convexSidebar.hasLiveSubscription && convexSidebar.canUseHttpFallback;
const [manualSnapshotState, setManualSnapshotState] = useState<{
workspaceId: string;
data: SidebarInitialData | null;
baseSyncKey: string | null;
}>({
workspaceId,
data: null,
baseSyncKey: null,
});
const httpQuery = useQuery({ const httpQuery = useQuery({
queryKey: ["sidebar", workspaceId], queryKey: ["sidebar", workspaceId],
@@ -68,43 +59,8 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
enabled: shouldUseHttpFallback, enabled: shouldUseHttpFallback,
}); });
const manualSnapshot =
manualSnapshotState.workspaceId === workspaceId
? manualSnapshotState.data
: null;
const manualSnapshotBaseSyncKey =
manualSnapshotState.workspaceId === workspaceId
? manualSnapshotState.baseSyncKey
: null;
const baseLiveData = convexSidebar.data ?? httpQuery.data ?? initialData; const baseLiveData = convexSidebar.data ?? httpQuery.data ?? initialData;
const baseLiveDataSyncKey = useMemo( const liveData = baseLiveData;
() => buildSidebarDataSyncKey(baseLiveData),
[baseLiveData],
);
const manualSnapshotSyncKey = useMemo(
() => (manualSnapshot ? buildSidebarDataSyncKey(manualSnapshot) : null),
[manualSnapshot],
);
const liveData = useMemo(() => {
if (!manualSnapshot) {
return baseLiveData;
}
if (
manualSnapshotBaseSyncKey === baseLiveDataSyncKey &&
manualSnapshotSyncKey &&
manualSnapshotSyncKey !== baseLiveDataSyncKey
) {
return manualSnapshot;
}
return baseLiveData;
}, [
baseLiveData,
baseLiveDataSyncKey,
manualSnapshot,
manualSnapshotBaseSyncKey,
manualSnapshotSyncKey,
]);
const isLoading = const isLoading =
convexSidebar.hasLiveSubscription convexSidebar.hasLiveSubscription
? convexSidebar.isLoading ? convexSidebar.isLoading
@@ -135,18 +91,6 @@ export function useSidebarData(initialData: SidebarInitialData): SidebarDataResu
}, [httpQuery]); }, [httpQuery]);
const refetch = useCallback(async () => { const refetch = useCallback(async () => {
if (workspaceId) {
try {
const refreshedSnapshot = await requestSidebarData(workspaceId);
setManualSnapshotState({
workspaceId,
data: refreshedSnapshot,
baseSyncKey: buildSidebarDataSyncKey(liveDataRef.current),
});
return refreshedSnapshot;
} catch {
}
}
if (convexSidebar.hasLiveSubscription) { if (convexSidebar.hasLiveSubscription) {
await convexRefetchRef.current(); await convexRefetchRef.current();
return liveDataRef.current; return liveDataRef.current;
@@ -1,21 +1,6 @@
import { headers } from "next/headers"; import { headers } from "next/headers";
import { api } from "@/lib/convex/api";
import { getAuthedConvexClient } from "@/lib/convex/route";
import {
buildDocumentBridgeContext,
buildDocumentQueryEnvelope,
type BridgeContext,
} from "@/lib/documents/bridge";
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url"; import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate"; import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
import {
buildPageAggregateFromDocumentPayloads,
type DocumentContentPayload,
} from "@/lib/documents/page-aggregate-builder";
import {
executeRustBridgeQueryTransport,
resolveRustBridgeQueryPlan,
} from "@/lib/documents/rust-runtime";
const FORWARDED_REQUEST_HEADERS = [ const FORWARDED_REQUEST_HEADERS = [
"cookie", "cookie",
@@ -237,71 +222,12 @@ async function buildServerBridgeRequest(pathname: string): Promise<Request> {
}); });
} }
async function fetchDocumentContentPayload(input: {
context: BridgeContext;
documentId: string;
workspaceId: string;
}): Promise<DocumentContentPayload | null> {
const { client } = await getAuthedConvexClient();
const envelope = buildDocumentQueryEnvelope({
name: "documents.content.get",
payload: {
documentId: input.documentId,
workspaceId: input.workspaceId,
},
});
const plan = await resolveRustBridgeQueryPlan({
context: input.context,
envelope,
});
return executeRustBridgeQueryTransport<DocumentContentPayload | null>({
client,
plan,
});
}
export async function loadPageAggregate(input: { export async function loadPageAggregate(input: {
request: Request; request: Request;
documentId: string; documentId: string;
workspaceId?: string | null; workspaceId?: string | null;
}): Promise<LoadedPageAggregate | null> { }): Promise<LoadedPageAggregate | null> {
const rustSnapshot = await loadPageAggregateFromRustSnapshot(input); return loadPageAggregateFromRustSnapshot(input);
if (rustSnapshot) {
return rustSnapshot;
}
const { client } = await getAuthedConvexClient();
const meta = await client.query(api.documents.getMeta, {
id: input.documentId,
});
if (!meta) {
return null;
}
const workspaceId = input.workspaceId?.trim() || meta.workspace_id;
const context = await buildDocumentBridgeContext({
request: input.request,
workspaceId,
});
const contentPayload = await fetchDocumentContentPayload({
context,
documentId: meta.id,
workspaceId,
});
return {
page: buildPageAggregateFromDocumentPayloads({
meta,
contentPayload,
}),
bridge: {
requestId: context.requestId,
traceId: context.traceId,
queryName: "documents.page.get",
},
};
} }
export async function loadPageAggregateFromNextHeaders(input: { export async function loadPageAggregateFromNextHeaders(input: {
@@ -22,6 +22,11 @@ vi.mock("@/lib/convex/api", () => ({
batchCopy: "mediaAssets.batchCopy", batchCopy: "mediaAssets.batchCopy",
batchMove: "mediaAssets.batchMove", batchMove: "mediaAssets.batchMove",
}, },
mindmaps: {
get: "mindmaps.get",
put: "mindmaps.put",
applyCommand: "mindmaps.applyCommand",
},
}, },
})); }));
@@ -95,7 +100,7 @@ describe("resolveRustRuntimeProcessEnv", () => {
expect( expect(
resolveRustRuntimeProcessEnv({ resolveRustRuntimeProcessEnv({
RUSTUP_TOOLCHAIN: "nightly", RUSTUP_TOOLCHAIN: "nightly",
} as NodeJS.ProcessEnv), } as unknown as NodeJS.ProcessEnv),
).toMatchObject({ ).toMatchObject({
CARGO_TERM_COLOR: "never", CARGO_TERM_COLOR: "never",
RUSTUP_TOOLCHAIN: "nightly", RUSTUP_TOOLCHAIN: "nightly",
@@ -103,7 +108,214 @@ describe("resolveRustRuntimeProcessEnv", () => {
}); });
}); });
describe("executeRustBridgeQueryTransport", () => {
it("mindmap.projection.get transport 包装为 Rust projection 合同", async () => {
const { executeRustBridgeQueryTransport } = await import("./rust-runtime");
const query = vi.fn().mockResolvedValue({
data: {
data: { uid: "root", text: "Rust 导图" },
children: [{ data: { uid: "child", text: "分支主题" }, children: [] }],
},
meta: { updated_at: "2026-05-10T00:00:00.000Z" },
});
const result = await executeRustBridgeQueryTransport({
client: { query } as unknown as ConvexHttpClient,
plan: {
kind: "query",
queryName: "mindmap.projection.get",
functionName: "mindmaps:getProjection",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
payloadJson: "{}",
argsJson: {
docId: "doc_1",
mindmapId: "mind_1",
},
},
});
expect(query).toHaveBeenCalledWith("mindmaps.get", {
docId: "doc_1",
mindmapId: "mind_1",
});
expect(result).toMatchObject({
schema: "mnote.mindmap_projection.v1",
projection: "mindmap_subtree",
source: "rust-kernel",
owner: "rust-kernel",
documentId: "doc_1",
mindmapId: "mind_1",
title: "Rust 导图",
nodeCount: 2,
meta: {
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
updatedAt: "2026-05-10T00:00:00.000Z",
},
});
});
it("mindmap.simple_mind_map_scene.get transport 包装为 simple-mind-map adapter projection", async () => {
const { executeRustBridgeQueryTransport } = await import("./rust-runtime");
const query = vi.fn().mockResolvedValue({
data: {
data: { uid: "root", text: "KMIND" },
children: [{ data: { uid: "topic", text: "二级节点" }, children: [] }],
},
meta: { updated_at: "2026-05-10T00:00:00.000Z" },
});
const result = await executeRustBridgeQueryTransport({
client: { query } as unknown as ConvexHttpClient,
plan: {
kind: "query",
queryName: "mindmap.simple_mind_map_scene.get",
functionName: "mindmaps:getProjection",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
payloadJson: "{}",
argsJson: {
docId: "doc_1",
mindmapId: "mind_1",
},
},
});
expect(query).toHaveBeenCalledWith("mindmaps.get", {
docId: "doc_1",
mindmapId: "mind_1",
});
expect(result).toMatchObject({
schema: "mnote.mindmap.simple_mind_map_scene.v1",
runtime: "simple-mind-map",
source: "rust-kernel",
owner: "rust-kernel",
documentId: "doc_1",
mindmapId: "mind_1",
rootNodeId: "root",
kernelRevision: 1,
meta: {
requestId: "req_1",
traceId: "trace_1",
workspaceId: "ws_1",
updatedAt: "2026-05-10T00:00:00.000Z",
},
});
expect((result as { root?: { data?: { text?: string } } }).root?.data?.text).toBe("KMIND");
});
it("mindmap.editor_scene.get transport 包装为 Rust editor scene 合同", async () => {
const { executeRustBridgeQueryTransport } = await import("./rust-runtime");
const query = vi.fn().mockResolvedValue({
data: {
data: { uid: "root", text: "KMIND" },
children: [
{
data: { uid: "topic", text: "二级节点" },
children: [
{ data: { uid: "branch-1", text: "分支主题" }, children: [] },
{ data: { uid: "branch-2", text: "分支主题" }, children: [] },
],
},
],
},
meta: { updated_at: "2026-05-10T00:00:00.000Z" },
});
const result = await executeRustBridgeQueryTransport({
client: { query } as unknown as ConvexHttpClient,
plan: {
kind: "query",
queryName: "mindmap.editor_scene.get",
functionName: "mindmaps:getEditorScene",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
payloadJson: "{}",
argsJson: {
docId: "doc_1",
mindmapId: "mind_1",
rootNodeId: "root",
},
},
});
expect(query).toHaveBeenCalledWith("mindmaps.get", {
docId: "doc_1",
mindmapId: "mind_1",
});
expect(result).toMatchObject({
schema: "mnote.mindmap_editor_scene.v1",
source: "rust-kernel",
documentId: "doc_1",
mindmapId: "mind_1",
rootNodeId: "root",
capabilities: {
canEditText: true,
canAddChild: true,
canAddSiblingAfter: true,
canDeleteNode: true,
},
});
expect((result as { nodes?: unknown[] }).nodes).toHaveLength(4);
expect((result as { edges?: unknown[] }).edges).toHaveLength(3);
});
});
describe("executeRustBridgeMutationTransport", () => { describe("executeRustBridgeMutationTransport", () => {
it("mindmap.command.apply 应注册为 mindmaps.applyCommand transport", async () => {
const mutation = vi.fn().mockResolvedValue({
ok: true,
applied: 1,
errors: [],
workspace_id: "ws_1",
document_id: "doc_1",
mindmap_id: "mind_1",
updated_at: "2026-05-10T00:00:00.000Z",
});
const plan: RustBridgeCommandPlan = {
kind: "command",
commandName: "mindmap.command.apply",
commandId: "cmd_mindmap_apply",
functionName: "mindmaps:applyCommand",
workspaceId: "ws_1",
requestId: "req_1",
traceId: "trace_1",
actorId: "user_1",
idempotencyKey: "idem_1",
payloadJson: "{}",
argsJson: {
documentId: "doc_1",
mindmapId: "mind_1",
commands: [
{ type: "renameNode", mapId: "mind_1", nodeId: "root", title: "新标题" },
],
canonicalCommand: "mindmap.command.apply",
},
};
await executeRustBridgeMutationTransport({
client: { mutation } as unknown as ConvexHttpClient,
plan,
});
expect(mutation).not.toHaveBeenCalledWith("mindmaps.put", expect.anything());
expect(mutation).toHaveBeenCalledWith("mindmaps.applyCommand", {
docId: "doc_1",
mindmapId: "mind_1",
commands: [
{ type: "renameNode", mapId: "mind_1", nodeId: "root", title: "新标题" },
],
});
});
it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => { it("documents.move 应把 Rust treeWriteOperation 透传给 Convex 写执行器", async () => {
const movePlan = { const movePlan = {
documentId: "doc_b", documentId: "doc_b",
@@ -861,6 +1073,10 @@ describe("buildRustBridgeCommandArtifactPlan", () => {
traceId: "trace_artifact_1", traceId: "trace_artifact_1",
actorId: "user_1", actorId: "user_1",
idempotencyKey: "idem_1", idempotencyKey: "idem_1",
source: {
channel: "next-route",
client: "vitest",
},
payloadJson: "{}", payloadJson: "{}",
argsJson: { argsJson: {
domainEventPlan: { domainEventPlan: {
@@ -5,6 +5,11 @@ import path from "node:path";
import type { ConvexHttpClient } from "convex/browser"; import type { ConvexHttpClient } from "convex/browser";
import type { Id } from "../../../convex/_generated/dataModel"; import type { Id } from "../../../convex/_generated/dataModel";
import { api } from "@/lib/convex/api"; import { api } from "@/lib/convex/api";
import {
buildMindmapEditorScene,
buildMindmapProjection,
buildMindmapSimpleMindMapScene,
} from "@/lib/mindmap/mindmap-projection";
import { import {
DocumentBridgeError, DocumentBridgeError,
type BridgeTarget, type BridgeTarget,
@@ -69,6 +74,7 @@ export type RustBridgeCommandPlan = {
traceId: string; traceId: string;
actorId: string; actorId: string;
idempotencyKey: string | null; idempotencyKey: string | null;
source?: Record<string, unknown>;
payloadJson: string; payloadJson: string;
argsJson: Record<string, unknown>; argsJson: Record<string, unknown>;
}; };
@@ -1131,6 +1137,67 @@ export async function executeRustBridgeQueryTransport<TResult>(input: {
docId: assertStringArg(input.plan.argsJson, "docId"), docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"), mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
}); });
case "mindmaps:getProjection": {
const documentId = assertStringArg(input.plan.argsJson, "docId");
const mindmapId = assertStringArg(input.plan.argsJson, "mindmapId");
// compat_blob_read:当前 transport 仍从 Convex blob substrate 取数,再包装为 Rust projection 合同。
const result = await query(api.mindmaps.get, {
docId: documentId,
mindmapId,
});
const projectionInput = {
documentId,
mindmapId,
data: (result as { data?: unknown } | null)?.data,
source: "rust-kernel",
owner: "rust-kernel",
meta: {
requestId: input.plan.requestId,
traceId: input.plan.traceId,
workspaceId: input.plan.workspaceId,
documentId,
pageId: documentId,
mindmapId,
attachmentId: mindmapId,
updatedAt:
(result as { meta?: { updated_at?: string | null } | null } | null)?.meta?.updated_at ??
null,
},
};
if (input.plan.queryName === "mindmap.simple_mind_map_scene.get") {
return buildMindmapSimpleMindMapScene(projectionInput) as TResult;
}
return buildMindmapProjection(projectionInput) as TResult;
}
case "mindmaps:getEditorScene": {
const documentId = assertStringArg(input.plan.argsJson, "docId");
const mindmapId = assertStringArg(input.plan.argsJson, "mindmapId");
// compat_blob_readeditor scene 由同一 Convex blob substrate 构建,但对外暴露 Rust scene 合同。
const result = await query(api.mindmaps.get, {
docId: documentId,
mindmapId,
});
return buildMindmapEditorScene({
documentId,
mindmapId,
rootNodeId: readOptionalStringArg(input.plan.argsJson, "rootNodeId"),
data: (result as { data?: unknown } | null)?.data,
source: "rust-kernel",
owner: "rust-kernel",
meta: {
requestId: input.plan.requestId,
traceId: input.plan.traceId,
workspaceId: input.plan.workspaceId,
documentId,
pageId: documentId,
mindmapId,
attachmentId: mindmapId,
updatedAt:
(result as { meta?: { updated_at?: string | null } | null } | null)?.meta?.updated_at ??
null,
},
}) as TResult;
}
case "sidebar:datasetList": case "sidebar:datasetList":
return query(api.sidebar.datasetList, { return query(api.sidebar.datasetList, {
workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"), workspaceId: assertStringArg(input.plan.argsJson, "workspaceId"),
@@ -1193,6 +1260,9 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
batchCopy: unknown; batchCopy: unknown;
batchMove: unknown; batchMove: unknown;
}; };
mindmaps: {
applyCommand: unknown;
};
}; };
switch (input.plan.functionName) { switch (input.plan.functionName) {
@@ -1323,6 +1393,7 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
conflictDetectionKey: readOptionalStringArg(input.plan.argsJson, "conflictDetectionKey"), conflictDetectionKey: readOptionalStringArg(input.plan.argsJson, "conflictDetectionKey"),
}); });
case "mindmaps:put": case "mindmaps:put":
// compat_blob_write:整棵 put 仅保留给导入、恢复和历史调用,不作为 Phase 6 编辑主写链。
return mutation(api.mindmaps.put, { return mutation(api.mindmaps.put, {
docId: assertStringArg(input.plan.argsJson, "docId"), docId: assertStringArg(input.plan.argsJson, "docId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"), mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
@@ -1332,6 +1403,15 @@ export async function executeRustBridgeMutationTransport<TResult>(input: {
? input.plan.argsJson.createOnly ? input.plan.argsJson.createOnly
: undefined, : undefined,
}); });
case "mindmaps:applyCommand":
// compat_blob_write:命令 facade 已是主写入口,但本阶段底层仍暂写 Convex blob substrate。
return mutation(runtimeApi.mindmaps.applyCommand, {
docId:
readOptionalStringArg(input.plan.argsJson, "docId") ??
assertStringArg(input.plan.argsJson, "documentId"),
mindmapId: assertStringArg(input.plan.argsJson, "mindmapId"),
commands: Array.isArray(input.plan.argsJson.commands) ? input.plan.argsJson.commands : [],
});
case "mindmaps:softDelete": case "mindmaps:softDelete":
return mutation(api.mindmaps.softDelete, { return mutation(api.mindmaps.softDelete, {
docId: assertStringArg(input.plan.argsJson, "docId"), docId: assertStringArg(input.plan.argsJson, "docId"),
-19
View File
@@ -231,24 +231,5 @@ export function buildSidebarTreeFromKernelProjection(input: {
sortTree(roots); sortTree(roots);
// 防御性处理:如果 projection 丢了节点,但 records 里还在,补到根节点,避免页面从主导航消失。
const missingRoots = input.records
.filter((record) => !itemById.has(record.id))
.map((record) => ({
...record,
children: [],
kernel: {
nodeType: "page" as const,
depth: 0,
position: record.sort_order ?? null,
childCount: 0,
expandedByDefault: true,
},
}));
if (missingRoots.length > 0) {
roots.push(...missingRoots);
sortTree(roots);
}
return roots; return roots;
} }
+2 -34
View File
@@ -24,17 +24,9 @@ export type MnoteRuntimeConfig = {
onlyofficeCallbackOriginDesktop?: string; onlyofficeCallbackOriginDesktop?: string;
/** /**
* host * host
* leptos_tiptap_island * leptos_tiptap_island
*/ */
documentEditorHost?: documentEditorHost?: "leptos_tiptap_island";
| "blocknote"
| "leptos_tiptap_island"
| "leptos_tiptap_iframe_debug";
/**
* BlockNote 退kill switch
* query host 退 blocknote
*/
documentEditorBlocknoteKillSwitch?: boolean;
/** /**
* renderer family * renderer family
* rust_familyReact fallback * rust_familyReact fallback
@@ -93,9 +85,6 @@ const parseDocumentEditorHost = (
if (!normalized) { if (!normalized) {
return undefined; return undefined;
} }
if (normalized === "blocknote") {
return "blocknote";
}
if ( if (
normalized === "leptos_tiptap_island" || normalized === "leptos_tiptap_island" ||
normalized === "leptos_tiptap_runtime" || normalized === "leptos_tiptap_runtime" ||
@@ -104,13 +93,6 @@ const parseDocumentEditorHost = (
) { ) {
return "leptos_tiptap_island"; return "leptos_tiptap_island";
} }
if (
normalized === "leptos_tiptap_iframe_debug" ||
normalized === "leptos_tiptap_debug" ||
normalized === "iframe_debug"
) {
return "leptos_tiptap_iframe_debug";
}
return undefined; return undefined;
}; };
@@ -169,17 +151,6 @@ const readFromEnv = (): MnoteRuntimeConfig => ({
supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL, supabaseInternalUrl: process.env.SUPABASE_INTERNAL_URL,
supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY, supabaseAnonKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL, backendUrl: process.env.NEXT_PUBLIC_BACKEND_URL ?? process.env.BACKEND_URL,
...(parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
) !== undefined
? {
documentEditorBlocknoteKillSwitch: parseRuntimeBoolean(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH ??
process.env.DOCUMENT_EDITOR_BLOCKNOTE_KILL_SWITCH,
),
}
: {}),
...(parseDocumentEditorHost( ...(parseDocumentEditorHost(
process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ?? process.env.NEXT_PUBLIC_DOCUMENT_EDITOR_HOST ??
process.env.DOCUMENT_EDITOR_HOST, process.env.DOCUMENT_EDITOR_HOST,
@@ -289,8 +260,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
const documentEditorHost = const documentEditorHost =
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island"; parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
const documentEditorBlocknoteKillSwitch =
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
const treeRendererFamily = const treeRendererFamily =
parseTreeRendererFamily(cfg.treeRendererFamily) ?? "rust_family"; parseTreeRendererFamily(cfg.treeRendererFamily) ?? "rust_family";
@@ -298,7 +267,6 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
...cfg, ...cfg,
isDesktop, isDesktop,
documentEditorHost, documentEditorHost,
documentEditorBlocknoteKillSwitch,
treeRendererFamily, treeRendererFamily,
onlyofficeBaseUrl, onlyofficeBaseUrl,
onlyofficeStorageHostOverride, onlyofficeStorageHostOverride,
+18 -14
View File
@@ -187,7 +187,7 @@ describe("buildSidebarInitialData", () => {
tables: [], tables: [],
}); });
expect(queryResult).toEqual({ expect(queryResult).toMatchObject({
active_workspace_id: "ws_1", active_workspace_id: "ws_1",
workspaces: [ workspaces: [
{ {
@@ -256,6 +256,11 @@ describe("buildSidebarInitialData", () => {
projectionId: "kernel_projection:file_tree:root", projectionId: "kernel_projection:file_tree:root",
projection: "file_tree", projection: "file_tree",
rootNodeId: null, rootNodeId: null,
meta: {
search: {
ordering: "kernel_file_tree_preorder",
},
},
items: [ items: [
{ {
rowId: "doc:doc_1", rowId: "doc:doc_1",
@@ -335,7 +340,7 @@ describe("buildSidebarInitialData", () => {
mindmap_asset_children: {}, mindmap_asset_children: {},
}); });
expect(mapSidebarDatasetListQueryResultToInitialData(queryResult)).toEqual({ expect(mapSidebarDatasetListQueryResultToInitialData(queryResult)).toMatchObject({
activeWorkspaceId: "ws_1", activeWorkspaceId: "ws_1",
workspaces: [ workspaces: [
{ {
@@ -426,6 +431,11 @@ describe("buildSidebarInitialData", () => {
projectionId: "kernel_projection:file_tree:root", projectionId: "kernel_projection:file_tree:root",
projection: "file_tree", projection: "file_tree",
rootNodeId: null, rootNodeId: null,
meta: {
search: {
ordering: "kernel_file_tree_preorder",
},
},
items: [ items: [
{ {
rowId: "doc:doc_1", rowId: "doc:doc_1",
@@ -506,7 +516,7 @@ describe("buildSidebarInitialData", () => {
}); });
}); });
it("缺少 kernel projection 时会按 documents 重建 projection 并保留层级", () => { it("缺少 kernel projection 时不再本地重建第二份 projection", () => {
expect( expect(
mapSidebarDatasetListQueryResultToInitialData({ mapSidebarDatasetListQueryResultToInitialData({
active_workspace_id: "ws_1", active_workspace_id: "ws_1",
@@ -550,18 +560,12 @@ describe("buildSidebarInitialData", () => {
).toMatchObject({ ).toMatchObject({
activeWorkspaceId: "ws_1", activeWorkspaceId: "ws_1",
kernelSidebarProjection: { kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root", projectionId: "kernel_projection:sidebar_tree:missing",
}, },
kernelSidebarTree: [ kernelFileTreeProjection: {
{ projectionId: "kernel_projection:file_tree:missing",
id: "doc_1", },
children: [ kernelSidebarTree: [],
{
id: "doc_2",
},
],
},
],
}); });
}); });
}); });
-16
View File
@@ -111,12 +111,6 @@ function readKernelSidebarProjection(
return camelCaseProjection; return camelCaseProjection;
} }
if (Array.isArray(result.documents) && result.documents.length > 0) {
// 说明:部分 query transport 只回 documents,没有同步附带 projection。
// 这里按同一协议即时重建,避免 UI 把缺失节点全部降级到根层。
return buildProjectionContract(result.documents);
}
return EMPTY_KERNEL_SIDEBAR_PROJECTION; return EMPTY_KERNEL_SIDEBAR_PROJECTION;
} }
@@ -135,16 +129,6 @@ function readKernelFileTreeProjection(
return camelCaseProjection; return camelCaseProjection;
} }
if (Array.isArray(result.documents)) {
return buildKernelFileTreeProjection({
documents: result.documents,
mediaAssets: result.media_assets,
mindmapAssets: result.mindmap_assets,
tableAssets: result.table_assets,
mindmapAssetChildren: result.mindmap_asset_children,
});
}
return EMPTY_KERNEL_FILE_TREE_PROJECTION; return EMPTY_KERNEL_FILE_TREE_PROJECTION;
} }
+3 -3
View File
@@ -11,13 +11,13 @@ import { isDevAuthEnabled } from "@/lib/auth/devUser";
const isPublicRoute = createRouteMatcher([ const isPublicRoute = createRouteMatcher([
"/auth", "/auth",
"/login", "/login",
// 说明:仅用于本地/联调的页面选项回归入口(Playwright 会用它验证页面选项是否真正生效)。
// 该路由不写入后端数据,放行可避免 E2E 因鉴权/数据初始化问题被阻塞。
"/dev/page-options-playground",
"/api/auth(.*)", "/api/auth(.*)",
// 说明:ONLYOFFICE 文档服务器(容器/远端)拉取文件与回调保存不携带用户态,必须放行。 // 说明:ONLYOFFICE 文档服务器(容器/远端)拉取文件与回调保存不携带用户态,必须放行。
"/api/onlyoffice/proxy(.*)", "/api/onlyoffice/proxy(.*)",
"/api/onlyoffice/callback(.*)", "/api/onlyoffice/callback(.*)",
// 说明:媒体附件 API 作为 Rust 3000 的 TS transport 壳保留;
// 先放过 Next middleware,真实鉴权由各 route 内部 requireAuthContext 执行。
"/api/media(.*)",
// 说明:/onlyoffice-server 与 /cache 主要承载 ONLYOFFICE 静态资源与二进制缓存。 // 说明:/onlyoffice-server 与 /cache 主要承载 ONLYOFFICE 静态资源与二进制缓存。
// 这些资源不依赖用户态,且需要浏览器强缓存;若经过 Auth middleware 可能被追加 no-store,导致每次都重下几十 MB。 // 这些资源不依赖用户态,且需要浏览器强缓存;若经过 Auth middleware 可能被追加 no-store,导致每次都重下几十 MB。
"/onlyoffice-server(.*)", "/onlyoffice-server(.*)",
@@ -32,7 +32,6 @@ export interface EditorReferenceBridge {
insertOnlineTableAsset?: (args: { documentId: string; tableId: string }) => void; insertOnlineTableAsset?: (args: { documentId: string; tableId: string }) => void;
replaceWithSnapshot: (blocks: Json) => void; replaceWithSnapshot: (blocks: Json) => void;
openTableFullScreen?: (tableId: string) => void; openTableFullScreen?: (tableId: string) => void;
requestFallbackToBlockNote?: () => void;
} }
interface EditorBridgeState { interface EditorBridgeState {