Phase A — EditorRuntimeActor 内存缓存层 - 新增 editor_actor.rs: EditorBlockDocument 内存态 + apply_command + load_or_init - block.rs 四个写工具(replace/insert/delete/move)接入 actor 路径 - editor_actor feature flag(MNOTE_WEB_ENABLE_EDITOR_ACTOR=true 默认开启) - bridge-runtime 三个核心函数公开化 - rust-toolchain: 1.89 → stable(修复 spike WASM 编译阻塞) Phase B — 编辑器增量 delta channel - BlockDelta/DeltaOperation 类型 + actor.build_block_delta() - leptos-tiptap spike: mnote:editor:block-delta CustomEvent 监听 + JSON patch - DocumentAiAgentPanel: 拦截 blockDelta → window dispatchEvent - 工具响应含 blockDelta 字段供前端消费 Phase C — 事件 stream delta - broadcast channel 在 AppState/actor/SSE 三层贯通 - tree_events SSE 端点发 block.delta 事件 - 旧客户端降级兼容 环境修复 - rustc recursion_limit = 1024(修复 Leptos SSR 类型深度溢出) - run-convex-deploy.js(封装 Convex function 部署到本地后端 3210) ref: design/07-ai/process/7-13-page-block-editor-runtime-actor-v1.md
354 lines
13 KiB
Rust
354 lines
13 KiB
Rust
mod bridge;
|
|
pub(crate) mod command_support;
|
|
mod compat;
|
|
mod documents;
|
|
mod editor;
|
|
mod gateway;
|
|
mod health;
|
|
mod hermes;
|
|
mod hermes_client;
|
|
mod hermes_tools;
|
|
mod kernel;
|
|
mod local_folder_events;
|
|
mod local_folder_source;
|
|
mod local_markdown_parser;
|
|
mod media;
|
|
mod mindmap_api;
|
|
mod mindmap_shell;
|
|
mod onlyoffice;
|
|
mod page_ai_workflow;
|
|
mod query_support;
|
|
mod resource_trash;
|
|
mod search;
|
|
mod session;
|
|
mod snapshot_support;
|
|
mod sse;
|
|
mod stream_support;
|
|
mod tree;
|
|
pub(crate) mod web_shell;
|
|
mod ws;
|
|
|
|
use crate::app::AppState;
|
|
use axum::routing::{any, delete, get, post, put};
|
|
use axum::Router;
|
|
|
|
pub fn build_router(state: AppState) -> Router {
|
|
let hermes_base_path = state.config().hermes_base_path.clone();
|
|
let enable_debug_shell_routes = state.config().enable_debug_shell_routes;
|
|
|
|
let mut router = Router::new()
|
|
.route("/health", get(health::health))
|
|
.route("/", get(gateway::root_entry))
|
|
.route("/trash", get(gateway::trash_entry))
|
|
.route("/favicon.ico", get(gateway::favicon))
|
|
.route("/auth", get(gateway::auth_entry).post(gateway::auth_entry))
|
|
.route("/search", get(search::shell))
|
|
.route(
|
|
"/mindmap/{doc_id}/{mindmap_id}",
|
|
get(mindmap_shell::mindmap_object_shell),
|
|
)
|
|
.route(
|
|
"/api/mindmap/{doc_id}/{mindmap_id}",
|
|
get(mindmap_api::get_mindmap)
|
|
.post(mindmap_api::apply_mindmap_command)
|
|
.delete(resource_trash::mindmap_delete)
|
|
.patch(resource_trash::mindmap_trash_action),
|
|
)
|
|
.route(
|
|
"/api/mindmap-trash/empty",
|
|
post(resource_trash::mindmap_empty_trash),
|
|
)
|
|
.route(
|
|
"/documents/{document_id}",
|
|
get(web_shell::document_page_shell),
|
|
)
|
|
.route(
|
|
"/api/page-aggregate/{document_id}",
|
|
get(web_shell::page_aggregate),
|
|
)
|
|
.route(
|
|
"/api/leptos-tiptap-runtime/manifest.json",
|
|
get(web_shell::leptos_tiptap_manifest),
|
|
)
|
|
.route(
|
|
"/api/leptos-tiptap-runtime/{*asset_path}",
|
|
get(web_shell::leptos_tiptap_asset),
|
|
)
|
|
.route(
|
|
"/api/editor/image-placeholder.svg",
|
|
get(web_shell::editor_image_placeholder_asset),
|
|
)
|
|
.route("/api/search/documents", post(search::documents))
|
|
.route("/api/gateway/health", get(gateway::gateway_health))
|
|
.route("/api/runtime/config", get(session::runtime_config))
|
|
.route("/api/auth", post(gateway::auth_api))
|
|
.route("/api/auth/session", get(session::session))
|
|
.route("/api/auth/whoami", get(session::session))
|
|
.route("/api/auth/mnote-web-token", get(session::session))
|
|
.route("/api/auth/session/refresh", post(session::refresh_session))
|
|
.route("/api/ai-agent/run", post(compat::next_ai_agent_run))
|
|
.route(
|
|
"/api/page-ai/block-edit-workflow",
|
|
post(page_ai_workflow::block_edit_workflow),
|
|
)
|
|
.route("/onlyoffice", get(onlyoffice::page))
|
|
.route("/onlyoffice-server/{*path}", any(onlyoffice::server_proxy))
|
|
.route("/cache/{*path}", any(onlyoffice::cache_proxy))
|
|
.route("/api/onlyoffice/sign", post(onlyoffice::sign))
|
|
.route("/api/onlyoffice/proxy", get(onlyoffice::proxy))
|
|
.route("/api/onlyoffice/callback", post(onlyoffice::callback))
|
|
.route("/api/onlyoffice/forcesave", post(onlyoffice::forcesave))
|
|
.route("/api/media/upload", post(media::upload))
|
|
.route("/api/media/sign", get(media::sign))
|
|
.route("/api/media/batch", post(resource_trash::media_batch))
|
|
.route("/api/media/purge", post(resource_trash::media_purge))
|
|
.route(
|
|
"/api/media/empty-trash",
|
|
post(resource_trash::media_empty_trash),
|
|
)
|
|
.route("/api/tables/restore", post(resource_trash::table_restore))
|
|
.route("/api/tables/create", post(resource_trash::table_create))
|
|
.route(
|
|
"/api/tables/{table_id}",
|
|
delete(resource_trash::table_delete),
|
|
)
|
|
.route("/api/tables/purge", post(resource_trash::table_purge))
|
|
.route(
|
|
"/api/tables/empty-trash",
|
|
post(resource_trash::table_empty_trash),
|
|
)
|
|
.route(
|
|
"/api/tree/filetree/upload-target-preflight",
|
|
post(media::filetree_upload_target_preflight),
|
|
)
|
|
.route(
|
|
"/api/tree/filetree/drop-preflight",
|
|
post(tree::filetree_drop_preflight),
|
|
)
|
|
.route("/api/documents/meta", get(documents::meta))
|
|
.route("/api/documents/content", get(documents::content))
|
|
.route("/api/documents/purge", post(documents::purge))
|
|
.route("/api/documents/empty-trash", post(documents::empty_trash))
|
|
.route("/api/documents/title", post(documents::title))
|
|
.route("/api/documents/options", post(documents::options))
|
|
.route("/api/documents/save", post(documents::save))
|
|
.route(
|
|
"/api/documents/runtime/transform",
|
|
post(editor::transform_runtime_snapshot),
|
|
)
|
|
.route(
|
|
"/api/tree/projections/sidebar",
|
|
get(kernel::project_tree_sidebar),
|
|
)
|
|
.route("/api/tree/projections/page", get(kernel::project_tree_page))
|
|
.route("/api/tree/projections/file", get(kernel::project_tree_file))
|
|
.route(
|
|
"/api/kernel/projections/sidebar",
|
|
get(kernel::project_sidebar),
|
|
)
|
|
.route("/api/kernel/subtree", get(kernel::subtree))
|
|
.route("/api/kernel/edges", get(kernel::edges))
|
|
.route("/api/kernel/graph", get(kernel::graph))
|
|
.route("/api/tree/commands", post(tree::tree_command))
|
|
.route(
|
|
"/api/tree/local-folder-watch",
|
|
get(tree::local_folder_watch),
|
|
)
|
|
.route(
|
|
"/api/local-folder/events",
|
|
get(local_folder_events::local_folder_events),
|
|
)
|
|
.route(
|
|
"/api/tree/runtime/reduce",
|
|
post(tree::reduce_tree_shell_runtime),
|
|
)
|
|
.route("/api/bridge/workspace", get(bridge::workspace))
|
|
.route("/api/bridge/request", get(bridge::request))
|
|
.route("/api/bridge/trace", get(bridge::trace))
|
|
.route("/api/tree/events", get(sse::tree_events))
|
|
.route("/api/stream/events", get(sse::events))
|
|
.route("/api/realtime/ws", get(ws::socket))
|
|
.nest(
|
|
&hermes_base_path,
|
|
Router::new()
|
|
.route("/health", get(hermes::health))
|
|
.route("/bridge", post(hermes::bridge_runtime))
|
|
.route(
|
|
"/client/sessions",
|
|
get(hermes_client::list_sessions).post(hermes_client::create_session),
|
|
)
|
|
.route(
|
|
"/client/sessions/{session_id}",
|
|
get(hermes_client::get_session),
|
|
)
|
|
.route(
|
|
"/client/sessions/{session_id}/resume",
|
|
post(hermes_client::resume_session),
|
|
)
|
|
.route("/client/gateway/health", get(hermes_client::gateway_health))
|
|
.route("/client/profiles", get(hermes_client::list_profiles))
|
|
.route(
|
|
"/client/profiles/active",
|
|
put(hermes_client::switch_active_profile),
|
|
)
|
|
.route(
|
|
"/client/profiles/{profile_name}",
|
|
get(hermes_client::get_profile),
|
|
)
|
|
.route(
|
|
"/client/profile-memory",
|
|
get(hermes_client::get_profile_memory).post(hermes_client::save_profile_memory),
|
|
)
|
|
.route("/client/skills", get(hermes_client::list_skills))
|
|
.route("/client/skills/toggle", put(hermes_client::toggle_skill))
|
|
.route("/client/tools/toggle", put(hermes_client::toggle_tool))
|
|
.route("/client/runs", post(hermes_client::create_run))
|
|
.route(
|
|
"/client/sessions/{session_id}/queue/{queue_id}",
|
|
delete(hermes_client::cancel_queued_run),
|
|
)
|
|
.route("/client/events/{run_id}", get(hermes_client::stream_events))
|
|
.route(
|
|
"/client/runs/{run_id}/abort",
|
|
post(hermes_client::abort_run),
|
|
)
|
|
.route("/client/models", get(hermes_client::list_models))
|
|
.route("/client/tools", get(hermes_client::list_tools)),
|
|
)
|
|
.nest(
|
|
"/api/hermes/tools",
|
|
Router::new()
|
|
.route("/mnote/manifest", get(hermes_tools::mnote_manifest))
|
|
.route("/mnote/call", post(hermes_tools::mnote_call))
|
|
.route("/mnote/audit", get(hermes_tools::mnote_audit)),
|
|
);
|
|
|
|
if enable_debug_shell_routes {
|
|
router = router
|
|
.route("/tree", get(tree::tree_shell))
|
|
.route("/document-debug", get(editor::document_editor_shell));
|
|
}
|
|
|
|
router.with_state(state)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::build_router;
|
|
use crate::app::{AppConfig, AppState};
|
|
use axum::body::Body;
|
|
use axum::http::{Request, StatusCode};
|
|
use axum::Router;
|
|
use tower::ServiceExt;
|
|
|
|
fn app(enable_debug_shell_routes: bool) -> Router {
|
|
build_router(AppState::new(AppConfig {
|
|
service_name: "mnote-web".into(),
|
|
service_version: "0.1.0".into(),
|
|
bind_addr: "127.0.0.1:0".into(),
|
|
public_bind_addr: "127.0.0.1:3000".into(),
|
|
legacy_next_base_url: Some("http://127.0.0.1:3100".into()),
|
|
enable_legacy_next_compat: false,
|
|
enable_debug_shell_routes,
|
|
enable_editor_actor: true,
|
|
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 单入口边界",
|
|
);
|
|
}
|
|
}
|
|
}
|