chore: 保存当前架构收口与 bug 修复快照

归档本轮 P0/P1 bug 修复、设计审查迁移、AI selection scope 收口与 stream contract 调整,并保留当前 05 主线迁移起点。
This commit is contained in:
lix-2026
2026-05-18 17:01:35 +08:00
parent 61ee4a38a2
commit a2cb1338c8
953 changed files with 14383 additions and 211845 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+13 -1
View File
@@ -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);
}
}
+96 -14
View File
@@ -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",
+68 -31
View File
@@ -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"));
}
+49 -10
View File
@@ -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());
}
}