use base64::Engine; use bridge_runtime::{ execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire, }; use clap::ValueEnum; use core_protocol::{ default_tool_registry, query::Pagination, tool_effect_label, ActorPayload, CommandEnvelope, CreateDocumentPage, DeleteDocumentPage, GetMindmap, GetPageContent, InvocationKind, ListSidebarDataset, MindmapOp, MindmapTreeNode, MoveDocumentPage, PatchPageBlock, PutMindmap, QueryEnvelope, RestoreDocumentPage, SavePageContent, SearchBlocks, SearchDocuments, SourcePayload, TargetRef, ToolExecutionMode, ToolInvocation, UpdatePageTitle, }; use mnote_editor_core::{ apply_ai_pipeline, export_markdown, import_markdown, BlockType, DocumentBlock, EditorAiScenario, EditorCommand, EditorInputKind, EditorPipelineRequest, EditorSession, VisibilitySnapshot, }; use reqwest::blocking::Client; use serde::Serialize; use serde_json::{json, Value}; use std::env; use storage_convex_bridge::{build_query_request, build_write_request, BridgeContext}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct CliContext { pub actor_id: String, pub actor_type: String, pub session_id: Option, pub reason: Option, pub idempotency_key: Option, pub validate_only: bool, pub dry_run: bool, } impl Default for CliContext { fn default() -> Self { Self { actor_id: "cli_user".into(), actor_type: "human".into(), session_id: None, reason: None, idempotency_key: None, validate_only: false, dry_run: false, } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct CliError { pub code: &'static str, pub message: String, } impl CliError { pub fn validation(message: impl Into) -> Self { Self { code: "VALIDATION_ERROR", message: message.into(), } } pub fn transport(message: impl Into) -> Self { Self { code: "TRANSPORT_ERROR", message: message.into(), } } } pub type CliResult = Result; #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CliJsonOutput { pub ok: bool, pub entrypoint: &'static str, pub domain: String, pub action: String, pub context: CliOutputContext, pub operation: CliOperationOutput, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CliOutputContext { pub request_id: String, pub trace_id: String, pub actor_id: String, pub actor_type: String, pub session_id: Option, pub reason: Option, pub workspace_id: Option, pub idempotency_key: Option, pub validate_only: bool, pub dry_run: bool, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum CliOperationOutput { Command { name: String, command_id: String, normalized_input: Value, transport: CliTransportPlan, #[serde(skip_serializing_if = "Option::is_none")] execution: Option, }, Query { name: String, normalized_input: Value, transport: CliTransportPlan, #[serde(skip_serializing_if = "Option::is_none")] execution: Option, }, Tool { tool_name: String, invocation_kind: String, execution_mode: String, toolset_id: String, effect: String, requires_confirmation: bool, normalized_input: Value, #[serde(skip_serializing_if = "Option::is_none")] execution: Option, }, } #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] pub struct CliTransportPlan { pub kind: String, pub function_name: String, pub payload_json: String, pub args_json: Value, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EditorCliOutput { pub ok: bool, pub entrypoint: &'static str, pub operation: &'static str, pub block_count: usize, pub output_markdown: String, pub visible_block_ids: Vec, pub outline: Vec, pub audit: Vec, pub change_report: EditorChangeReportOutput, #[serde(skip_serializing_if = "Option::is_none")] pub undo_applied: Option, #[serde(skip_serializing_if = "Option::is_none")] pub redo_applied: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EditorOutlineItem { pub block_id: String, pub level: u8, pub title: String, pub depth: usize, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EditorAuditItem { pub command: String, pub target_block_id: Option, pub before: Option, pub after: Option, } #[derive(Debug, Clone, Serialize, PartialEq, Eq)] #[serde(rename_all = "camelCase")] pub struct EditorBlockSnapshot { pub block_id: String, pub block_type: String, pub text: String, pub parent_id: Option, pub indent: u16, } #[derive(Debug, Clone, Serialize, PartialEq, Eq, Default)] #[serde(rename_all = "camelCase")] pub struct EditorChangeReportOutput { #[serde(default)] pub created_blocks: Vec, #[serde(default)] pub updated_blocks: Vec, #[serde(default)] pub moved_blocks: Vec, #[serde(default)] pub removed_blocks: Vec, #[serde(default)] pub notes: Vec, } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] pub enum EditorInputKindArg { PlainText, Markdown, } #[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)] pub enum EditorAiScenarioArg { MeetingNotesToTodos, LongParagraphToTitle, PageReorder, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PageCreateArgs<'a> { pub page_id: &'a str, pub workspace_id: &'a str, pub parent_id: Option<&'a str>, pub title: &'a str, pub access_scope: &'a str, pub content_json: &'a str, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PageMoveArgs<'a> { pub page_id: &'a str, pub workspace_id: Option<&'a str>, pub parent_id: Option<&'a str>, pub sort_order: i64, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PageDeleteArgs<'a> { pub page_id: &'a str, pub workspace_id: Option<&'a str>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct PageRestoreArgs<'a> { pub page_id: &'a str, pub workspace_id: Option<&'a str>, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct BlockMoveArgs<'a> { pub source_document_id: &'a str, pub block_id: &'a str, pub target_document_id: &'a str, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct BlockEmbedArgs<'a> { pub source_document_id: &'a str, pub block_id: &'a str, pub target_document_id: &'a str, pub target_block_id: Option<&'a str>, } pub fn supported_command_surface() -> Vec<&'static str> { let mut surface = vec![ "page get", "page title", "page save", "page create", "page move", "page delete", "page restore", "block insert", "block patch", "block move", "block embed", "mindmap get", "mindmap put", "mindmap op", "search documents", "search blocks", "sidebar dataset", "editor markdown-roundtrip", "editor session-demo", "tool run", ]; for tool_name in default_tool_registry().tool_names() { surface.push(Box::leak( format!("tool run --tool-name {tool_name}").into_boxed_str(), )); } surface } pub fn supported_json_contracts() -> Vec<&'static str> { let mut contracts = vec![ "page.get", "page.title", "page.save", "page.create", "page.move", "page.delete", "page.restore", "block.insert", "block.patch", "block.move", "block.embed", "mindmap.get", "mindmap.put", "mindmap.op", "search.documents", "search.blocks", "sidebar.dataset", "editor.markdown_roundtrip", "editor.session_demo", "tool.run", ]; for tool_name in default_tool_registry().tool_names() { contracts.push(Box::leak(format!("tool.{tool_name}").into_boxed_str())); } contracts } pub fn editor_markdown_roundtrip(markdown: &str) -> CliResult { let document = import_markdown(markdown).map_err(map_editor_error)?; let projection = VisibilitySnapshot::derive(&document); Ok(build_editor_output( "markdown_roundtrip", document.blocks().len(), export_markdown(&document), &projection, Vec::new(), EditorChangeReportOutput::default(), None, None, )) } pub fn editor_ai_pipeline( kind: EditorInputKind, scenario: EditorAiScenario, input: &str, ) -> CliResult { let result = apply_ai_pipeline(EditorPipelineRequest { kind, scenario, input: input.to_string(), }) .map_err(map_editor_error)?; let projection = VisibilitySnapshot::derive(&result.document); Ok(build_editor_output( "ai_pipeline", result.document.blocks().len(), result.markdown, &projection, result .audit .into_iter() .map(|item| EditorAuditItem { command: item.command, target_block_id: item.target_block_id, before: item.before.map(|snapshot| EditorBlockSnapshot { block_id: snapshot.block_id, block_type: snapshot.block_type, text: snapshot.text, parent_id: snapshot.parent_id, indent: snapshot.indent, }), after: item.after.map(|snapshot| EditorBlockSnapshot { block_id: snapshot.block_id, block_type: snapshot.block_type, text: snapshot.text, parent_id: snapshot.parent_id, indent: snapshot.indent, }), }) .collect(), EditorChangeReportOutput { created_blocks: result.change_report.created_blocks, updated_blocks: result.change_report.updated_blocks, moved_blocks: result.change_report.moved_blocks, removed_blocks: result.change_report.removed_blocks, notes: result.change_report.notes, }, None, None, )) } pub fn editor_session_demo(markdown: &str) -> CliResult { let document = import_markdown(markdown).map_err(map_editor_error)?; let bootstrap = if document.blocks().is_empty() { mnote_editor_core::DocumentModel::new(vec![DocumentBlock::new( "demo_root", BlockType::Paragraph, ) .with_text("demo root")]) } else { document }; let mut session = EditorSession::new(bootstrap); let primary_block_id = session .document() .blocks() .first() .map(|block| block.id.clone()) .ok_or_else(|| CliError::validation("session demo 缺少可编辑块"))?; let original_text = session .document() .block(&primary_block_id) .map(|block| block.content.text.clone()) .unwrap_or_default(); let next_text = if original_text.trim().is_empty() { "demo edited".to_string() } else { format!("{original_text} [edited]") }; session .apply_command(EditorCommand::ReplaceBlock { block_id: primary_block_id.clone(), text: next_text, }) .map_err(map_editor_error)?; session .apply_command(EditorCommand::InsertBlockAfter { after_block_id: Some(primary_block_id), block: DocumentBlock::new("cli_demo_block", BlockType::Paragraph) .with_text("CLI demo block"), }) .map_err(map_editor_error)?; let undo_applied = session.undo(); let redo_applied = session.redo(); let projection = VisibilitySnapshot::derive(session.document()); Ok(build_editor_output( "session_demo", session.document().blocks().len(), export_markdown(session.document()), &projection, Vec::new(), EditorChangeReportOutput::default(), Some(undo_applied), Some(redo_applied), )) } pub fn render_editor_plain_output(output: &EditorCliOutput) -> String { format!( "entrypoint={} operation={} block_count={} visible_blocks={} audit_count={} updated_blocks={} undo_applied={} redo_applied={}", output.entrypoint, output.operation, output.block_count, output.visible_block_ids.join(","), output.audit.len(), output.change_report.updated_blocks.join(","), output.undo_applied.unwrap_or(false), output.redo_applied.unwrap_or(false), ) } fn build_editor_output( operation: &'static str, block_count: usize, output_markdown: String, projection: &VisibilitySnapshot, audit: Vec, change_report: EditorChangeReportOutput, undo_applied: Option, redo_applied: Option, ) -> EditorCliOutput { EditorCliOutput { ok: true, entrypoint: "mnote-cli", operation, block_count, output_markdown, visible_block_ids: projection.visible_block_ids.clone(), outline: projection .outline .iter() .map(|entry| EditorOutlineItem { block_id: entry.block_id.clone(), level: entry.level, title: entry.title.clone(), depth: entry.depth, }) .collect(), audit, change_report, undo_applied, redo_applied, } } pub fn render_plain_output(output: &CliJsonOutput) -> String { match &output.operation { CliOperationOutput::Command { name, command_id, transport, execution, .. } => format!( "domain={} action={} kind=command name={} command_id={} function={} executed={} request_id={} trace_id={}", output.domain, output.action, name, command_id, transport.function_name, execution.is_some(), output.context.request_id, output.context.trace_id, ), CliOperationOutput::Query { name, transport, execution, .. } => format!( "domain={} action={} kind=query name={} function={} executed={} request_id={} trace_id={}", output.domain, output.action, name, transport.function_name, execution.is_some(), output.context.request_id, output.context.trace_id, ), CliOperationOutput::Tool { tool_name, invocation_kind, execution_mode, toolset_id, effect, requires_confirmation, execution, .. } => format!( "domain={} action={} kind=tool tool={} invocation_kind={} execution_mode={} toolset={} effect={} requires_confirmation={} executed={} request_id={} trace_id={}", output.domain, output.action, tool_name, invocation_kind, execution_mode, toolset_id, effect, requires_confirmation, execution.is_some(), output.context.request_id, output.context.trace_id, ), } } pub fn plan_page_get( ctx: &CliContext, page_id: &str, workspace_id: Option<&str>, ) -> CliResult { let bridge = build_bridge_context(ctx, workspace_id, "page", "get", page_id); let query = QueryEnvelope { name: "documents.content.get".into(), payload: GetPageContent { page_id: page_id.into(), workspace_id: workspace_id.map(|value| value.into()), }, }; let request = build_query_request(&bridge, &query).map_err(map_bridge_error)?; Ok(build_query_output( "page", "get", &bridge, ctx, query.name, json!({ "pageId": page_id, "workspaceId": workspace_id, }), CliTransportPlan { kind: "convex_query".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "id": page_id, }), }, )) } pub fn plan_page_title( ctx: &CliContext, page_id: &str, workspace_id: Option<&str>, title: &str, ) -> CliResult { let bridge = build_bridge_context(ctx, workspace_id, "page", "title", page_id); let command = CommandEnvelope { name: "documents.title.update".into(), command_id: build_command_id("page", "title", page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: workspace_id.map(|value| value.into()), page_id: Some(page_id.into()), block_id: None, }), payload: UpdatePageTitle { page_id: page_id.into(), title: title.into(), }, reason: ctx.reason.clone(), refs: vec!["phase2-cli".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "page", "title", &bridge, ctx, command.name, command.command_id, json!({ "pageId": page_id, "workspaceId": workspace_id, "title": title, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "id": page_id, "title": title, }), }, )) } pub fn plan_page_save( ctx: &CliContext, page_id: &str, workspace_id: Option<&str>, revision: Option, content_json: &str, conflict_detection_key: Option<&str>, ) -> CliResult { let content_value = parse_json_value(content_json, "content_json")?; let bridge = build_bridge_context(ctx, workspace_id, "page", "save", page_id); let command = CommandEnvelope { name: "documents.save".into(), command_id: build_command_id("page", "save", page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: workspace_id.map(|value| value.into()), page_id: Some(page_id.into()), block_id: None, }), payload: SavePageContent { page_id: page_id.into(), workspace_id: workspace_id.map(|value| value.into()), revision, content_json: content_json.into(), conflict_detection_key: conflict_detection_key.map(|value| value.into()), }, reason: ctx.reason.clone(), refs: vec!["phase2-cli".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "page", "save", &bridge, ctx, command.name, command.command_id, json!({ "pageId": page_id, "workspaceId": workspace_id, "revision": revision, "content": content_value, "conflictDetectionKey": conflict_detection_key, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "id": page_id, "content": content_value, "expectedRevision": revision, "conflictDetectionKey": conflict_detection_key, }), }, )) } pub fn plan_page_create(ctx: &CliContext, args: &PageCreateArgs<'_>) -> CliResult { let content_value = parse_json_value(args.content_json, "content_json")?; let bridge = build_bridge_context(ctx, Some(args.workspace_id), "page", "create", args.page_id); let command = CommandEnvelope { name: "documents.create".into(), command_id: build_command_id("page", "create", args.page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: Some(args.workspace_id.into()), page_id: Some(args.page_id.into()), block_id: None, }), payload: CreateDocumentPage { page_id: args.page_id.into(), parent_page_id: args.parent_id.map(|value| value.into()), title: args.title.into(), workspace_seed_id: args.workspace_id.into(), }, reason: ctx.reason.clone(), refs: vec!["phase8-cli".into(), "task-048".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "page", "create", &bridge, ctx, command.name, command.command_id, json!({ "pageId": args.page_id, "workspaceId": args.workspace_id, "parentId": args.parent_id, "title": args.title, "accessScope": args.access_scope, "content": content_value, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "id": args.page_id, "workspaceId": args.workspace_id, "parentId": args.parent_id, "title": args.title, "accessScope": args.access_scope, "content": content_value, }), }, )) } pub fn plan_page_move(ctx: &CliContext, args: &PageMoveArgs<'_>) -> CliResult { let bridge = build_bridge_context(ctx, args.workspace_id, "page", "move", args.page_id); let command = CommandEnvelope { name: "documents.move".into(), command_id: build_command_id("page", "move", args.page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: args.workspace_id.map(|value| value.into()), page_id: Some(args.page_id.into()), block_id: None, }), payload: MoveDocumentPage { page_id: args.page_id.into(), parent_page_id: args.parent_id.map(|value| value.into()), sort_order: args.sort_order, }, reason: ctx.reason.clone(), refs: vec!["phase8-cli".into(), "task-048".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "page", "move", &bridge, ctx, command.name, command.command_id, json!({ "pageId": args.page_id, "workspaceId": args.workspace_id, "parentId": args.parent_id, "sortOrder": args.sort_order, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "id": args.page_id, "parentId": args.parent_id, "sortOrder": args.sort_order, }), }, )) } pub fn plan_page_delete(ctx: &CliContext, args: &PageDeleteArgs<'_>) -> CliResult { let bridge = build_bridge_context(ctx, args.workspace_id, "page", "delete", args.page_id); let command = CommandEnvelope { name: "documents.delete".into(), command_id: build_command_id("page", "delete", args.page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: args.workspace_id.map(|value| value.into()), page_id: Some(args.page_id.into()), block_id: None, }), payload: DeleteDocumentPage { page_id: args.page_id.into(), }, reason: ctx.reason.clone(), refs: vec!["phase8-cli".into(), "task-048".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "page", "delete", &bridge, ctx, command.name, command.command_id, json!({ "pageId": args.page_id, "workspaceId": args.workspace_id, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "id": args.page_id, }), }, )) } pub fn plan_page_restore(ctx: &CliContext, args: &PageRestoreArgs<'_>) -> CliResult { let bridge = build_bridge_context(ctx, args.workspace_id, "page", "restore", args.page_id); let command = CommandEnvelope { name: "documents.restore".into(), command_id: build_command_id("page", "restore", args.page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: args.workspace_id.map(|value| value.into()), page_id: Some(args.page_id.into()), block_id: None, }), payload: RestoreDocumentPage { page_id: args.page_id.into(), }, reason: ctx.reason.clone(), refs: vec!["phase8-cli".into(), "task-048".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "page", "restore", &bridge, ctx, command.name, command.command_id, json!({ "pageId": args.page_id, "workspaceId": args.workspace_id, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "id": args.page_id, }), }, )) } pub fn plan_block_insert( ctx: &CliContext, workspace_id: &str, page_id: &str, content: &str, block_type: &str, parent_block_id: Option<&str>, prev_block_id: Option<&str>, ) -> CliResult { let bridge = build_bridge_context(ctx, Some(workspace_id), "block", "insert", page_id); let command = CommandEnvelope { name: "insert_block".into(), command_id: build_command_id("block", "insert", page_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: Some(workspace_id.into()), page_id: Some(page_id.into()), block_id: None, }), payload: core_protocol::InsertBlock { page_id: page_id.into(), block_type: block_type.into(), content: content.into(), parent_block_id: parent_block_id.map(|value| value.into()), prev_block_id: prev_block_id.map(|value| value.into()), }, reason: ctx.reason.clone(), refs: vec!["phase2-cli".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "block", "insert", &bridge, ctx, command.name, command.command_id, json!({ "workspaceId": workspace_id, "pageId": page_id, "content": content, "blockType": block_type, "parentBlockId": parent_block_id, "prevBlockId": prev_block_id, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "pageId": page_id, "content": content, "blockType": block_type, "parentBlockId": parent_block_id, "prevBlockId": prev_block_id, }), }, )) } pub fn plan_block_patch( ctx: &CliContext, workspace_id: Option<&str>, page_id: &str, block_id: &str, revision: Option, snapshot_json: &str, conflict_detection_key: Option<&str>, ) -> CliResult { let snapshot_value = parse_json_value(snapshot_json, "snapshot_json")?; let bridge = build_bridge_context(ctx, workspace_id, "block", "patch", block_id); let command = CommandEnvelope { name: "blocks.patch".into(), command_id: build_command_id("block", "patch", block_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: workspace_id.map(|value| value.into()), page_id: Some(page_id.into()), block_id: Some(block_id.into()), }), payload: PatchPageBlock { page_id: page_id.into(), block_id: block_id.into(), workspace_id: workspace_id.map(|value| value.into()), revision, block_snapshot_json: snapshot_json.into(), conflict_detection_key: conflict_detection_key.map(|value| value.into()), }, reason: ctx.reason.clone(), refs: vec!["phase2-cli".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "block", "patch", &bridge, ctx, command.name, command.command_id, json!({ "workspaceId": workspace_id, "pageId": page_id, "blockId": block_id, "revision": revision, "snapshot": snapshot_value, "conflictDetectionKey": conflict_detection_key, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "pageId": page_id, "blockId": block_id, "snapshot": snapshot_value, "expectedRevision": revision, "conflictDetectionKey": conflict_detection_key, }), }, )) } pub fn plan_block_move(ctx: &CliContext, args: &BlockMoveArgs<'_>) -> CliResult { let bridge = build_bridge_context(ctx, None, "block", "move", args.block_id); let command = CommandEnvelope { name: "blocks.move".into(), command_id: build_command_id("block", "move", args.block_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: None, page_id: Some(args.target_document_id.into()), block_id: Some(args.block_id.into()), }), payload: core_protocol::MoveBlock { block_id: args.block_id.into(), new_parent_block_id: None, new_page_id: Some(args.target_document_id.into()), prev_block_id: None, }, reason: ctx.reason.clone(), refs: vec!["phase8-cli".into(), "task-048".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "block", "move", &bridge, ctx, command.name, command.command_id, json!({ "sourceDocumentId": args.source_document_id, "blockId": args.block_id, "targetDocumentId": args.target_document_id, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "id": args.block_id, "sourceDocumentId": args.source_document_id, "targetDocumentId": args.target_document_id, }), }, )) } pub fn plan_block_embed(ctx: &CliContext, args: &BlockEmbedArgs<'_>) -> CliResult { let bridge = build_bridge_context(ctx, None, "block", "embed", args.block_id); let command = CommandEnvelope { name: "blocks.embed".into(), command_id: build_command_id("block", "embed", args.block_id), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: None, page_id: Some(args.target_document_id.into()), block_id: Some(args.block_id.into()), }), payload: core_protocol::EmbedBlock { source_document_id: args.source_document_id.into(), source_block_id: args.block_id.into(), target_document_id: args.target_document_id.into(), target_block_id: args.target_block_id.map(|value| value.into()), }, reason: ctx.reason.clone(), refs: vec!["phase8-cli".into(), "task-048".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "block", "embed", &bridge, ctx, command.name, command.command_id, json!({ "sourceDocumentId": args.source_document_id, "blockId": args.block_id, "targetDocumentId": args.target_document_id, "targetBlockId": args.target_block_id, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "sourceDocumentId": args.source_document_id, "blockId": args.block_id, "targetDocumentId": args.target_document_id, "targetBlockId": args.target_block_id, }), }, )) } pub fn plan_search_documents( ctx: &CliContext, query: &str, workspace_id: Option<&str>, limit: u32, cursor: Option<&str>, ) -> CliResult { let workspace_id = workspace_id.ok_or_else(|| CliError::validation("search documents 缺少 --workspace-id"))?; let bridge = build_bridge_context(ctx, Some(workspace_id), "search", "documents", query); let envelope = QueryEnvelope { name: "search.documents".into(), payload: SearchDocuments { query: query.into(), workspace_id: workspace_id.into(), page_id: None, pagination: Pagination { limit, cursor: cursor.map(|value| value.into()), }, title_only: false, exact: false, include_ocr: false, time_range: "any".into(), time_field: "updated".into(), custom_range_from: None, custom_range_to: None, }, }; let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?; Ok(build_query_output( "search", "documents", &bridge, ctx, envelope.name, json!({ "query": query, "workspaceId": workspace_id, "pagination": { "limit": limit, "cursor": cursor, }, "filters": { "titleOnly": false, "exact": false, "includeOcr": false, "timeRange": "any", "timeField": "updated", }, }), CliTransportPlan { kind: "convex_query".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "query": query, "workspaceId": workspace_id, "pageId": Value::Null, "limit": limit, "cursor": cursor, "titleOnly": false, "exact": false, "includeOcr": false, "timeRange": "any", "timeField": "updated", "customRangeFrom": Value::Null, "customRangeTo": Value::Null, }), }, )) } pub fn plan_search_blocks( ctx: &CliContext, query: &str, page_id: Option<&str>, limit: u32, cursor: Option<&str>, ) -> CliResult { let bridge = build_bridge_context(ctx, None, "search", "blocks", query); let envelope = QueryEnvelope { name: "search_blocks".into(), payload: SearchBlocks { query: query.into(), page_id: page_id.map(|value| value.into()), pagination: Pagination { limit, cursor: cursor.map(|value| value.into()), }, }, }; let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?; Ok(build_query_output( "search", "blocks", &bridge, ctx, envelope.name, json!({ "query": query, "pageId": page_id, "pagination": { "limit": limit, "cursor": cursor, }, }), CliTransportPlan { kind: "convex_query".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "query": query, "pageId": page_id, "limit": limit, "cursor": cursor, }), }, )) } pub fn plan_sidebar_dataset(ctx: &CliContext, workspace_id: &str) -> CliResult { let bridge = build_bridge_context(ctx, Some(workspace_id), "sidebar", "dataset", workspace_id); let envelope = QueryEnvelope { name: "sidebar.dataset.list".into(), payload: ListSidebarDataset { workspace_id: workspace_id.into(), }, }; let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?; Ok(build_query_output( "sidebar", "dataset", &bridge, ctx, envelope.name, json!({ "workspaceId": workspace_id, }), CliTransportPlan { kind: "convex_query".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "workspaceId": workspace_id, }), }, )) } pub fn plan_mindmap_get( ctx: &CliContext, workspace_id: Option<&str>, document_id: &str, mindmap_id: &str, ) -> CliResult { let bridge = build_bridge_context( ctx, workspace_id, "mindmap", "get", &format!("{document_id}_{mindmap_id}"), ); let envelope = QueryEnvelope { name: "mindmaps.get".into(), payload: GetMindmap { document_id: document_id.into(), mindmap_id: mindmap_id.into(), workspace_id: workspace_id.map(|value| value.into()), }, }; let request = build_query_request(&bridge, &envelope).map_err(map_bridge_error)?; Ok(build_query_output( "mindmap", "get", &bridge, ctx, envelope.name, json!({ "workspaceId": workspace_id, "documentId": document_id, "mindmapId": mindmap_id, }), CliTransportPlan { kind: "convex_query".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "docId": document_id, "mindmapId": mindmap_id, }), }, )) } pub fn plan_mindmap_put( ctx: &CliContext, workspace_id: Option<&str>, document_id: &str, mindmap_id: &str, data_json: &str, create_only: bool, ) -> CliResult { let data = parse_json_value(data_json, "data_json")?; serde_json::from_value::(data.clone()) .map_err(|error| CliError::validation(format!("data_json 不是合法导图树: {error}")))?; let bridge = build_bridge_context( ctx, workspace_id, "mindmap", "put", &format!("{document_id}_{mindmap_id}"), ); let command = CommandEnvelope { name: "mindmaps.put".into(), command_id: build_command_id("mindmap", "put", &format!("{document_id}_{mindmap_id}")), idempotency_key: bridge.idempotency_key.clone(), actor: build_actor_payload(ctx), source: build_source_payload(), target: Some(TargetRef { workspace_id: workspace_id.map(|value| value.into()), page_id: Some(document_id.into()), block_id: Some(mindmap_id.into()), }), payload: PutMindmap { document_id: document_id.into(), mindmap_id: mindmap_id.into(), data_json: serde_json::to_string(&data).map_err(|error| { CliError::validation(format!("mindmap data_json 序列化失败: {error}")) })?, create_only, }, reason: ctx.reason.clone(), refs: vec!["phase6-cli".into(), "task-032".into()], dry_run: ctx.dry_run, validate_only: ctx.validate_only, }; let request = build_write_request(&bridge, &command).map_err(map_bridge_error)?; Ok(build_command_output( "mindmap", "put", &bridge, ctx, command.name, command.command_id, json!({ "workspaceId": workspace_id, "documentId": document_id, "mindmapId": mindmap_id, "createOnly": create_only, "data": data, }), CliTransportPlan { kind: "convex_mutation".into(), function_name: request.function_name, payload_json: request.payload_json, args_json: json!({ "docId": document_id, "mindmapId": mindmap_id, "data": data, "createOnly": create_only, }), }, )) } pub fn plan_mindmap_op( ctx: &CliContext, workspace_id: Option<&str>, document_id: &str, mindmap_id: &str, ops_json: &str, ) -> CliResult { let ops_value = parse_json_value(ops_json, "ops_json")?; let ops: Vec = serde_json::from_value(ops_value.clone()) .map_err(|error| CliError::validation(format!("ops_json 不是合法 MindmapOp[]: {error}")))?; if ops.is_empty() { return Err(CliError::validation("ops_json 不能为空数组")); } let bridge = build_bridge_context( ctx, workspace_id, "mindmap", "op", &format!("{document_id}_{mindmap_id}"), ); let spec = default_tool_registry() .tool("mindmap_apply_ops") .ok_or_else(|| CliError::validation("未注册 tool: mindmap_apply_ops"))?; Ok(CliJsonOutput { ok: true, entrypoint: "mnote-cli", domain: "mindmap".into(), action: "op".into(), context: build_output_context(&bridge, ctx), operation: CliOperationOutput::Tool { tool_name: "mindmap_apply_ops".into(), invocation_kind: invocation_kind_label(&InvocationKind::Command).into(), execution_mode: core_protocol::tool_mode_label(&ToolExecutionMode::Result).into(), toolset_id: spec.toolset_id.into(), effect: tool_effect_label(&spec.effect).into(), requires_confirmation: spec.requires_confirmation, normalized_input: json!({ "toolName": "mindmap_apply_ops", "workspaceId": workspace_id, "documentId": document_id, "mindmapId": mindmap_id, "ops": ops_value, "reason": ctx.reason, "validateOnly": bridge.validate_only, "dryRun": bridge.dry_run, }), execution: None, }, }) } pub fn plan_tool_run( ctx: &CliContext, tool_name: &str, invocation_kind: InvocationKind, execution_mode: ToolExecutionMode, args_json: &str, ) -> CliResult { let args_value = parse_json_value(args_json, "args_json")?; let bridge = build_bridge_context(ctx, None, "tool", "run", tool_name); let spec = default_tool_registry() .tool(tool_name) .ok_or_else(|| CliError::validation(format!("未知 tool: {tool_name}")))?; if spec.invocation_kind != invocation_kind { return Err(CliError::validation(format!( "tool {tool_name} 仅支持 {},当前传入 {}", invocation_kind_label(&spec.invocation_kind), invocation_kind_label(&invocation_kind), ))); } let invocation = ToolInvocation { tool: tool_name.into(), kind: invocation_kind, mode: execution_mode, args_json: args_json.into(), }; let operation = CliOperationOutput::Tool { tool_name: invocation.tool, invocation_kind: invocation_kind_label(&invocation.kind).into(), execution_mode: core_protocol::tool_mode_label(&invocation.mode).into(), toolset_id: spec.toolset_id.into(), effect: tool_effect_label(&spec.effect).into(), requires_confirmation: spec.requires_confirmation, normalized_input: json!({ "toolName": tool_name, "invocationKind": invocation_kind_label(&invocation.kind), "executionMode": core_protocol::tool_mode_label(&invocation.mode), "toolsetId": spec.toolset_id, "effect": tool_effect_label(&spec.effect), "requiresConfirmation": spec.requires_confirmation, "validateOnly": bridge.validate_only, "dryRun": bridge.dry_run, "args": args_value, }), execution: None, }; Ok(CliJsonOutput { ok: true, entrypoint: "mnote-cli", domain: "tool".into(), action: "run".into(), context: build_output_context(&bridge, ctx), operation, }) } pub fn execute_output(output: &CliJsonOutput, ctx: &CliContext) -> CliResult { let mut next = output.clone(); match &mut next.operation { CliOperationOutput::Query { name, transport, execution, .. } => { *execution = Some(execute_query(name, transport, &next.context, ctx)?); } CliOperationOutput::Command { name, normalized_input, transport, execution, .. } => { *execution = Some(execute_command( name, normalized_input, transport, &next.context, ctx, )?); } CliOperationOutput::Tool { tool_name, invocation_kind, execution_mode, normalized_input, execution, .. } => { *execution = Some(execute_tool( tool_name, invocation_kind, execution_mode, normalized_input, &next.context, ctx, )?); } } Ok(next) } fn execute_query( name: &str, transport: &CliTransportPlan, context: &CliOutputContext, cli_ctx: &CliContext, ) -> CliResult { match name { "documents.content.get" | "documents.meta.get" | "mindmaps.get" | "sidebar.dataset.list" => execute_convex_query(transport, cli_ctx), "search.documents" => execute_search_documents(transport, context, cli_ctx), other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))), } } fn execute_command( name: &str, normalized_input: &Value, transport: &CliTransportPlan, context: &CliOutputContext, cli_ctx: &CliContext, ) -> CliResult { match name { "documents.create" | "documents.move" | "documents.delete" | "documents.restore" | "documents.title.update" | "documents.save" | "mindmaps.put" => execute_convex_mutation(transport, cli_ctx), "insert_block" => execute_block_insert(normalized_input, context, cli_ctx), "blocks.patch" => execute_block_patch(normalized_input, transport, cli_ctx), "blocks.move" => execute_block_move(normalized_input, cli_ctx), "blocks.embed" => execute_block_embed(normalized_input, cli_ctx), other => Err(CliError::validation(format!( "暂不支持执行 command: {other}" ))), } } fn execute_tool( tool_name: &str, invocation_kind: &str, execution_mode: &str, normalized_input: &Value, context: &CliOutputContext, cli_ctx: &CliContext, ) -> CliResult { if execution_mode != "result" { return Err(CliError::validation( "tool --execute 目前只支持 mode=result", )); } let args = normalized_input.get("args").cloned().unwrap_or(Value::Null); let runtime_args = merge_tool_args(tool_name, normalized_input, &args, context); let target = build_tool_target(tool_name, normalized_input, context)?; let runtime_context = build_runtime_context(context, cli_ctx); let data = load_tool_data(tool_name, &runtime_args, target.as_ref(), cli_ctx)?; execute_runtime_query(RuntimeInput::Tool { context: runtime_context, tool: RuntimeToolInvocationWire { tool: tool_name.into(), kind: invocation_kind.into(), mode: Some("result".into()), args_json: runtime_args, target, reason: cli_ctx.reason.clone(), refs: vec!["mnote-cli-execute".into()], }, data: Some(data), }) .map_err(|error| CliError::transport(error.message)) } fn merge_tool_args( tool_name: &str, normalized_input: &Value, args: &Value, context: &CliOutputContext, ) -> Value { let mut merged = args.as_object().cloned().unwrap_or_default(); if let Some(workspace_id) = context.workspace_id.as_ref() { merged .entry("workspaceId") .or_insert_with(|| json!(workspace_id)); } if let Some(document_id) = normalized_input.get("documentId").cloned() { merged.entry("documentId").or_insert(document_id); } if let Some(mindmap_id) = normalized_input.get("mindmapId").cloned() { merged.entry("mindmapId").or_insert(mindmap_id); } if let Some(ops) = normalized_input.get("ops").cloned() { merged.entry("ops").or_insert(ops); } if tool_name == "mindmap_apply_ops" { if let Some(page_id) = normalized_input.get("documentId").cloned() { merged.entry("pageId").or_insert(page_id); } } Value::Object(merged) } fn execute_search_documents( transport: &CliTransportPlan, context: &CliOutputContext, cli_ctx: &CliContext, ) -> CliResult { let workspace_id = transport .args_json .get("workspaceId") .and_then(Value::as_str) .ok_or_else(|| CliError::validation("search documents 缺少 workspaceId"))?; let documents = execute_convex_query_raw( "documents:listSearchDataByWorkspace", json!({ "workspaceId": workspace_id }), cli_ctx, )?; let mindmaps = execute_convex_query_raw( "mindmaps:listByWorkspace", json!({ "workspaceId": workspace_id, "includeDeleted": false }), cli_ctx, ) .unwrap_or_else(|_| Value::Array(vec![])); let tables = execute_convex_query_raw( "tables:listByWorkspaceForSearch", json!({ "userId": context.actor_id, "workspaceId": workspace_id, "includeArchived": false, "limit": 3000, }), cli_ctx, ) .unwrap_or_else(|_| Value::Array(vec![])); let table_rows = execute_convex_query_raw( "tables:listRowsByWorkspaceForSearch", json!({ "userId": context.actor_id, "workspaceId": workspace_id, "limit": 8000, }), cli_ctx, ) .unwrap_or_else(|_| Value::Array(vec![])); let assets = execute_convex_query_raw( "mediaAssets:listSearchDataByWorkspace", json!({ "userId": context.actor_id, "workspaceId": workspace_id, "includeDeleted": false, "limit": 5000, }), cli_ctx, ) .unwrap_or_else(|_| Value::Array(vec![])); let dataset = json!({ "documents": documents.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ "id": item.get("id").cloned().unwrap_or(Value::Null), "workspaceId": item.get("workspace_id").cloned().unwrap_or(json!(workspace_id)), "title": item.get("title").cloned().unwrap_or(Value::Null), "rawText": item.get("raw_text").cloned().unwrap_or(Value::Null), "createdAt": item.get("created_at").cloned().unwrap_or(Value::Null), "updatedAt": item.get("updated_at").cloned().unwrap_or(Value::Null), })).collect::>(), "mindmaps": mindmaps.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), "data": item.get("data").cloned().unwrap_or(Value::Null), })).collect::>(), "tables": tables.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ "id": item.get("id").cloned().unwrap_or(Value::Null), "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), "title": item.get("title").cloned().unwrap_or(Value::Null), })).collect::>(), "tableRows": table_rows.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ "tableId": item.get("table_id").cloned().unwrap_or(Value::Null), "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), "rowHash": item.get("row_hash").cloned().unwrap_or(Value::Null), })).collect::>(), "assets": assets.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ "id": item.get("id").cloned().unwrap_or(Value::Null), "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), "assetType": item.get("asset_type").cloned().unwrap_or(Value::Null), "fileName": item.get("file_name").cloned().unwrap_or(Value::Null), "mimeType": item.get("mime_type").cloned().unwrap_or(Value::Null), "ocrText": item.get("ocr_text").cloned().unwrap_or(Value::Null), "ocrStatus": item.get("ocr_status").cloned().unwrap_or(Value::Null), })).collect::>(), }); execute_runtime_query(RuntimeInput::Query { context: build_runtime_context(context, cli_ctx), query: RuntimeQueryEnvelopeWire { name: "search.documents".into(), payload: transport.args_json.clone(), }, data: Some(dataset), }) .map_err(|error| CliError::transport(error.message)) } fn execute_block_insert( normalized_input: &Value, context: &CliOutputContext, cli_ctx: &CliContext, ) -> CliResult { let page_id = required_str(normalized_input, "pageId")?; let workspace_id = required_str(normalized_input, "workspaceId")?; let content = required_str(normalized_input, "content")?; let block_type = normalized_input .get("blockType") .and_then(Value::as_str) .unwrap_or("paragraph"); let before_block_id = None::; let after_block_id = normalized_input .get("prevBlockId") .and_then(Value::as_str) .map(str::to_string); let data = execute_convex_query_raw("documents:getContent", json!({ "id": page_id }), cli_ctx)?; let block_specs = json!([{ "type": block_type, "text": content, }]); let runtime_result = execute_runtime_query(RuntimeInput::Tool { context: build_runtime_context(context, cli_ctx), tool: RuntimeToolInvocationWire { tool: "doc_insert_blocks".into(), kind: "command".into(), mode: Some("result".into()), args_json: json!({ "pageId": page_id, "workspaceId": workspace_id, "afterBlockId": after_block_id, "beforeBlockId": before_block_id, "blocks": block_specs, }), target: Some(RuntimeTargetWire { workspace_id: Some(workspace_id.to_string()), page_id: Some(page_id.to_string()), block_id: None, }), reason: cli_ctx.reason.clone(), refs: vec!["mnote-cli-execute".into()], }, data: Some(data.clone()), }) .map_err(|error| CliError::transport(error.message))?; let next_content = write_blocks_back( &data, runtime_result .get("data") .cloned() .unwrap_or(Value::Array(vec![])), ); let save_result = execute_convex_mutation_raw( "documents:updateContent", json!({ "id": page_id, "content": next_content, "expectedRevision": data.get("revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": data.get("conflict_detection_key").cloned().unwrap_or(Value::Null), }), cli_ctx, )?; Ok(json!({ "ok": true, "pageId": page_id, "inserted": runtime_result.get("inserted").cloned().unwrap_or(Value::Array(vec![])), "revision": save_result.get("revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": save_result.get("conflict_detection_key").cloned().unwrap_or(Value::Null), "data": runtime_result.get("data").cloned().unwrap_or(Value::Null), })) } fn execute_block_patch( normalized_input: &Value, transport: &CliTransportPlan, cli_ctx: &CliContext, ) -> CliResult { let page_id = required_str(normalized_input, "pageId")?; let block_id = required_str(normalized_input, "blockId")?; let snapshot = normalized_input .get("snapshot") .cloned() .ok_or_else(|| CliError::validation("block patch 缺少 snapshot"))?; let current = execute_convex_query_raw("documents:getContent", json!({ "id": page_id }), cli_ctx)?; let next_blocks = replace_block_subtree(extract_blocks(¤t), block_id, &snapshot)?; let args = json!({ "id": page_id, "content": write_blocks_back(¤t, Value::Array(next_blocks)), "expectedRevision": transport.args_json.get("expectedRevision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": transport.args_json.get("conflictDetectionKey").cloned().unwrap_or(Value::Null), }); execute_convex_mutation_raw("documents:updateContent", args, cli_ctx) } fn execute_block_move(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult { let source_document_id = required_str(normalized_input, "sourceDocumentId")?; let target_document_id = required_str(normalized_input, "targetDocumentId")?; let block_id = required_str(normalized_input, "blockId")?; let source = execute_convex_query_raw( "documents:getContent", json!({ "id": source_document_id }), cli_ctx, )?; let target = execute_convex_query_raw( "documents:getContent", json!({ "id": target_document_id }), cli_ctx, )?; let source_blocks = extract_blocks(&source); let target_blocks = extract_blocks(&target); let (removed, next_source_blocks) = remove_block_subtree(source_blocks, block_id)?; let mut next_target_blocks = target_blocks; next_target_blocks.push(removed); let next_source_content = write_blocks_back(&source, Value::Array(next_source_blocks)); let next_target_content = write_blocks_back(&target, Value::Array(next_target_blocks)); let source_save = execute_convex_mutation_raw( "documents:updateContent", json!({ "id": source_document_id, "content": next_source_content, "expectedRevision": source.get("revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": source.get("conflict_detection_key").cloned().unwrap_or(Value::Null), }), cli_ctx, )?; let target_save = execute_convex_mutation_raw( "documents:updateContent", json!({ "id": target_document_id, "content": next_target_content, "expectedRevision": target.get("revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": target.get("conflict_detection_key").cloned().unwrap_or(Value::Null), }), cli_ctx, )?; Ok(json!({ "ok": true, "sourceRevision": source_save.get("revision").cloned().unwrap_or(Value::Null), "targetRevision": target_save.get("revision").cloned().unwrap_or(Value::Null), })) } fn execute_block_embed(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult { let source_document_id = required_str(normalized_input, "sourceDocumentId")?; let target_document_id = required_str(normalized_input, "targetDocumentId")?; let block_id = required_str(normalized_input, "blockId")?; let target_block_id = normalized_input .get("targetBlockId") .and_then(Value::as_str); let source_meta = execute_convex_query_raw( "documents:getMeta", json!({ "id": source_document_id }), cli_ctx, )?; let target_content = execute_convex_query_raw( "documents:getContent", json!({ "id": target_document_id }), cli_ctx, )?; let target_meta = execute_convex_query_raw( "documents:getMeta", json!({ "id": target_document_id }), cli_ctx, )?; let mut target_blocks = extract_blocks(&target_content); let reference_block = json!({ "id": format!("cli_embed_{}_{}", target_document_id, block_id), "type": "blockReference", "props": { "sourceDocumentId": source_document_id, "targetBlockId": block_id, "display": "embed", }, "content": [], "children": [], }); let anchor_id = target_block_id.map(str::to_string).or_else(|| { target_meta .get("embed_default_block_id") .and_then(Value::as_str) .map(str::to_string) }); if let Some(anchor_id) = anchor_id { if let Some(index) = find_block_index(&target_blocks, &anchor_id) { target_blocks.insert(index + 1, reference_block.clone()); } else { target_blocks.push(reference_block.clone()); } } else { target_blocks.push(reference_block.clone()); } let saved = execute_convex_mutation_raw( "documents:updateContent", json!({ "id": target_document_id, "content": write_blocks_back(&target_content, Value::Array(target_blocks)), "expectedRevision": target_content.get("revision").cloned().unwrap_or(Value::Null), "conflictDetectionKey": target_content.get("conflict_detection_key").cloned().unwrap_or(Value::Null), }), cli_ctx, )?; Ok(json!({ "ok": true, "sourceTitle": source_meta.get("title").cloned().unwrap_or(Value::Null), "referenceBlockId": reference_block.get("id").cloned().unwrap_or(Value::Null), "revision": saved.get("revision").cloned().unwrap_or(Value::Null), })) } fn build_tool_target( tool_name: &str, normalized_input: &Value, context: &CliOutputContext, ) -> CliResult> { let args = normalized_input.get("args").cloned().unwrap_or(Value::Null); let workspace_id = args .get("workspaceId") .and_then(Value::as_str) .map(str::to_string) .or_else(|| context.workspace_id.clone()); let page_id = args .get("documentId") .and_then(Value::as_str) .map(str::to_string) .or_else(|| { args.get("pageId") .and_then(Value::as_str) .map(str::to_string) }); let block_id = args .get("mindmapId") .and_then(Value::as_str) .map(str::to_string) .or_else(|| { args.get("blockId") .and_then(Value::as_str) .map(str::to_string) }); if matches!( tool_name, "search_web" | "image_read" | "slash_run" | "event_replay" | "index_rebuild" ) { return Ok(None); } if workspace_id.is_none() && page_id.is_none() && block_id.is_none() { return Ok(None); } Ok(Some(RuntimeTargetWire { workspace_id, page_id, block_id, })) } fn load_tool_data( tool_name: &str, args: &Value, target: Option<&RuntimeTargetWire>, cli_ctx: &CliContext, ) -> CliResult { match tool_name { "search_web" | "slash_run" | "event_replay" | "index_rebuild" => { Ok(json!({ "source": "cli" })) } "image_read" => Ok(json!({ "source": "cli", "asset": Value::Null })), "doc_get" | "doc_find" | "doc_insert_blocks" | "doc_replace_range" => { let page_id = read_doc_target(args, target)?; let result = execute_convex_query_raw( "documents:getContent", json!({ "id": page_id }), cli_ctx, )?; Ok( json!({ "source": "convex", "content": result.get("content").cloned().unwrap_or(Value::Null) }), ) } "docs_read" => { let document_id = args .get("documentId") .and_then(Value::as_str) .ok_or_else(|| CliError::validation("docs_read 缺少 documentId"))?; let meta = execute_convex_query_raw( "documents:getMeta", json!({ "id": document_id }), cli_ctx, )?; let content = execute_convex_query_raw( "documents:getContent", json!({ "id": document_id }), cli_ctx, )?; Ok(json!({ "source": "convex", "documentId": document_id, "title": meta.get("title").cloned().unwrap_or(Value::Null), "workspaceId": meta.get("workspace_id").cloned().unwrap_or(Value::Null), "parentId": meta.get("parent_id").cloned().unwrap_or(Value::Null), "updatedAt": meta.get("updated_at").cloned().unwrap_or(Value::Null), "rawText": extract_raw_text(&content), "rawTextLength": extract_raw_text(&content).chars().count(), "content": content.get("content").cloned().unwrap_or(Value::Null), })) } "docs_search" => { let workspace_id = args .get("workspaceId") .and_then(Value::as_str) .ok_or_else(|| CliError::validation("docs_search 缺少 workspaceId"))?; let documents = execute_convex_query_raw( "documents:listSearchDataByWorkspace", json!({ "workspaceId": workspace_id }), cli_ctx, )?; let mindmaps = execute_convex_query_raw( "mindmaps:listByWorkspace", json!({ "workspaceId": workspace_id, "includeDeleted": false }), cli_ctx, ) .unwrap_or_else(|_| Value::Array(vec![])); let tables = execute_convex_query_raw( "tables:listByWorkspaceForSearch", json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "includeArchived": false, "limit": 3000 }), cli_ctx, ).unwrap_or_else(|_| Value::Array(vec![])); let table_rows = execute_convex_query_raw( "tables:listRowsByWorkspaceForSearch", json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "limit": 8000 }), cli_ctx, ) .unwrap_or_else(|_| Value::Array(vec![])); let assets = execute_convex_query_raw( "mediaAssets:listSearchDataByWorkspace", json!({ "userId": cli_ctx.actor_id, "workspaceId": workspace_id, "includeDeleted": false, "limit": 5000 }), cli_ctx, ).unwrap_or_else(|_| Value::Array(vec![])); Ok(json!({ "source": "convex", "datasets": [{ "workspaceId": workspace_id, "documents": documents.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ "id": item.get("id").cloned().unwrap_or(Value::Null), "workspaceId": item.get("workspace_id").cloned().unwrap_or(json!(workspace_id)), "title": item.get("title").cloned().unwrap_or(Value::Null), "rawText": item.get("raw_text").cloned().unwrap_or(Value::Null), "createdAt": item.get("created_at").cloned().unwrap_or(Value::Null), "updatedAt": item.get("updated_at").cloned().unwrap_or(Value::Null) })).collect::>(), "mindmaps": mindmaps.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), "data": item.get("data").cloned().unwrap_or(Value::Null) })).collect::>(), "tables": tables.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ "id": item.get("id").cloned().unwrap_or(Value::Null), "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), "title": item.get("title").cloned().unwrap_or(Value::Null) })).collect::>(), "tableRows": table_rows.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ "tableId": item.get("table_id").cloned().unwrap_or(Value::Null), "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), "rowHash": item.get("row_hash").cloned().unwrap_or(Value::Null) })).collect::>(), "assets": assets.as_array().cloned().unwrap_or_default().into_iter().map(|item| json!({ "id": item.get("id").cloned().unwrap_or(Value::Null), "documentId": item.get("document_id").cloned().unwrap_or(Value::Null), "assetType": item.get("asset_type").cloned().unwrap_or(Value::Null), "fileName": item.get("file_name").cloned().unwrap_or(Value::Null), "mimeType": item.get("mime_type").cloned().unwrap_or(Value::Null), "ocrText": item.get("ocr_text").cloned().unwrap_or(Value::Null), "ocrStatus": item.get("ocr_status").cloned().unwrap_or(Value::Null) })).collect::>() }] })) } "mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => { let document_id = read_doc_target(args, target)?; let mindmap_id = args .get("mindmapId") .and_then(Value::as_str) .or_else(|| target.and_then(|value| value.block_id.as_deref())) .ok_or_else(|| CliError::validation("mindmap 工具缺少 mindmapId"))?; let result = execute_convex_query_raw( "mindmaps:get", json!({ "docId": document_id, "mindmapId": mindmap_id }), cli_ctx, )?; Ok( json!({ "source": "convex", "data": result.get("data").cloned().unwrap_or(Value::Null), "meta": result.get("meta").cloned().unwrap_or(Value::Null) }), ) } other => Err(CliError::validation(format!( "暂不支持 tool 数据加载: {other}" ))), } } fn execute_convex_query(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult { execute_convex_query_raw( &transport.function_name, transport.args_json.clone(), cli_ctx, ) } fn execute_convex_mutation(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult { execute_convex_mutation_raw( &transport.function_name, transport.args_json.clone(), cli_ctx, ) } fn execute_convex_query_raw( function_name: &str, args_json: Value, cli_ctx: &CliContext, ) -> CliResult { let env = ConvexCliEnv::load(cli_ctx)?; let payload = json!({ "path": function_name, "format": "convex_encoded_json", "args": args_json, }); let response = env .client .post(format!("{}/api/query", env.url)) .header("Authorization", env.authorization) .header("Content-Type", "application/json") .header("Convex-Client", "mnote-cli") .json(&payload) .send() .map_err(|error| CliError::transport(format!("Convex query 请求失败: {error}")))?; parse_convex_response(response, cli_ctx) } fn execute_convex_mutation_raw( function_name: &str, args_json: Value, cli_ctx: &CliContext, ) -> CliResult { let env = ConvexCliEnv::load(cli_ctx)?; let payload = json!({ "path": function_name, "format": "convex_encoded_json", "args": [args_json], }); let response = env .client .post(format!("{}/api/mutation", env.url)) .header("Authorization", env.authorization) .header("Content-Type", "application/json") .header("Convex-Client", "mnote-cli") .json(&payload) .send() .map_err(|error| CliError::transport(format!("Convex mutation 请求失败: {error}")))?; parse_convex_response(response, cli_ctx) } fn parse_convex_response( response: reqwest::blocking::Response, _cli_ctx: &CliContext, ) -> CliResult { let status = response.status(); let body: Value = response .json() .map_err(|error| CliError::transport(format!("Convex 响应解析失败: {error}")))?; if !status.is_success() { let message = body .get("errorMessage") .and_then(Value::as_str) .map(str::to_string) .unwrap_or_else(|| format!("HTTP {}", status.as_u16())); return Err(CliError::transport(message)); } match body.get("status").and_then(Value::as_str) { Some("success") => Ok(body.get("value").cloned().unwrap_or(Value::Null)), Some("error") => Err(CliError::transport( body.get("errorMessage") .and_then(Value::as_str) .unwrap_or("Convex 返回 error") .to_string(), )), _ => Err(CliError::transport(format!("未知 Convex 响应: {body}"))), } } #[derive(Debug, Clone)] struct ConvexCliEnv { url: String, authorization: String, client: Client, } impl ConvexCliEnv { fn load(cli_ctx: &CliContext) -> CliResult { let url = read_env_or_dotenv("CONVEX_SELF_HOSTED_URL")? .or_else(|| env::var("NEXT_PUBLIC_CONVEX_URL").ok()) .ok_or_else(|| { CliError::validation("缺少 CONVEX_SELF_HOSTED_URL / NEXT_PUBLIC_CONVEX_URL") })?; let admin_key = read_env_or_dotenv("CONVEX_SELF_HOSTED_ADMIN_KEY")? .ok_or_else(|| CliError::validation("缺少 CONVEX_SELF_HOSTED_ADMIN_KEY"))?; let dev_user_id = normalize_actor_for_convex_identity(&cli_ctx.actor_id) .or(read_env_or_dotenv("DEV_USER_ID")?) .unwrap_or_else(|| "dev-user".into()); let dev_user_name = read_env_or_dotenv("DEV_USER_NAME")?.unwrap_or_else(|| "开发用户".into()); let dev_user_email = read_env_or_dotenv("DEV_USER_EMAIL")?.unwrap_or_else(|| "dev@mnote.local".into()); let identity = build_convex_dev_identity(&dev_user_id, &dev_user_name, &dev_user_email); let encoded = base64::engine::general_purpose::STANDARD .encode(serde_json::to_string(&identity).map_err(|error| { CliError::transport(format!("开发用户身份序列化失败: {error}")) })?); let authorization = format!("Convex {admin_key}:{encoded}"); let client = Client::builder() .timeout(std::time::Duration::from_secs(30)) .build() .map_err(|error| CliError::transport(format!("HTTP 客户端创建失败: {error}")))?; Ok(Self { url: url.trim().trim_end_matches('/').to_string(), authorization, client, }) } } fn normalize_actor_for_convex_identity(actor_id: &str) -> Option { let trimmed = actor_id.trim(); if trimmed.is_empty() || trimmed == "anonymous" || trimmed == "cli_user" { None } else { Some(trimmed.to_string()) } } fn build_convex_dev_identity(user_id: &str, name: &str, email: &str) -> Value { json!({ "subject": user_id, "issuer": "https://mnote.local/dev-auth", "tokenIdentifier": format!("dev-user|{}", user_id), "name": name, "email": email, }) } fn read_env_or_dotenv(key: &str) -> CliResult> { if let Ok(value) = env::var(key) { let trimmed = value.trim().to_string(); if !trimmed.is_empty() { return Ok(Some(trimmed)); } } let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .join("../../..") .join(".env.all"); let content = std::fs::read_to_string(&root) .map_err(|error| CliError::transport(format!("读取 .env.all 失败: {error}")))?; for line in content.lines() { let line = line.trim_end_matches('\r'); if line.starts_with('#') || line.trim().is_empty() { continue; } if let Some((k, v)) = line.split_once('=') { if k.trim() == key { let trimmed = v.trim().trim_matches('"').to_string(); if !trimmed.is_empty() { return Ok(Some(trimmed)); } } } } Ok(None) } fn build_runtime_context( context: &CliOutputContext, cli_ctx: &CliContext, ) -> RuntimeBridgeContextWire { RuntimeBridgeContextWire { deployment_id: None, project_id: None, workspace_id: context.workspace_id.clone(), request_id: context.request_id.clone(), trace_id: context.trace_id.clone(), actor: RuntimeActorWire { actor_type: context.actor_type.clone(), actor_id: context.actor_id.clone(), session_id: context.session_id.clone(), }, source: RuntimeSourceWire { channel: "cli".into(), client: "mnote-cli".into(), }, tenant_id: None, auth_token: None, idempotency_key: cli_ctx.idempotency_key.clone(), validate_only: cli_ctx.validate_only, dry_run: cli_ctx.dry_run, } } fn extract_blocks(content_result: &Value) -> Vec { if let Some(content) = content_result.get("content") { if let Some(array) = content.as_array() { return array.clone(); } if let Some(array) = content.get("blocks").and_then(Value::as_array) { return array.clone(); } } vec![] } fn write_blocks_back(content_result: &Value, blocks: Value) -> Value { let current = content_result .get("content") .cloned() .unwrap_or(Value::Null); if current.is_array() { return blocks; } if let Some(obj) = current.as_object() { let mut next = obj.clone(); next.insert("blocks".into(), blocks); return Value::Object(next); } json!({ "blocks": blocks }) } fn find_block_index(blocks: &[Value], target_id: &str) -> Option { blocks.iter().position(|block| { block .get("id") .and_then(Value::as_str) .map(|value| value == target_id) .unwrap_or(false) }) } fn remove_block_subtree(mut blocks: Vec, block_id: &str) -> CliResult<(Value, Vec)> { if let Some(index) = blocks .iter() .position(|block| block.get("id").and_then(Value::as_str) == Some(block_id)) { let removed = blocks.remove(index); return Ok((removed, blocks)); } for block in &mut blocks { if let Some(children) = block.get_mut("children").and_then(Value::as_array_mut) { let (removed, next_children) = remove_block_subtree(children.clone(), block_id)?; *children = next_children; return Ok((removed, blocks)); } } Err(CliError::validation(format!("未找到 blockId:{block_id}"))) } fn replace_block_subtree( mut blocks: Vec, block_id: &str, next_block: &Value, ) -> CliResult> { let mut normalized = next_block .as_object() .cloned() .ok_or_else(|| CliError::validation("block patch snapshot 必须是对象"))?; normalized.insert("id".into(), Value::String(block_id.to_string())); let next_value = Value::Object(normalized); if let Some(index) = blocks .iter() .position(|block| block.get("id").and_then(Value::as_str) == Some(block_id)) { blocks[index] = next_value; return Ok(blocks); } for block in &mut blocks { if let Some(children) = block.get_mut("children").and_then(Value::as_array_mut) { let next_children = replace_block_subtree(children.clone(), block_id, &next_value)?; *children = next_children; return Ok(blocks); } } Err(CliError::validation(format!("未找到 blockId:{block_id}"))) } fn extract_raw_text(content_result: &Value) -> String { fn walk(value: &Value, parts: &mut Vec) { match value { Value::Array(items) => { for item in items { walk(item, parts); } } Value::Object(map) => { if let Some(text) = map.get("text").and_then(Value::as_str) { if !text.trim().is_empty() { parts.push(text.to_string()); } } if let Some(content) = map.get("content") { walk(content, parts); } if let Some(children) = map.get("children") { walk(children, parts); } if let Some(blocks) = map.get("blocks") { walk(blocks, parts); } } _ => {} } } let mut parts = Vec::new(); if let Some(content) = content_result.get("content") { walk(content, &mut parts); } parts.join("\n") } fn read_doc_target(args: &Value, target: Option<&RuntimeTargetWire>) -> CliResult { args.get("documentId") .and_then(Value::as_str) .or_else(|| args.get("pageId").and_then(Value::as_str)) .map(str::to_string) .or_else(|| target.and_then(|value| value.page_id.clone())) .ok_or_else(|| CliError::validation("缺少 documentId/pageId")) } fn required_str<'a>(value: &'a Value, key: &str) -> CliResult<&'a str> { value .get(key) .and_then(Value::as_str) .ok_or_else(|| CliError::validation(format!("缺少 {key}"))) } fn build_query_output( domain: &str, action: &str, bridge: &BridgeContext, ctx: &CliContext, name: String, normalized_input: Value, transport: CliTransportPlan, ) -> CliJsonOutput { CliJsonOutput { ok: true, entrypoint: "mnote-cli", domain: domain.into(), action: action.into(), context: build_output_context(bridge, ctx), operation: CliOperationOutput::Query { name, normalized_input, transport, execution: None, }, } } fn build_command_output( domain: &str, action: &str, bridge: &BridgeContext, ctx: &CliContext, name: String, command_id: String, normalized_input: Value, transport: CliTransportPlan, ) -> CliJsonOutput { CliJsonOutput { ok: true, entrypoint: "mnote-cli", domain: domain.into(), action: action.into(), context: build_output_context(bridge, ctx), operation: CliOperationOutput::Command { name, command_id, normalized_input, transport, execution: None, }, } } fn build_output_context(bridge: &BridgeContext, ctx: &CliContext) -> CliOutputContext { CliOutputContext { request_id: bridge.request_id.clone(), trace_id: bridge.trace_id.clone(), actor_id: bridge.actor_id.clone(), actor_type: bridge.actor_type.clone(), session_id: bridge.session_id.clone(), reason: ctx.reason.clone(), workspace_id: bridge.workspace_id.clone(), idempotency_key: ctx.idempotency_key.clone(), validate_only: bridge.validate_only, dry_run: bridge.dry_run, } } fn build_bridge_context( ctx: &CliContext, workspace_id: Option<&str>, domain: &str, action: &str, target: &str, ) -> BridgeContext { let key = sanitize_id(&format!("{domain}_{action}_{target}")); BridgeContext { deployment_id: None, project_id: None, workspace_id: workspace_id.map(|value| value.into()), request_id: format!("req_cli_{key}"), trace_id: format!("trace_cli_{key}"), actor_type: ctx.actor_type.clone(), actor_id: ctx.actor_id.clone(), session_id: ctx.session_id.clone(), tenant_id: None, auth_token: None, source_channel: "cli".into(), source_client: "mnote-cli".into(), idempotency_key: ctx.idempotency_key.clone(), validate_only: ctx.validate_only, dry_run: ctx.dry_run, } } fn build_actor_payload(ctx: &CliContext) -> ActorPayload { ActorPayload { actor_type: ctx.actor_type.clone(), actor_id: ctx.actor_id.clone(), session_id: ctx.session_id.clone(), } } fn build_source_payload() -> SourcePayload { SourcePayload { channel: "cli".into(), client: "mnote-cli".into(), } } fn build_command_id(domain: &str, action: &str, target: &str) -> String { format!( "cmd_{}", sanitize_id(&format!("{domain}_{action}_{target}")) ) } fn sanitize_id(input: &str) -> String { input .chars() .map(|ch| { if ch.is_ascii_alphanumeric() { ch.to_ascii_lowercase() } else { '_' } }) .collect() } fn parse_json_value(raw: &str, field: &str) -> CliResult { serde_json::from_str::(raw) .map_err(|error| CliError::validation(format!("{field} 不是合法 JSON: {error}"))) } fn invocation_kind_label(kind: &InvocationKind) -> &'static str { match kind { InvocationKind::Command => "command", InvocationKind::Query => "query", InvocationKind::Job => "job", } } fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError { CliError::validation(error.message) } fn map_editor_error(error: mnote_editor_core::CoreError) -> CliError { CliError::validation(error.to_string()) } #[cfg(test)] mod tests { use super::*; #[test] fn supported_surface_covers_phase2_minimum_domains() { let commands = supported_command_surface(); assert!(commands.contains(&"page get")); assert!(commands.contains(&"page create")); assert!(commands.contains(&"page move")); assert!(commands.contains(&"block patch")); assert!(commands.contains(&"block move")); assert!(commands.contains(&"mindmap get")); assert!(commands.contains(&"search documents")); assert!(commands.contains(&"sidebar dataset")); assert!(commands.contains(&"editor markdown-roundtrip")); assert!(commands.contains(&"editor session-demo")); assert!(commands.contains(&"tool run")); } #[test] fn editor_markdown_roundtrip_returns_outline_and_markdown() { let output = editor_markdown_roundtrip("# 标题\n普通段落").expect("roundtrip should succeed"); assert_eq!(output.operation, "markdown_roundtrip"); assert_eq!(output.block_count, 2); assert!(output.output_markdown.contains("# 标题")); assert_eq!( output.visible_block_ids, vec!["imported_1".to_string(), "imported_2".to_string()] ); assert_eq!(output.outline.len(), 1); assert_eq!(output.outline[0].title, "标题"); } #[test] fn editor_session_demo_applies_insert_and_redo() { let output = editor_session_demo("原始段落").expect("session demo should succeed"); assert_eq!(output.operation, "session_demo"); assert_eq!(output.block_count, 2); assert_eq!(output.undo_applied, Some(true)); assert_eq!(output.redo_applied, Some(true)); assert!(output.output_markdown.contains("原始段落 [edited]")); assert!(output.output_markdown.contains("CLI demo block")); } #[test] fn editor_ai_pipeline_exposes_audit_and_change_report() { let output = editor_ai_pipeline( EditorInputKind::PlainText, EditorAiScenario::MeetingNotesToTodos, "待办:整理会议纪要\n普通段落", ) .expect("ai pipeline should succeed"); assert_eq!(output.operation, "ai_pipeline"); assert_eq!(output.audit.len(), 1); assert_eq!( output.change_report.updated_blocks, vec!["imported_1".to_string()] ); assert!(output.output_markdown.contains("待办:整理会议纪要")); } #[test] fn editor_ai_pipeline_reorders_pages() { let output = editor_ai_pipeline( EditorInputKind::PlainText, EditorAiScenario::PageReorder, "第一页\n\n第二页", ) .expect("ai pipeline should succeed"); assert_eq!(output.output_markdown.lines().next(), Some("第二页")); assert_eq!( output.change_report.moved_blocks, vec!["imported_1".to_string()] ); } #[test] fn ai_regression_meeting_notes_to_todos() { let output = editor_ai_pipeline( EditorInputKind::PlainText, EditorAiScenario::MeetingNotesToTodos, "待办:整理会议纪要\n普通段落", ) .expect("ai regression meeting notes should succeed"); assert!(output.output_markdown.contains("- [ ] 待办:整理会议纪要")); assert_eq!( output.change_report.updated_blocks, vec!["imported_1".to_string()] ); } #[test] fn ai_regression_long_paragraph_to_title() { let output = editor_ai_pipeline( EditorInputKind::PlainText, EditorAiScenario::LongParagraphToTitle, "这是一个很长的段落,用来验证 AI 回归样例会把首块提炼成标题,并保留后续结构化内容。", ) .expect("ai regression long paragraph should succeed"); assert!(output.output_markdown.starts_with("# 这是一个很长的段落")); assert_eq!( output.change_report.updated_blocks, vec!["imported_1".to_string()] ); } #[test] fn ai_regression_page_reorder() { let output = editor_ai_pipeline( EditorInputKind::PlainText, EditorAiScenario::PageReorder, "第一页\n\n第二页", ) .expect("ai regression reorder should succeed"); assert_eq!(output.output_markdown.lines().next(), Some("第二页")); assert_eq!( output.change_report.moved_blocks, vec!["imported_1".to_string()] ); } #[test] fn page_save_json_contract_uses_documents_save() { let output = plan_page_save( &CliContext::default(), "page_1", Some("ws_1"), Some(7), r#"[{"id":"block_1"}]"#, Some("conflict_1"), ) .expect("page save plan should build"); match output.operation { CliOperationOutput::Command { name, command_id, transport, .. } => { assert_eq!(name, "documents.save"); assert_eq!(command_id, "cmd_page_save_page_1"); assert_eq!(transport.function_name, "documents:updateContent"); assert_eq!( transport.args_json, json!({ "id": "page_1", "content": [{"id": "block_1"}], "expectedRevision": 7, "conflictDetectionKey": "conflict_1", }) ); } _ => panic!("expected command output"), } } #[test] fn output_context_keeps_reason_for_audit_trace() { let ctx = CliContext { reason: Some("phase7-cli-first".into()), ..CliContext::default() }; let output = plan_page_save(&ctx, "page_1", Some("ws_1"), None, "[]", None) .expect("page save plan should build"); assert_eq!(output.context.reason.as_deref(), Some("phase7-cli-first")); } #[test] fn page_create_json_contract_uses_documents_create() { let output = plan_page_create( &CliContext::default(), &PageCreateArgs { page_id: "page_1", workspace_id: "ws_1", parent_id: Some("parent_1"), title: "新页面", access_scope: "private", content_json: r#"[{"id":"block_1"}]"#, }, ) .expect("page create plan should build"); match output.operation { CliOperationOutput::Command { name, transport, .. } => { assert_eq!(name, "documents.create"); assert_eq!( transport.function_name, "documents:createWithParentReference" ); assert_eq!(transport.args_json["workspaceId"], json!("ws_1")); assert_eq!(transport.args_json["parentId"], json!("parent_1")); } _ => panic!("expected command output"), } } #[test] fn convex_dev_identity_prefers_explicit_cli_actor() { let ctx = CliContext { actor_id: "nn7bhmt782sykdrecah0rbe2nx867ks1".into(), ..CliContext::default() }; let user_id = normalize_actor_for_convex_identity(&ctx.actor_id) .expect("显式 CLI actor 应成为 Convex 写入身份"); let identity = build_convex_dev_identity(&user_id, "开发用户", "dev@mnote.local"); assert_eq!( identity["subject"], json!("nn7bhmt782sykdrecah0rbe2nx867ks1") ); assert_eq!( identity["tokenIdentifier"], json!("dev-user|nn7bhmt782sykdrecah0rbe2nx867ks1") ); } #[test] fn convex_dev_identity_ignores_legacy_default_cli_actor() { assert_eq!(normalize_actor_for_convex_identity("cli_user"), None); assert_eq!(normalize_actor_for_convex_identity(" anonymous "), None); } #[test] fn page_move_json_contract_uses_documents_move() { let output = plan_page_move( &CliContext::default(), &PageMoveArgs { page_id: "page_1", workspace_id: Some("ws_1"), parent_id: Some("parent_2"), sort_order: 3, }, ) .expect("page move plan should build"); match output.operation { CliOperationOutput::Command { name, transport, .. } => { assert_eq!(name, "documents.move"); assert_eq!(transport.function_name, "documents:move"); assert_eq!(transport.args_json["sortOrder"], json!(3)); } _ => panic!("expected command output"), } } #[test] fn sidebar_dataset_json_contract_uses_single_dataset_query() { let output = plan_sidebar_dataset(&CliContext::default(), "ws_1") .expect("sidebar dataset plan should build"); match output.operation { CliOperationOutput::Query { name, transport, .. } => { assert_eq!(name, "sidebar.dataset.list"); assert_eq!(transport.function_name, "sidebar:datasetList"); assert_eq!(transport.args_json, json!({ "workspaceId": "ws_1" })); } _ => panic!("expected query output"), } } #[test] fn tool_run_keeps_kind_and_args_json() { let output = plan_tool_run( &CliContext::default(), "doc_get", InvocationKind::Query, ToolExecutionMode::Plan, r#"{"pageId":"page_1"}"#, ) .expect("tool plan should build"); match output.operation { CliOperationOutput::Tool { tool_name, invocation_kind, execution_mode, toolset_id, effect, requires_confirmation, normalized_input, .. } => { assert_eq!(tool_name, "doc_get"); assert_eq!(invocation_kind, "query"); assert_eq!(execution_mode, "plan"); assert_eq!(toolset_id, "toolset.doc_read"); assert_eq!(effect, "read"); assert!(!requires_confirmation); assert_eq!( normalized_input, json!({ "toolName": "doc_get", "invocationKind": "query", "executionMode": "plan", "toolsetId": "toolset.doc_read", "effect": "read", "requiresConfirmation": false, "validateOnly": false, "dryRun": false, "args": { "pageId": "page_1", }, }) ); } _ => panic!("expected tool output"), } } #[test] fn tool_run_rejects_unknown_tool() { let error = plan_tool_run( &CliContext::default(), "doc_unknown", InvocationKind::Query, ToolExecutionMode::Plan, r#"{}"#, ) .expect_err("unknown tool should fail"); assert_eq!(error.code, "VALIDATION_ERROR"); assert!(error.message.contains("未知 tool")); } #[test] fn mindmap_get_json_contract_uses_mindmaps_get() { let output = plan_mindmap_get(&CliContext::default(), Some("ws_1"), "page_1", "mind_1") .expect("mindmap get plan should build"); match output.operation { CliOperationOutput::Query { name, transport, .. } => { assert_eq!(name, "mindmaps.get"); assert_eq!(transport.function_name, "mindmaps:get"); assert_eq!( transport.args_json, json!({ "docId": "page_1", "mindmapId": "mind_1", }) ); } _ => panic!("expected query output"), } } #[test] fn mindmap_put_json_contract_uses_mindmaps_put() { let output = plan_mindmap_put( &CliContext::default(), Some("ws_1"), "page_1", "mind_1", r#"{"data":{"text":"中心主题"},"children":[]}"#, true, ) .expect("mindmap put plan should build"); match output.operation { CliOperationOutput::Command { name, transport, .. } => { assert_eq!(name, "mindmaps.put"); assert_eq!(transport.function_name, "mindmaps:put"); assert_eq!( transport.args_json, json!({ "docId": "page_1", "mindmapId": "mind_1", "data": { "data": { "text": "中心主题" }, "children": [] }, "createOnly": true, }) ); } _ => panic!("expected command output"), } } #[test] fn replace_block_subtree_preserves_other_blocks() { let next_blocks = replace_block_subtree( vec![ json!({ "id": "block_1", "type": "paragraph", "content": [{"type": "text", "text": "old"}], "children": [], }), json!({ "id": "block_2", "type": "paragraph", "content": [{"type": "text", "text": "keep"}], "children": [], }), ], "block_1", &json!({ "type": "paragraph", "content": [{"type": "text", "text": "new"}], "children": [], }), ) .expect("block replacement should succeed"); assert_eq!( next_blocks, vec![ json!({ "id": "block_1", "type": "paragraph", "content": [{"type": "text", "text": "new"}], "children": [], }), json!({ "id": "block_2", "type": "paragraph", "content": [{"type": "text", "text": "keep"}], "children": [], }), ] ); } #[test] fn read_env_or_dotenv_trims_crlf_values() { let got = read_env_or_dotenv("CONVEX_SELF_HOSTED_URL").expect("read env should work"); assert_eq!(got.as_deref(), Some("http://127.0.0.1:3210")); } }