chore: 保存当前架构收口与 bug 修复快照
归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -179,14 +179,26 @@ pub fn build_router(state: AppState) -> Router {
|
||||
"/client/sessions",
|
||||
get(hermes_client::list_sessions).post(hermes_client::create_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/search",
|
||||
get(hermes_client::search_sessions),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}",
|
||||
get(hermes_client::get_session),
|
||||
get(hermes_client::get_session).delete(hermes_client::delete_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/resume",
|
||||
post(hermes_client::resume_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/rename",
|
||||
post(hermes_client::rename_session),
|
||||
)
|
||||
.route(
|
||||
"/client/sessions/{session_id}/auto-title",
|
||||
post(hermes_client::auto_title_session),
|
||||
)
|
||||
.route("/client/gateway/health", get(hermes_client::gateway_health))
|
||||
.route("/client/profiles", get(hermes_client::list_profiles))
|
||||
.route(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{doc, ToolCallInput};
|
||||
use crate::hermes_tools::ToolCallInput;
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
@@ -88,6 +88,30 @@ pub async fn block_edit_workflow(
|
||||
} else {
|
||||
context.auth.actor_id.clone()
|
||||
};
|
||||
let allowed_target_block_ids = ai_context
|
||||
.get("allowedTargetBlockIds")
|
||||
.and_then(Value::as_array)
|
||||
.map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::trim)
|
||||
.filter(|item| !item.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let mut markdown_args = json!({
|
||||
"operations": markdown_operations.clone()
|
||||
});
|
||||
if !allowed_target_block_ids.is_empty() {
|
||||
if let Value::Object(map) = &mut markdown_args {
|
||||
map.insert(
|
||||
"allowedTargetBlockIds".into(),
|
||||
json!(allowed_target_block_ids),
|
||||
);
|
||||
}
|
||||
}
|
||||
let edit_input = ToolCallInput {
|
||||
tool_name: "mnote.doc.markdown_edit".into(),
|
||||
workspace_id: Some(workspace_id.clone()),
|
||||
@@ -101,12 +125,12 @@ pub async fn block_edit_workflow(
|
||||
idempotency_key: Some(format!("page_ai_fast_edit_{}", context.trace.request_id)),
|
||||
dry_run: Some(false),
|
||||
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
|
||||
args: Some(json!({
|
||||
"operations": markdown_operations
|
||||
})),
|
||||
args: Some(markdown_args),
|
||||
};
|
||||
let apply_started = Instant::now();
|
||||
let apply_result = doc::doc_markdown_edit(&state, &context, &edit_input).await?;
|
||||
let tool_response =
|
||||
crate::routes::hermes_tools::execute_mnote_tool_call(&state, &context, edit_input).await?;
|
||||
let apply_result = tool_response.get("result").cloned().unwrap_or(Value::Null);
|
||||
let apply_ms = apply_started.elapsed().as_millis();
|
||||
info!(
|
||||
trace_id = %trace_id,
|
||||
@@ -128,6 +152,7 @@ pub async fn block_edit_workflow(
|
||||
"traceId": trace_id,
|
||||
"operations": markdown_operations,
|
||||
"applyResult": apply_result,
|
||||
"toolExecution": tool_response,
|
||||
"message": "已通过页面 markdown 编辑快路径完成写入。",
|
||||
"timingsMs": {
|
||||
"total": started.elapsed().as_millis(),
|
||||
@@ -540,6 +565,124 @@ fn yaml_path_value(content: &str, path: &[&str]) -> Option<String> {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{direct_block_edit_operations, extract_operations_from_model_text};
|
||||
use crate::app::{build_app, AppConfig, AppState};
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use axum::routing::post;
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn env_lock() -> &'static Mutex<()> {
|
||||
crate::test_support::hermes_env_lock()
|
||||
}
|
||||
|
||||
fn app() -> axum::Router {
|
||||
build_app(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: true,
|
||||
enable_debug_shell_routes: false,
|
||||
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: true,
|
||||
query_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:getMeta": {
|
||||
"id": "doc_1",
|
||||
"workspace_id": "ws_demo",
|
||||
"title": "服务端页面",
|
||||
"can_edit": true,
|
||||
"wide_layout": false,
|
||||
"use_small_text": false,
|
||||
"show_toc": true,
|
||||
"block_count": 2
|
||||
},
|
||||
"documents:getContent": {
|
||||
"title": "服务端页面",
|
||||
"content": [
|
||||
{
|
||||
"id": "p_1",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第一段" }]
|
||||
},
|
||||
{
|
||||
"id": "p_2",
|
||||
"type": "paragraph",
|
||||
"content": [{ "type": "text", "text": "第二段" }]
|
||||
}
|
||||
],
|
||||
"revision": 7,
|
||||
"conflict_detection_key": "doc_1:7"
|
||||
}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
mutation_fixtures_json: Some(
|
||||
r#"{
|
||||
"documents:updateContent": {"ok": true, "revision": 8, "conflict_detection_key": "doc_1:8"},
|
||||
"bridgeLogs:recordCommandLog": {"ok": true, "id": "cmd_log_fixture"},
|
||||
"bridgeLogs:recordDomainEvent": {"ok": true, "id": "event_fixture"}
|
||||
}"#
|
||||
.into(),
|
||||
),
|
||||
dev_user_id: "dev-user".into(),
|
||||
dev_user_name: "开发用户".into(),
|
||||
dev_user_email: "dev@mnote.local".into(),
|
||||
}))
|
||||
}
|
||||
|
||||
async fn spawn_mock_model_server() -> String {
|
||||
async fn completions() -> Json<Value> {
|
||||
Json(json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "{\"operations\":[{\"search\":\"第二段\",\"replace\":\"测试123\"}],\"summary\":\"ok\"}"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind mock model");
|
||||
let addr = listener.local_addr().expect("mock model addr");
|
||||
let server = Router::new().route("/chat/completions", post(completions));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, server).await;
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
async fn spawn_out_of_scope_mock_model_server() -> String {
|
||||
async fn completions() -> Json<Value> {
|
||||
Json(json!({
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "{\"operations\":[{\"search\":\"第一段\",\"replace\":\"越权修改\"}],\"summary\":\"out_of_scope\"}"
|
||||
}
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("bind out-of-scope mock model");
|
||||
let addr = listener.local_addr().expect("mock model addr");
|
||||
let server = Router::new().route("/chat/completions", post(completions));
|
||||
tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, server).await;
|
||||
});
|
||||
format!("http://{addr}")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_operations_from_fenced_model_json() {
|
||||
@@ -570,4 +713,137 @@ mod tests {
|
||||
assert_eq!(operations[2]["op"], "delete");
|
||||
assert_eq!(operations[2]["matchText"], "第三段");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_edit_workflow_respects_disabled_markdown_edit_tool() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-disabled-tool-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
format!(
|
||||
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\nmnote:\n tools:\n disabled:\n - mnote.doc.markdown_edit\n"
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/page-ai/block-edit-workflow")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"message": "把第二段改成测试123",
|
||||
"profile": "mnoteai",
|
||||
"sessionId": "sess_page_ai_disabled",
|
||||
"runId": "run_page_ai_disabled",
|
||||
"traceId": "trace_page_ai_disabled",
|
||||
"pageContext": {
|
||||
"aiContext": {
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"pageText": "第一段\n\n第二段",
|
||||
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
|
||||
"contextBlocks": [
|
||||
{"blockId": "p_1", "text": "第一段"},
|
||||
{"blockId": "p_2", "text": "第二段"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "mnote_tool_disabled");
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn block_edit_workflow_forwards_allowed_target_blocks_to_markdown_edit() {
|
||||
let _guard = env_lock().lock().expect("env lock");
|
||||
let base_url = spawn_out_of_scope_mock_model_server().await;
|
||||
let hermes_home = std::env::temp_dir().join(format!(
|
||||
"mnote-page-ai-workflow-selection-scope-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
let profile_dir = hermes_home.join("profiles").join("mnoteai");
|
||||
fs::create_dir_all(&profile_dir).expect("profile dir");
|
||||
fs::write(
|
||||
profile_dir.join("config.yaml"),
|
||||
format!(
|
||||
"model:\n provider: mock\n default: mock-model\n base_url: {base_url}\n api_key: test-key\n"
|
||||
),
|
||||
)
|
||||
.expect("profile config");
|
||||
std::env::set_var("HERMES_HOME", &hermes_home);
|
||||
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/page-ai/block-edit-workflow")
|
||||
.header("content-type", "application/json")
|
||||
.header("x-mnote-actor-id", "user_1")
|
||||
.body(Body::from(
|
||||
json!({
|
||||
"workspaceId": "ws_demo",
|
||||
"documentId": "doc_1",
|
||||
"message": "把选中段落改成测试123",
|
||||
"profile": "mnoteai",
|
||||
"sessionId": "sess_page_ai_scope",
|
||||
"runId": "run_page_ai_scope",
|
||||
"traceId": "trace_page_ai_scope",
|
||||
"pageContext": {
|
||||
"aiContext": {
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"allowedTargetBlockIds": ["p_2"],
|
||||
"pageText": "第一段\n\n第二段",
|
||||
"pageXml": "<page><block id=\"p_1\">第一段</block><block id=\"p_2\">第二段</block></page>",
|
||||
"contextBlocks": [
|
||||
{"blockId": "p_1", "text": "第一段"},
|
||||
{"blockId": "p_2", "text": "第二段"}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string(),
|
||||
))
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
let body = to_bytes(response.into_body(), usize::MAX)
|
||||
.await
|
||||
.expect("body");
|
||||
let payload: Value = serde_json::from_slice(&body).expect("json");
|
||||
assert_eq!(payload["code"], "mnote_markdown_edit_target_out_of_scope");
|
||||
|
||||
std::env::remove_var("HERMES_HOME");
|
||||
let _ = fs::remove_dir_all(&hermes_home);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,9 @@ use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{
|
||||
build_stream_delta_payload, load_stream_overview, load_stream_snapshot,
|
||||
read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind, StreamChangeKind,
|
||||
StreamSnapshotQuery,
|
||||
build_stream_delta_payload, build_stream_push_delta_hint, load_stream_overview,
|
||||
load_stream_snapshot, read_stream_cursor_from_payload, resolve_stream_change, with_stream_kind,
|
||||
StreamChangeKind, StreamSnapshotQuery,
|
||||
};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::http::{HeaderMap, HeaderName, HeaderValue};
|
||||
@@ -94,15 +94,11 @@ async fn events_with_stream_delta(
|
||||
if let Some(ref mut rx) = state.stream_delta_rx {
|
||||
match rx.try_recv() {
|
||||
Ok(payload) => {
|
||||
let hint = json!({
|
||||
"kind": "delta",
|
||||
"hint": "command_committed",
|
||||
"commandName": payload.get("commandName"),
|
||||
"commandId": payload.get("commandId"),
|
||||
"workspaceId": payload.get("workspaceId"),
|
||||
"requestId": payload.get("requestId"),
|
||||
"traceId": payload.get("traceId"),
|
||||
});
|
||||
let hint = build_stream_push_delta_hint(
|
||||
&payload,
|
||||
&state.context.trace.request_id,
|
||||
&state.context.trace.trace_id,
|
||||
);
|
||||
return Some((Ok(stream_event("delta", &hint)), Some(state)));
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Empty) => {}
|
||||
@@ -113,9 +109,68 @@ async fn events_with_stream_delta(
|
||||
}
|
||||
}
|
||||
|
||||
// If push-driven and we already checked both broadcasts, brief sleep then re-check
|
||||
// If push-driven and broadcasts are quiet, polling remains the safety net.
|
||||
if state.stream_delta_rx.is_some() {
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
if let Some(max_polls) = max_polls {
|
||||
if state.polls >= max_polls {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
state.polls += 1;
|
||||
sleep(Duration::from_millis(poll_ms)).await;
|
||||
|
||||
let poll_query = live_poll_query(&state.query);
|
||||
let Ok((workspace_id, overview)) =
|
||||
load_stream_overview(state.app_state.config(), &state.context, &poll_query)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if let Some(change) =
|
||||
resolve_stream_change(&overview, state.current_cursor.as_deref())
|
||||
{
|
||||
state.current_cursor = change.cursor.clone();
|
||||
match change.kind {
|
||||
StreamChangeKind::Delta => {
|
||||
let Ok(payload) = build_stream_delta_payload(
|
||||
state.app_state.config(),
|
||||
&state.context,
|
||||
&poll_query,
|
||||
&workspace_id,
|
||||
&overview,
|
||||
change.cursor,
|
||||
change
|
||||
.delta
|
||||
.unwrap_or_else(|| serde_json::json!({ "op": "noop" })),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
return Some((Ok(stream_event("delta", &payload)), Some(state)));
|
||||
}
|
||||
StreamChangeKind::Resync => {
|
||||
let Ok(snapshot_payload) = load_stream_snapshot(
|
||||
state.app_state.config(),
|
||||
&state.context,
|
||||
&poll_query,
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
state.current_cursor =
|
||||
read_stream_cursor_from_payload(&snapshot_payload);
|
||||
return Some((
|
||||
Ok(stream_event(
|
||||
"resync",
|
||||
&with_stream_kind(&snapshot_payload, "resync"),
|
||||
)),
|
||||
Some(state),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Some((Ok(stream_event("heartbeat", &json!({}))), Some(state)));
|
||||
}
|
||||
|
||||
@@ -289,6 +344,8 @@ mod tests {
|
||||
use crate::routes::stream_support::StreamSnapshotQuery;
|
||||
use axum::body::{to_bytes, Body};
|
||||
use axum::http::{Request, StatusCode};
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use tower::util::ServiceExt;
|
||||
|
||||
fn app() -> axum::Router {
|
||||
@@ -395,6 +452,31 @@ mod tests {
|
||||
assert!(text.contains("\"revision\""));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn tree_events_push_mode_honors_polling_safety_net_max_polls() {
|
||||
let response = app()
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/tree/events?workspaceId=ws_demo&maxPolls=1&pollMs=1")
|
||||
.body(Body::empty())
|
||||
.expect("request"),
|
||||
)
|
||||
.await
|
||||
.expect("response");
|
||||
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = timeout(
|
||||
Duration::from_secs(2),
|
||||
to_bytes(response.into_body(), usize::MAX),
|
||||
)
|
||||
.await
|
||||
.expect("push SSE stream should stop after maxPolls")
|
||||
.expect("body");
|
||||
let text = String::from_utf8(body.to_vec()).expect("utf8");
|
||||
assert!(text.contains("event: snapshot") || text.contains("event:snapshot"));
|
||||
assert!(text.contains("event: heartbeat") || text.contains("event:heartbeat"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn live_poll_query_drops_bridge_pagination_cursor() {
|
||||
let query = StreamSnapshotQuery {
|
||||
|
||||
@@ -574,6 +574,18 @@ pub fn with_stream_kind(payload: &Value, kind: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn build_stream_push_delta_hint(payload: &Value, request_id: &str, trace_id: &str) -> Value {
|
||||
json!({
|
||||
"kind": "delta",
|
||||
"hint": "command_committed",
|
||||
"commandName": payload.get("commandName"),
|
||||
"commandId": payload.get("commandId"),
|
||||
"workspaceId": payload.get("workspaceId"),
|
||||
"requestId": request_id,
|
||||
"traceId": trace_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn build_stream_delta_payload(
|
||||
config: &AppConfig,
|
||||
context: &RequestContext,
|
||||
@@ -1042,7 +1054,7 @@ mod tests {
|
||||
"id": "clog_2",
|
||||
"command_id": "cmd_2",
|
||||
"created_at": "2026-04-25T10:00:02Z",
|
||||
"command_name": "tree.resource.delete",
|
||||
"command_name": "tree.resource.archive",
|
||||
"payload": {
|
||||
"streamDelta": {
|
||||
"op": "remove_asset",
|
||||
|
||||
@@ -2018,12 +2018,17 @@ fn build_tree_shell_html(
|
||||
|
||||
const getFileTreeRowDocumentId = (item) => {
|
||||
if (!item) return "";
|
||||
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
|
||||
if (item.rowKind === "document" || item.rowKind === "markdown") return item.nodeId;
|
||||
if (item.rowKind === "index") return item.nodeId.replace(/^index:/, "");
|
||||
return "";
|
||||
};
|
||||
|
||||
const getFileTreeRowOwnerDocumentId = (item) => {
|
||||
if (!item) return "";
|
||||
if (item.resourceMeta?.documentId) return item.resourceMeta.documentId;
|
||||
return getFileTreeRowDocumentId(item);
|
||||
};
|
||||
|
||||
const getFileTreeRowAssetId = (item) => {
|
||||
if (!item) return "";
|
||||
if (item.resourceMeta?.assetId) return item.resourceMeta.assetId;
|
||||
@@ -2084,6 +2089,7 @@ fn build_tree_shell_html(
|
||||
rowKind: "root",
|
||||
nodeId: null,
|
||||
documentId: null,
|
||||
ownerDocumentId: null,
|
||||
assetId: null,
|
||||
};
|
||||
}
|
||||
@@ -2092,6 +2098,7 @@ fn build_tree_shell_html(
|
||||
rowKind: normalizeText(row.dataset.rowKind, "document"),
|
||||
nodeId: normalizeText(row.dataset.nodeId) || null,
|
||||
documentId: normalizeText(row.dataset.documentId) || null,
|
||||
ownerDocumentId: normalizeText(row.dataset.ownerDocumentId) || null,
|
||||
assetId: normalizeText(row.dataset.assetId) || null,
|
||||
};
|
||||
};
|
||||
@@ -2934,13 +2941,14 @@ fn build_tree_shell_html(
|
||||
const targetItem = targetRowId ? fileTreeRowById.get(targetRowId) : null;
|
||||
const target = targetItem
|
||||
? {
|
||||
rowId: targetItem.rowId,
|
||||
rowKind: targetItem.rowKind,
|
||||
nodeId: targetItem.nodeId,
|
||||
documentId: getFileTreeRowDocumentId(targetItem) || null,
|
||||
assetId: getFileTreeRowAssetId(targetItem) || null,
|
||||
}
|
||||
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, assetId: null };
|
||||
rowId: targetItem.rowId,
|
||||
rowKind: targetItem.rowKind,
|
||||
nodeId: targetItem.nodeId,
|
||||
documentId: getFileTreeRowDocumentId(targetItem) || null,
|
||||
ownerDocumentId: getFileTreeRowOwnerDocumentId(targetItem) || null,
|
||||
assetId: getFileTreeRowAssetId(targetItem) || null,
|
||||
}
|
||||
: { rowId: null, rowKind: "root", nodeId: null, documentId: null, ownerDocumentId: null, assetId: null };
|
||||
if (sourceKind === "local_folder") {
|
||||
const pasted = await executeLocalFileTreeInternalDrop(
|
||||
target,
|
||||
@@ -3030,7 +3038,12 @@ fn build_tree_shell_html(
|
||||
if (event.key === "F2") {
|
||||
event.preventDefault();
|
||||
const rowId = item?.rowId || fileTreeFocusedRowId;
|
||||
if (rowId) beginInlineRename("filetree", rowId);
|
||||
const renameItem = rowId ? fileTreeRowById.get(rowId) : null;
|
||||
if (rowId && getFileTreeRowDocumentId(renameItem)) {
|
||||
beginInlineRename("filetree", rowId);
|
||||
} else {
|
||||
setLastAction("当前资源暂不支持重命名", "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.key === "ArrowDown") {
|
||||
@@ -3057,7 +3070,11 @@ fn build_tree_shell_html(
|
||||
event.preventDefault();
|
||||
if (item) {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
if (documentId) handleNavigate(documentId);
|
||||
if (documentId) {
|
||||
handleNavigate(documentId);
|
||||
} else {
|
||||
openHydratedFileTreeItem(item);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -3219,7 +3236,7 @@ fn build_tree_shell_html(
|
||||
});
|
||||
}
|
||||
normalizedItems.slice().forEach((item) => {
|
||||
const itemDocumentId = getFileTreeRowDocumentId(item) || item.resourceMeta?.documentId || item.nodeId;
|
||||
const itemDocumentId = getFileTreeRowOwnerDocumentId(item) || item.nodeId;
|
||||
if (removedNodeIds.has(item.nodeId) || itemDocumentId === normalizedDocumentId) {
|
||||
removeTreeItemEverywhere(item);
|
||||
changed = true;
|
||||
@@ -4149,8 +4166,7 @@ fn build_tree_shell_html(
|
||||
rowKind === "markdown" ||
|
||||
rowKind === "document" ||
|
||||
rowKind === "folder" ||
|
||||
rowKind === "index" ||
|
||||
(localSource && rowKind === "asset");
|
||||
rowKind === "index";
|
||||
const canCopyCut =
|
||||
rowKind === "markdown" ||
|
||||
rowKind === "document" ||
|
||||
@@ -4282,7 +4298,9 @@ fn build_tree_shell_html(
|
||||
return;
|
||||
}
|
||||
if (kind === "rename") {
|
||||
if (target.rowId) beginInlineRename("filetree", target.rowId);
|
||||
if (target.rowId && getFileTreeRowDocumentId(item)) {
|
||||
beginInlineRename("filetree", target.rowId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (kind === "copy" || kind === "cut") {
|
||||
@@ -4639,8 +4657,14 @@ fn build_tree_shell_html(
|
||||
const id = inlineRenameState.id;
|
||||
const item = renameMode === "filetree" ? fileTreeRowById.get(id) : itemById.get(id);
|
||||
const documentId = renameMode === "filetree"
|
||||
? getFileTreeRowDocumentId(item) || item?.nodeId || id
|
||||
? getFileTreeRowDocumentId(item)
|
||||
: id;
|
||||
if (!documentId) {
|
||||
inlineRenameState = { mode: null, id: null, committing: false };
|
||||
setLastAction("当前资源暂不支持重命名", "error");
|
||||
renderTree();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await sendCommand({
|
||||
action: "rename",
|
||||
@@ -4953,6 +4977,7 @@ fn build_tree_shell_html(
|
||||
|
||||
const openHydratedFileTreeItem = (item) => {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
||||
const assetId = getFileTreeRowAssetId(item);
|
||||
if (item.rowKind === "document" || item.rowKind === "index") {
|
||||
handleNavigate(documentId || item.nodeId);
|
||||
@@ -4960,12 +4985,12 @@ fn build_tree_shell_html(
|
||||
}
|
||||
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
||||
postToHost("tree.asset.open", {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
target: { documentId: documentId || null },
|
||||
target: { documentId: ownerDocumentId || null },
|
||||
payload: {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
@@ -5122,6 +5147,7 @@ fn build_tree_shell_html(
|
||||
const bindFileTreeRowEvents = (row, item) => {
|
||||
if (!(row instanceof HTMLElement) || !item) return;
|
||||
const documentId = getFileTreeRowDocumentId(item) || null;
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
||||
const assetId = getFileTreeRowAssetId(item) || null;
|
||||
row.dataset.active = String(
|
||||
item.rowKind === "document" && documentId === currentActiveDocumentId
|
||||
@@ -5130,6 +5156,7 @@ fn build_tree_shell_html(
|
||||
row.dataset.rowId = item.rowId;
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
row.dataset.documentId = documentId || "";
|
||||
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
||||
row.dataset.assetId = assetId || "";
|
||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||
@@ -5577,6 +5604,7 @@ fn build_tree_shell_html(
|
||||
|
||||
const openFileTreeItem = (item) => {
|
||||
const documentId = getFileTreeRowDocumentId(item);
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item);
|
||||
const assetId = getFileTreeRowAssetId(item);
|
||||
if (item.rowKind === "document" || item.rowKind === "index" || item.rowKind === "markdown") {
|
||||
handleNavigate(documentId || item.nodeId);
|
||||
@@ -5584,12 +5612,12 @@ fn build_tree_shell_html(
|
||||
}
|
||||
setLastAction(`准备打开资源 ${assetId || item.rowId}`);
|
||||
postToHost("tree.asset.open", {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
objectIdentity: item.resourceMeta?.objectIdentity || null,
|
||||
target: { documentId: documentId || null },
|
||||
target: { documentId: ownerDocumentId || null },
|
||||
payload: {
|
||||
documentId: documentId || null,
|
||||
documentId: ownerDocumentId || null,
|
||||
assetId: assetId || null,
|
||||
rowId: item.rowId,
|
||||
rowKind: item.rowKind,
|
||||
@@ -5649,6 +5677,7 @@ fn build_tree_shell_html(
|
||||
const children = getSiblings(item.nodeId);
|
||||
const hasBranches = canExpandFileTreeRow(item) && children.length > 0;
|
||||
const documentId = getFileTreeRowDocumentId(item) || null;
|
||||
const ownerDocumentId = getFileTreeRowOwnerDocumentId(item) || null;
|
||||
const assetId = getFileTreeRowAssetId(item) || null;
|
||||
|
||||
const row = document.createElement("div");
|
||||
@@ -5661,6 +5690,7 @@ fn build_tree_shell_html(
|
||||
row.dataset.rowId = item.rowId;
|
||||
row.dataset.rowKind = item.rowKind;
|
||||
row.dataset.documentId = documentId || "";
|
||||
row.dataset.ownerDocumentId = ownerDocumentId || "";
|
||||
row.dataset.assetId = assetId || "";
|
||||
row.dataset.objectIdentity = item.resourceMeta?.objectIdentity
|
||||
? JSON.stringify(item.resourceMeta.objectIdentity)
|
||||
@@ -5769,15 +5799,17 @@ fn build_tree_shell_html(
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "tree-actions";
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.edit,
|
||||
"filetree-action-rename",
|
||||
`重命名 ${item.title}`,
|
||||
() => beginInlineRename("filetree", item.rowId),
|
||||
false,
|
||||
),
|
||||
);
|
||||
if (getFileTreeRowDocumentId(item)) {
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.edit,
|
||||
"filetree-action-rename",
|
||||
`重命名 ${item.title}`,
|
||||
() => beginInlineRename("filetree", item.rowId),
|
||||
false,
|
||||
),
|
||||
);
|
||||
}
|
||||
actions.appendChild(
|
||||
createActionButton(
|
||||
ICONS.more,
|
||||
@@ -7143,6 +7175,11 @@ mod tests {
|
||||
assert!(html.contains("tree.filetree.external-drop"));
|
||||
assert!(html.contains("\"rowKind\":\"asset_folder\""));
|
||||
assert!(html.contains("\"resourceMeta\""));
|
||||
assert!(html.contains("const getFileTreeRowOwnerDocumentId = (item) => {"));
|
||||
assert!(html.contains("row.dataset.ownerDocumentId = ownerDocumentId || \"\";"));
|
||||
assert!(html.contains("if (rowId && getFileTreeRowDocumentId(renameItem))"));
|
||||
assert!(html.contains("if (getFileTreeRowDocumentId(item))"));
|
||||
assert!(html.contains("documentId: ownerDocumentId || null"));
|
||||
assert!(html.contains("dragover"));
|
||||
assert!(html.contains("hydrateInitialFileTree"));
|
||||
assert!(html.contains("const usedRustInitialRenderer = hydrateInitialRenderer();"));
|
||||
@@ -7897,7 +7934,7 @@ mod tests {
|
||||
payload["result"]["documentId"],
|
||||
Value::String("page_child".into())
|
||||
);
|
||||
assert_eq!(payload["result"]["sortOrder"], Value::from(1));
|
||||
assert_eq!(payload["result"]["sortOrder"], Value::Null);
|
||||
assert_eq!(
|
||||
payload["result"]["execution"]["deletedCount"],
|
||||
Value::from(1)
|
||||
|
||||
@@ -1210,6 +1210,18 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
titleEndpoint: '/api/documents/title',
|
||||
editorHostKind: 'leptos_tiptap_island',
|
||||
});
|
||||
const syncPageAggregateScript = (session, aggregate) => {
|
||||
if (!session || !aggregate) return;
|
||||
const scriptId = session.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__';
|
||||
const node = document.getElementById(scriptId);
|
||||
if (!node) return;
|
||||
try {
|
||||
node.textContent = JSON.stringify(aggregate);
|
||||
node.setAttribute('data-mnote-page-aggregate-synced-at', String(Date.now()));
|
||||
} catch (error) {
|
||||
console.warn('mnote Page Aggregate script 同步失败', error);
|
||||
}
|
||||
};
|
||||
|
||||
const documentSessionRegistry = new Map();
|
||||
const localFolderEventRegistry = new Map();
|
||||
@@ -1622,6 +1634,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
}
|
||||
const nextRevision = Number.isInteger(nextBody.revision) ? nextBody.revision : revisionFromConflictKey(nextConflictKey);
|
||||
session.latestAggregate = nextAggregate;
|
||||
syncPageAggregateScript(session, nextAggregate);
|
||||
session.title = nextAggregate?.head?.title || session.title;
|
||||
session.currentTiptapDocument = nextTiptapDocument;
|
||||
session.currentSerialized = nextSerialized;
|
||||
@@ -1891,6 +1904,7 @@ pub(crate) fn render_editor_island_adapter_script() -> &'static str {
|
||||
sourceKind,
|
||||
rootUri: runtimeDescriptor.bootstrap.rootUri,
|
||||
saveEndpoint: runtimeDescriptor.bootstrap.saveEndpoint || '/api/documents/save',
|
||||
pageAggregateScriptId: runtimeDescriptor.bootstrap.pageAggregateScriptId || '__MNOTE_PAGE_AGGREGATE__',
|
||||
latestAggregate: runtimeDescriptor.aggregate,
|
||||
title: runtimeDescriptor.aggregate.head?.title || '无标题',
|
||||
currentTiptapDocument: tiptapDocument,
|
||||
@@ -3082,6 +3096,7 @@ mod tests {
|
||||
assert!(html.contains("mnote.tree_live_bootstrap.v1"));
|
||||
assert!(html.contains("/api/tree/events"));
|
||||
assert!(html.contains("data-mnote-tree-live-transport"));
|
||||
assert!(html.contains("syncPageAggregateScript(session, nextAggregate);"));
|
||||
assert!(!html.contains("mnote-web-document-shell"));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::routes::stream_support::{load_stream_snapshot, StreamSnapshotQuery};
|
||||
use crate::routes::stream_support::{
|
||||
build_stream_push_delta_hint, load_stream_snapshot, StreamSnapshotQuery,
|
||||
};
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Extension, Query, State};
|
||||
use axum::response::Response;
|
||||
@@ -41,14 +43,15 @@ async fn handle_socket(
|
||||
delta_result = stream_delta_rx.recv() => {
|
||||
match delta_result {
|
||||
Ok(delta) => {
|
||||
let notify = json!({
|
||||
"kind": "delta",
|
||||
"data": delta,
|
||||
"requestId": context.trace.request_id,
|
||||
"traceId": context.trace.trace_id,
|
||||
"workspaceId": delta.get("workspaceId").and_then(Value::as_str).unwrap_or(""),
|
||||
});
|
||||
if socket.send(Message::Text(notify.to_string().into())).await.is_err() {
|
||||
if socket
|
||||
.send(serialize_delta_message(
|
||||
&delta,
|
||||
&context.trace.request_id,
|
||||
&context.trace.trace_id,
|
||||
))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -130,6 +133,14 @@ fn serialize_snapshot_message(payload: &Value) -> Message {
|
||||
Message::Text(payload.to_string().into())
|
||||
}
|
||||
|
||||
fn serialize_delta_message(payload: &Value, request_id: &str, trace_id: &str) -> Message {
|
||||
Message::Text(
|
||||
build_stream_push_delta_hint(payload, request_id, trace_id)
|
||||
.to_string()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
fn serialize_resync_message(payload: &Value) -> Message {
|
||||
Message::Text(
|
||||
json!({
|
||||
@@ -156,7 +167,10 @@ fn is_resync_request(text: &str) -> bool {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{is_resync_request, serialize_resync_message, serialize_snapshot_message};
|
||||
use super::{
|
||||
is_resync_request, serialize_delta_message, serialize_resync_message,
|
||||
serialize_snapshot_message,
|
||||
};
|
||||
use axum::extract::ws::Message;
|
||||
use serde_json::json;
|
||||
|
||||
@@ -183,4 +197,29 @@ mod tests {
|
||||
};
|
||||
assert!(resync_text.contains("\"kind\":\"resync\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ws_delta_serializer_uses_stream_hint_contract() {
|
||||
let delta = json!({
|
||||
"kind": "command_committed",
|
||||
"commandName": "tree.rename",
|
||||
"commandId": "cmd_1",
|
||||
"workspaceId": "ws_demo",
|
||||
"requestId": "req_command",
|
||||
"traceId": "trace_command"
|
||||
});
|
||||
let Message::Text(delta_text) = serialize_delta_message(&delta, "req_ws", "trace_ws")
|
||||
else {
|
||||
panic!("delta message 应该是文本帧");
|
||||
};
|
||||
let payload: serde_json::Value = serde_json::from_str(&delta_text).expect("delta json");
|
||||
assert_eq!(payload["kind"], "delta");
|
||||
assert_eq!(payload["hint"], "command_committed");
|
||||
assert_eq!(payload["commandName"], "tree.rename");
|
||||
assert_eq!(payload["commandId"], "cmd_1");
|
||||
assert_eq!(payload["workspaceId"], "ws_demo");
|
||||
assert_eq!(payload["requestId"], "req_ws");
|
||||
assert_eq!(payload["traceId"], "trace_ws");
|
||||
assert!(payload.get("data").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user