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_convex_with_artifacts; use crate::routes::ensure_local_workspace_access; use bridge_runtime::{ RuntimeActorWire, RuntimeCommandEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, }; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; pub async fn create_summary( state: &AppState, context: &RequestContext, input: &ToolCallInput, ) -> Result { create_artifact_node( state, context, input, "summary", "mnote.artifact.create_summary", ) .await } pub async fn create_ai_note( state: &AppState, context: &RequestContext, input: &ToolCallInput, ) -> Result { create_artifact_node( state, context, input, "ai_note", "mnote.artifact.create_ai_note", ) .await } async fn create_artifact_node( state: &AppState, context: &RequestContext, input: &ToolCallInput, node_type: &str, tool_name: &str, ) -> Result { ensure_write_contract(context, input)?; let document_id = input.effective_document_id().ok_or_else(|| { WebError::bad_request_code("mnote_tool_bad_request", "artifact 工具缺少 documentId") .with_context(context) })?; let workspace_id = input.effective_workspace_id(); let content = input .arg_string("summary") .or_else(|| input.arg_string("content")) .ok_or_else(|| { WebError::bad_request_code("mnote_tool_bad_request", "artifact 工具缺少内容") .with_context(context) })?; let idempotency_key = input.idempotency_key_or_default(&format!("{tool_name}_{}", context.trace.request_id)); let command_id = format!( "{}_{}", tool_name.replace('.', "_"), context.trace.request_id ); let artifact_document_id = if node_type == "summary" { format!("summary_{}", document_id) } else { format!("ai_note_{}_{}", document_id, context.trace.request_id) }; 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) })?; ensure_local_workspace_access(context, &root_uri) .map_err(|error| error.with_context(context))?; if input.dry_run.unwrap_or(false) { return Ok(json!({ "dryRun": true, "commandName": "tree.node.create", "commandId": command_id, "artifactType": node_type, "artifactDocumentId": artifact_document_id, "documentId": document_id, "workspaceId": workspace_id, "diff": [{"op": "create_artifact", "artifactType": node_type}] })); } let root_path = parse_local_root_path(&root_uri)?; let artifact_dir = root_path.join(".mnote").join("artifacts"); fs::create_dir_all(&artifact_dir).map_err(|error| { WebError::bad_request_code( "local_artifact_write_failed", format!( "无法创建本地 artifact 目录 {}: {error}", artifact_dir.display() ), ) .with_context(context) })?; let artifact_path = artifact_dir.join(format!( "{}.json", sanitize_local_artifact_file_name(&artifact_document_id) )); let artifact_value = json!({ "schema": "mnote.local_artifact.v1", "artifactType": node_type, "artifactDocumentId": artifact_document_id, "documentId": document_id, "workspaceId": workspace_id, "content": content, "createdAt": context.trace.trace_id, }); fs::write( &artifact_path, serde_json::to_string_pretty(&artifact_value).map_err(|error| { WebError::internal(format!("本地 artifact 序列化失败: {error}")) .with_context(context) })?, ) .map_err(|error| { WebError::bad_request_code( "local_artifact_write_failed", format!( "无法写入本地 artifact 文件 {}: {error}", artifact_path.display() ), ) .with_context(context) })?; return Ok(json!({ "dryRun": false, "commandName": "tree.node.create", "commandId": command_id, "source": "local_folder", "artifactType": node_type, "artifactDocumentId": artifact_document_id, "documentId": document_id, "workspaceId": workspace_id, "result": { "ok": true, "source": "local_folder", "artifactPath": artifact_path, "artifactDocumentId": artifact_document_id, } })); } if input.dry_run.unwrap_or(false) { return Ok(json!({ "dryRun": true, "commandName": "tree.node.create", "commandId": command_id, "artifactType": node_type, "artifactDocumentId": artifact_document_id, "documentId": document_id, "workspaceId": workspace_id, "diff": [{"op": "create_artifact", "artifactType": node_type}] })); } let command = RuntimeCommandEnvelopeWire { name: "tree.node.create".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: json!({ "workspaceId": workspace_id, "parentId": document_id, "documentId": artifact_document_id, "accessScope": "private", "nodeType": node_type, "title": if node_type == "summary" { "AI Summary" } else { "AI Note" }, "content": [ { "id": format!("{}_body", node_type), "type": "paragraph", "content": [{"type": "text", "text": content}] } ], "artifact": { "kind": node_type, "sourceDocumentId": document_id, "source": "hermes", "sessionId": input.session_id, "runId": input.run_id, "toolCallId": input.tool_call_id, "traceId": input.effective_trace_id(&context.trace.trace_id) }, "referenceEdge": { "from": document_id, "kind": "ai_artifact_reference" } }), preflight_data: None, reason: Some(tool_name.into()), refs: vec![tool_name.into(), "hermes-tool-call".into()], dry_run: false, validate_only: false, }; let execution = execute_runtime_command_via_convex_with_artifacts( &state, context, workspace_id.as_deref(), command, ) .await?; Ok(json!({ "commandName": "tree.node.create", "commandId": command_id, "artifactType": node_type, "artifactDocumentId": artifact_document_id, "referenceEdge": { "from": document_id, "to": artifact_document_id, "kind": "ai_artifact_reference" }, "result": execution.result, "artifacts": execution.artifacts, "artifactError": execution.artifact_error })) } fn parse_local_root_path(root_uri: &str) -> Result { let root_path = if let Some(stripped) = root_uri.trim().strip_prefix("file://") { stripped.trim() } else { root_uri.trim() }; if root_path.is_empty() { return Err(WebError::bad_request_code( "local_folder_root_required", "缺少本地文件夹 rootUri", )); } Ok(PathBuf::from(root_path)) } fn sanitize_local_artifact_file_name(value: &str) -> String { value .chars() .map(|ch| match ch { '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_', _ => ch, }) .collect::() .trim() .to_string() } fn ensure_write_contract(context: &RequestContext, input: &ToolCallInput) -> Result<(), WebError> { crate::hermes_tools::ensure_write_authorized(context, input) }