use crate::app::AppState; use crate::context::RequestContext; use crate::error::WebError; use crate::hermes_tools::ToolCallInput; use crate::routes::command_support::execute_runtime_command_via_legacy_cloud_with_artifacts; use crate::routes::web_shell::build_page_aggregate_snapshot; use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, }; use serde_json::{json, Value}; pub async fn page_get( state: &AppState, context: &RequestContext, input: &ToolCallInput, ) -> Result { let document_id = input.effective_document_id().ok_or_else(|| { WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.get 缺少 documentId") .with_context(context) })?; crate::hermes_tools::doc::ensure_ai_scope_resource_allowed(context, input, &document_id)?; let workspace_id = input.effective_workspace_id(); let source_kind = input.effective_source_kind(); let root_uri = input.effective_root_uri(); let aggregate = build_page_aggregate_snapshot( state, context, &document_id, workspace_id.as_deref(), source_kind.as_deref(), root_uri.as_deref(), ) .await?; let aggregate_value = serde_json::to_value(&aggregate).map_err(|error| WebError::internal(error.to_string()))?; let title = aggregate_value .pointer("/head/title") .and_then(Value::as_str) .unwrap_or("无标题"); let content = aggregate_value .pointer("/body/content") .cloned() .unwrap_or(Value::Null); let page_options = aggregate_value .pointer("/layout/pageOptions") .or_else(|| aggregate_value.pointer("/layout/page_options")) .cloned() .unwrap_or_else(|| json!({})); let blocks = aggregate_value .pointer("/body/blockDocument/blocks") .and_then(Value::as_array) .cloned() .unwrap_or_else(|| summarize_blocks(&content)); let body_summary = blocks .iter() .filter_map(|block| block.get("text").and_then(Value::as_str)) .filter(|text| !text.trim().is_empty()) .take(8) .collect::>() .join("\n"); Ok(json!({ "documentId": document_id, "workspaceId": workspace_id, "title": title, "bodySummary": body_summary, "pageOptions": page_options, "blocks": blocks, "aggregateSchema": aggregate_value.get("schema").cloned().unwrap_or(Value::Null), "aggregateSource": aggregate_value.get("source").cloned().unwrap_or(Value::Null) })) } fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> { crate::hermes_tools::ensure_write_authorized(context, input) } pub async fn page_save( state: &AppState, context: &RequestContext, input: &ToolCallInput, ) -> Result { let content = input.arg_value("content").ok_or_else(|| { WebError::bad_request_code("mnote_tool_bad_request", "mnote.page.save 缺少 content") .with_context(context) })?; let mode = input .arg_string("mode") .unwrap_or_else(|| "replace".into()) .trim() .to_ascii_lowercase(); let save_content = resolve_page_save_content(state, context, input, content, &mode).await?; page_command( state, context, input, "page.body.save", json!({ "content": save_content, "mode": mode }), None, ) .await } pub async fn update_title( state: &AppState, context: &RequestContext, input: &ToolCallInput, ) -> Result { let title = input.arg_string("title").ok_or_else(|| { WebError::bad_request_code( "mnote_tool_bad_request", "mnote.page.update_title 缺少 title", ) .with_context(context) })?; page_command( state, context, input, "page.head.updateTitle", json!({ "title": title }), None, ) .await } pub async fn update_options( state: &AppState, context: &RequestContext, input: &ToolCallInput, ) -> Result { let options = input.arg_value("options").ok_or_else(|| { WebError::bad_request_code( "mnote_tool_bad_request", "mnote.page.update_options 缺少 options", ) .with_context(context) })?; let (wired_options, ignored_options, warnings) = if input.effective_source_kind().as_deref() == Some("local_folder") { filter_local_ui_preference_options(options) } else { filter_wired_page_options(options) }; page_command( state, context, input, "page.layout.updateOptions", json!({ "options": wired_options }), Some(json!({ "ignoredOptions": ignored_options, "warnings": warnings })), ) .await } async fn page_command( state: &AppState, context: &RequestContext, input: &ToolCallInput, command_name: &str, payload_patch: Value, result_extra: Option, ) -> Result { ensure_write_contract(context, input)?; let document_id = input.effective_document_id().ok_or_else(|| { WebError::bad_request_code("mnote_tool_bad_request", "页面写工具缺少 documentId") .with_context(context) })?; let workspace_id = input.effective_workspace_id(); let command_id = format!( "{}_{}", command_name.replace('.', "_"), context.trace.request_id ); let idempotency_key = input.idempotency_key_or_default(&command_id); let payload = merge_page_payload(&document_id, workspace_id.as_deref(), payload_patch); if input.dry_run.unwrap_or(false) { let mut result = json!({ "dryRun": true, "commandName": command_name, "commandId": command_id, "documentId": document_id, "workspaceId": workspace_id, "diff": [{"op": command_name, "payload": payload}] }); merge_result_extra(&mut result, result_extra); return Ok(result); } if input.effective_source_kind().as_deref() == Some("local_folder") { let root_uri = input.effective_root_uri().ok_or_else(|| { WebError::bad_request_code("local_folder_root_required", "缺少本地文件夹 rootUri") .with_context(context) })?; crate::routes::ensure_local_workspace_access(context, &root_uri) .map_err(|error| error.with_context(context))?; let local_result = match command_name { "page.body.save" => { let content = payload.get("content").cloned().unwrap_or(Value::Null); crate::routes::write_local_markdown_page_body( &core_protocol::PageBodyWriteRequest { document_id: document_id.clone(), workspace_id: workspace_id.clone().unwrap_or_default(), source_kind: core_protocol::WorkspaceSourceKind::LocalFolder, root_uri: root_uri.clone(), expected_file_version: input.arg_string("expectedFileVersion"), write_intent_id: Some("intent:ai:mnote.page.save".into()), save_operation_id: None, base_content_hash: input.arg_string("baseContentHash"), content_format: "editorBlocks".into(), content, editor_source: Some("mnote.page.save".into()), }, Some(&state.buffer_store), )? } "page.head.updateTitle" => { let title = payload .get("title") .and_then(Value::as_str) .ok_or_else(|| { WebError::bad_request_code( "mnote_tool_bad_request", "mnote.page.update_title 缺少 title", ) .with_context(context) })?; crate::routes::update_local_markdown_title(&root_uri, &document_id, title)? } "page.layout.updateOptions" => { let options = payload.get("options").cloned().ok_or_else(|| { WebError::bad_request_code( "mnote_tool_bad_request", "mnote.page.update_options 缺少 options", ) .with_context(context) })?; crate::routes::ui_preferences::update_page_preferences_from_value( state, context, context.auth.actor_id.trim(), workspace_id.as_deref().unwrap_or_default(), "local_folder", &root_uri, &document_id, &options, )? } _ => { return Err(WebError::bad_request_code( "mnote_tool_bad_request", format!("local source 暂不支持页面命令 {command_name}"), ) .with_context(context)); } }; let mut result = json!({ "dryRun": false, "source": "local_folder", "commandName": if command_name == "page.body.save" { "page.body.write" } else { command_name }, "commandId": command_id, "documentId": document_id, "workspaceId": workspace_id, "result": local_result }); merge_result_extra(&mut result, result_extra); return Ok(result); } let command = RuntimeCommandEnvelopeWire { name: command_name.into(), command_id: command_id.clone(), idempotency_key: Some(idempotency_key), actor: RuntimeActorWire { actor_type: context.auth.actor_type.clone(), actor_id: context.auth.actor_id.clone(), session_id: input .session_id .clone() .or_else(|| context.auth.session_id.clone()), }, source: RuntimeSourceWire { channel: "hermes".into(), client: "mnote-hermes-plugin".into(), source_kind: None, root_uri: None, workspace_id: workspace_id.clone(), capabilities: input.capability_scope.clone().unwrap_or_default(), }, target: Some(RuntimeTargetWire { workspace_id: workspace_id.clone(), page_id: Some(document_id.clone()), block_id: None, }), payload, preflight_data: None, reason: Some(format!("Hermes tool {command_name}")), refs: vec![command_name.into(), "hermes-tool-call".into()], dry_run: false, validate_only: false, }; let execution = execute_runtime_command_via_legacy_cloud_with_artifacts( &state, context, workspace_id.as_deref(), command, ) .await?; let mut result = json!({ "commandName": command_name, "commandId": command_id, "result": execution.result, "artifacts": execution.artifacts, "artifactError": execution.artifact_error }); merge_result_extra(&mut result, result_extra); Ok(result) } fn merge_result_extra(result: &mut Value, extra: Option) { if let (Value::Object(result_map), Some(Value::Object(extra_map))) = (result, extra) { for (key, value) in extra_map { result_map.insert(key, value); } } } fn merge_page_payload(document_id: &str, workspace_id: Option<&str>, patch: Value) -> Value { let mut payload = json!({ "documentId": document_id, "workspaceId": workspace_id }); if let (Value::Object(base), Value::Object(extra)) = (&mut payload, patch) { for (key, value) in extra { base.insert(key, value); } } payload } async fn resolve_page_save_content( state: &AppState, context: &RequestContext, input: &ToolCallInput, content: Value, mode: &str, ) -> Result { match mode { "replace" | "" => Ok(content), "append" | "prepend" => { let document_id = input.effective_document_id().ok_or_else(|| { WebError::bad_request_code("mnote_tool_bad_request", "页面写工具缺少 documentId") .with_context(context) })?; let workspace_id = input.effective_workspace_id(); let aggregate = build_page_aggregate_snapshot( state, context, &document_id, workspace_id.as_deref(), None, None, ) .await?; let aggregate_value = serde_json::to_value(&aggregate) .map_err(|error| WebError::internal(error.to_string()))?; let current = aggregate_value .pointer("/body/content") .cloned() .unwrap_or_else(|| json!([])); Ok(merge_page_content_for_mode(current, content, mode)) } other => Err(WebError::bad_request_code( "mnote_tool_bad_request", format!("mnote.page.save 不支持 mode={other}"), ) .with_context(context)), } } fn normalize_page_save_blocks(value: Value) -> Vec { match value { Value::Array(items) => items.into_iter().map(normalize_page_save_block).collect(), Value::Object(mut map) => { if let Some(Value::Array(items)) = map.remove("blocks") { return items.into_iter().map(normalize_page_save_block).collect(); } if let Some(Value::Array(items)) = map.get("content").cloned() { return items.into_iter().map(normalize_page_save_block).collect(); } vec![normalize_page_save_block(Value::Object(map))] } Value::String(text) => vec![json!({ "type": "paragraph", "content": text })], other => vec![other], } } fn normalize_page_save_block(value: Value) -> Value { let Value::Object(mut map) = value else { return value; }; if !map.contains_key("type") { map.insert("type".into(), Value::String("paragraph".into())); } if !map.contains_key("content") { if let Some(text) = map .get("text") .and_then(Value::as_str) .map(str::trim) .filter(|text| !text.is_empty()) { map.insert("content".into(), Value::String(text.to_string())); } } Value::Object(map) } fn merge_page_content_for_mode(current: Value, next: Value, mode: &str) -> Value { let mut current_blocks = normalize_page_save_blocks(current); let mut next_blocks = normalize_page_save_blocks(next); if mode == "prepend" { next_blocks.extend(current_blocks); Value::Array(next_blocks) } else { current_blocks.extend(next_blocks); Value::Array(current_blocks) } } fn filter_wired_page_options(options: Value) -> (Value, Vec, Vec) { let allowed = ["wideLayout", "smallText", "showToc", "protectEditing"]; let mut out = serde_json::Map::new(); let mut ignored = Vec::new(); let mut warnings = Vec::new(); if let Value::Object(map) = options { for (key, value) in map { if allowed.contains(&key.as_str()) { out.insert(key, value); } else { warnings.push(json!({ "code": "page_option_not_wired", "field": key.clone(), "message": "页面设置字段尚未接入 Page Aggregate command,已忽略" })); ignored.push(key); } } } (Value::Object(out), ignored, warnings) } fn filter_local_ui_preference_options(options: Value) -> (Value, Vec, Vec) { let allowed = [ "wideLayout", "smallText", "layoutDensity", "pageFont", "showHeadingNumbers", "showToc", "showStructure", "showWordCount", "collapseBacklinks", "hideChildPages", "showBlockRefCount", "hideTitleHeader", ]; let mut out = serde_json::Map::new(); let mut ignored = Vec::new(); let mut warnings = Vec::new(); if let Value::Object(map) = options { for (key, value) in map { if allowed.contains(&key.as_str()) { out.insert(key, value); } else { warnings.push(json!({ "code": "page_option_not_wired", "field": key.clone(), "message": "页面设置字段尚未接入 SQLite UI 偏好,已忽略" })); ignored.push(key); } } } (Value::Object(out), ignored, warnings) } fn summarize_blocks(content: &Value) -> Vec { let mut out = Vec::new(); collect_blocks(content, &mut out); out.into_iter().take(40).collect() } fn collect_blocks(value: &Value, out: &mut Vec) { match value { Value::Array(items) => { for item in items { collect_blocks(item, out); } } Value::Object(map) => { let block_id = map .get("id") .and_then(Value::as_str) .unwrap_or_default() .to_string(); let block_type = map .get("type") .and_then(Value::as_str) .unwrap_or("unknown") .to_string(); let text = collect_text(value); if !block_id.is_empty() || !text.is_empty() { out.push(json!({ "id": block_id, "type": block_type, "text": text })); } if let Some(children) = map.get("children") { collect_blocks(children, out); } } _ => {} } } fn collect_text(value: &Value) -> String { match value { Value::String(text) => text.clone(), Value::Array(items) => items.iter().map(collect_text).collect::>().join(""), Value::Object(map) => { if let Some(text) = map.get("text").and_then(Value::as_str) { return text.to_string(); } if let Some(content) = map.get("content") { return collect_text(content); } String::new() } _ => String::new(), } } #[cfg(test)] mod tests { use super::*; #[test] fn page_save_append_merges_current_and_next_blocks() { let merged = merge_page_content_for_mode( json!([ {"id": "block_1", "type": "paragraph", "content": "原正文"} ]), json!([ {"type": "paragraph", "content": [{"type": "text", "text": "AI 追加"}]} ]), "append", ); assert_eq!(merged.as_array().map(Vec::len), Some(2)); assert_eq!(merged[0]["id"], "block_1"); assert_eq!(collect_text(&merged[1]), "AI 追加"); } #[test] fn page_save_prepend_accepts_plain_text() { let merged = merge_page_content_for_mode( json!([ {"id": "block_1", "type": "paragraph", "content": "原正文"} ]), json!("AI 前置"), "prepend", ); assert_eq!(merged.as_array().map(Vec::len), Some(2)); assert_eq!(collect_text(&merged[0]), "AI 前置"); assert_eq!(merged[1]["id"], "block_1"); } #[test] fn page_save_accepts_text_field_blocks_from_ai() { let merged = merge_page_content_for_mode( json!([]), json!([ {"type": "paragraph", "text": "AI 使用 text 字段"} ]), "append", ); assert_eq!(merged.as_array().map(Vec::len), Some(1)); assert_eq!(merged[0]["content"], "AI 使用 text 字段"); assert_eq!(collect_text(&merged[0]), "AI 使用 text 字段"); } }