feat: 收口 Rust Web 3000 主链
This commit is contained in:
Generated
+1
@@ -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",
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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 单入口边界",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
})));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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`,也就是“结果更直观、测试更像真的看过页面”。
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
// 说明:
|
// 说明:
|
||||||
// - 这是 task-019 的最小真实浏览器回归脚本。
|
// - 这是 task-019 的最小真实浏览器回归脚本。
|
||||||
// - 目标只覆盖文档页元信息、Sidebar、BlockNote 保存链,不扩大到 Mindmap / OnlyOffice。
|
// - 目标只覆盖文档页元信息、Sidebar、标题/正文保存主链,不扩大到 Mindmap / OnlyOffice。
|
||||||
// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。
|
// - 脚本会先创建一篇临时页面,完成回归后再彻底删除,避免污染现有数据。
|
||||||
|
|
||||||
const { chromium } = require("playwright");
|
const { chromium } = require("playwright");
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 switch(runtime/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 标题");
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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_read:editor 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"),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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_family;React fallback 仍作为过渡兜底保留。
|
* 说明:默认主路径已切到 rust_family;React 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,
|
||||||
|
|||||||
@@ -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",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user