feat: consolidate local-first mnote web runtime
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
use base64::Engine;
|
||||
use bridge_runtime::{
|
||||
execute_runtime_query, RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput,
|
||||
RuntimeQueryEnvelopeWire, RuntimeSourceWire, RuntimeTargetWire, RuntimeToolInvocationWire,
|
||||
build_query_request, build_write_request, execute_runtime_query, BridgeContext, BridgeError,
|
||||
RuntimeActorWire, RuntimeBridgeContextWire, RuntimeInput, RuntimeSourceWire, RuntimeTargetWire,
|
||||
RuntimeToolInvocationWire,
|
||||
};
|
||||
use clap::ValueEnum;
|
||||
use core_protocol::{
|
||||
@@ -16,11 +16,8 @@ use mnote_editor_core::{
|
||||
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 {
|
||||
@@ -1554,7 +1551,7 @@ fn execute_query(
|
||||
"documents.content.get"
|
||||
| "documents.meta.get"
|
||||
| "mindmaps.get"
|
||||
| "sidebar.dataset.list" => execute_convex_query(transport, cli_ctx),
|
||||
| "sidebar.dataset.list" => execute_retired_cloud_transport(name),
|
||||
"search.documents" => execute_search_documents(transport, context, cli_ctx),
|
||||
other => Err(CliError::validation(format!("暂不支持执行 query: {other}"))),
|
||||
}
|
||||
@@ -1574,7 +1571,7 @@ fn execute_command(
|
||||
| "documents.restore"
|
||||
| "documents.title.update"
|
||||
| "documents.save"
|
||||
| "mindmaps.put" => execute_convex_mutation(transport, cli_ctx),
|
||||
| "mindmaps.put" => execute_retired_cloud_transport(name),
|
||||
"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),
|
||||
@@ -1649,313 +1646,35 @@ fn merge_tool_args(
|
||||
}
|
||||
|
||||
fn execute_search_documents(
|
||||
transport: &CliTransportPlan,
|
||||
context: &CliOutputContext,
|
||||
cli_ctx: &CliContext,
|
||||
_transport: &CliTransportPlan,
|
||||
_context: &CliOutputContext,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
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::<Vec<Value>>(),
|
||||
"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::<Vec<Value>>(),
|
||||
"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::<Vec<Value>>(),
|
||||
"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::<Vec<Value>>(),
|
||||
"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::<Vec<Value>>(),
|
||||
});
|
||||
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))
|
||||
execute_retired_cloud_transport("search.documents")
|
||||
}
|
||||
|
||||
fn execute_block_insert(
|
||||
normalized_input: &Value,
|
||||
context: &CliOutputContext,
|
||||
cli_ctx: &CliContext,
|
||||
_normalized_input: &Value,
|
||||
_context: &CliOutputContext,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
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::<String>;
|
||||
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),
|
||||
}))
|
||||
execute_retired_cloud_transport("insert_block")
|
||||
}
|
||||
|
||||
fn execute_block_patch(
|
||||
normalized_input: &Value,
|
||||
transport: &CliTransportPlan,
|
||||
cli_ctx: &CliContext,
|
||||
_normalized_input: &Value,
|
||||
_transport: &CliTransportPlan,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
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)
|
||||
execute_retired_cloud_transport("blocks.patch")
|
||||
}
|
||||
|
||||
fn execute_block_move(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
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_move(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
execute_retired_cloud_transport("blocks.move")
|
||||
}
|
||||
|
||||
fn execute_block_embed(normalized_input: &Value, cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
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 execute_block_embed(_normalized_input: &Value, _cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
execute_retired_cloud_transport("blocks.embed")
|
||||
}
|
||||
|
||||
fn build_tool_target(
|
||||
@@ -2007,7 +1726,7 @@ fn load_tool_data(
|
||||
tool_name: &str,
|
||||
args: &Value,
|
||||
target: Option<&RuntimeTargetWire>,
|
||||
cli_ctx: &CliContext,
|
||||
_cli_ctx: &CliContext,
|
||||
) -> CliResult<Value> {
|
||||
match tool_name {
|
||||
"search_web" | "slash_run" | "event_replay" | "index_rebuild" => {
|
||||
@@ -2015,112 +1734,24 @@ fn load_tool_data(
|
||||
}
|
||||
"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) }),
|
||||
)
|
||||
let _ = read_doc_target(args, target)?;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"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),
|
||||
}))
|
||||
let _ = document_id;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"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::<Vec<Value>>(),
|
||||
"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::<Vec<Value>>(),
|
||||
"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::<Vec<Value>>(),
|
||||
"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::<Vec<Value>>(),
|
||||
"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::<Vec<Value>>()
|
||||
}]
|
||||
}))
|
||||
let _ = workspace_id;
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
"mindmap_get" | "mindmap_get_subtree" | "mindmap_apply_ops" | "mindmap_put" => {
|
||||
let document_id = read_doc_target(args, target)?;
|
||||
@@ -2129,14 +1760,8 @@ fn load_tool_data(
|
||||
.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) }),
|
||||
)
|
||||
let _ = (document_id, mindmap_id);
|
||||
Err(retired_cloud_transport_error(tool_name))
|
||||
}
|
||||
other => Err(CliError::validation(format!(
|
||||
"暂不支持 tool 数据加载: {other}"
|
||||
@@ -2144,183 +1769,14 @@ fn load_tool_data(
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_convex_query(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
execute_convex_query_raw(
|
||||
&transport.function_name,
|
||||
transport.args_json.clone(),
|
||||
cli_ctx,
|
||||
)
|
||||
fn execute_retired_cloud_transport(operation: &str) -> CliResult<Value> {
|
||||
Err(retired_cloud_transport_error(operation))
|
||||
}
|
||||
|
||||
fn execute_convex_mutation(transport: &CliTransportPlan, cli_ctx: &CliContext) -> CliResult<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Self> {
|
||||
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<String> {
|
||||
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<Option<String>> {
|
||||
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 retired_cloud_transport_error(operation: &str) -> CliError {
|
||||
CliError::validation(format!(
|
||||
"旧 Convex CLI 执行链已退役: {operation};请改用 local-first Rust/SQLite control-plane 路径"
|
||||
))
|
||||
}
|
||||
|
||||
fn build_runtime_context(
|
||||
@@ -2341,6 +1797,10 @@ fn build_runtime_context(
|
||||
source: RuntimeSourceWire {
|
||||
channel: "cli".into(),
|
||||
client: "mnote-cli".into(),
|
||||
source_kind: None,
|
||||
root_uri: None,
|
||||
workspace_id: None,
|
||||
capabilities: Vec::new(),
|
||||
},
|
||||
tenant_id: None,
|
||||
auth_token: None,
|
||||
@@ -2350,125 +1810,6 @@ fn build_runtime_context(
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_blocks(content_result: &Value) -> Vec<Value> {
|
||||
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<usize> {
|
||||
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<Value>, block_id: &str) -> CliResult<(Value, Vec<Value>)> {
|
||||
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<Value>,
|
||||
block_id: &str,
|
||||
next_block: &Value,
|
||||
) -> CliResult<Vec<Value>> {
|
||||
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<String>) {
|
||||
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<String> {
|
||||
args.get("documentId")
|
||||
.and_then(Value::as_str)
|
||||
@@ -2478,13 +1819,6 @@ fn read_doc_target(args: &Value, target: Option<&RuntimeTargetWire>) -> CliResul
|
||||
.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,
|
||||
@@ -2625,7 +1959,7 @@ fn invocation_kind_label(kind: &InvocationKind) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn map_bridge_error(error: storage_convex_bridge::BridgeError) -> CliError {
|
||||
fn map_bridge_error(error: BridgeError) -> CliError {
|
||||
CliError::validation(error.message)
|
||||
}
|
||||
|
||||
@@ -2842,32 +2176,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[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(
|
||||
@@ -3033,55 +2341,4 @@ mod tests {
|
||||
_ => 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"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user