feat(mnote-web): add local file read support to mnote.doc.fetch + WS push design doc
## mnote.doc.fetch: local file path support
- Detect local file paths (starting with `/` or `./`) and bypass Convex aggregate
- Read .md file directly via fs::read_to_string
- Return source: "local_fs" in response
- Support maxChars truncation for local files
- Online Convex path unchanged
## Design: WS push migration (3-14)
- New: design/03-rust-web/process/3-14-rust-web-tree-realtime-ws-push-v1.md
- Documents commit 9d8e361e WebSocket push migration rationale,
architecture, and verification
## Updated design doc references
- 3-3: Mark SSE as fallback transport (WS push is primary)
- 08 checklist: Note WS push in Tree Realtime section
- AGENTS.md: Update tree/realtime references to reflect WS push
## Housekeeping
- desktop-hot.js: remove 3 stale log messages about disabled services
- page_ai_workflow.rs: fix 5 warnings (unused import, dead_code)
This commit is contained in:
@@ -11,9 +11,48 @@ pub async fn doc_fetch(
|
||||
context: &RequestContext,
|
||||
input: &ToolCallInput,
|
||||
) -> Result<Value, WebError> {
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let document_id = input.effective_document_id().unwrap_or_default();
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
|
||||
// 本地文件路径检测:直接读取 .md 文件,不经过 Convex
|
||||
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
|
||||
if is_local_file {
|
||||
use std::fs;
|
||||
let path = &document_id;
|
||||
let content = fs::read_to_string(path).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
format!("无法读取本地文件 {path}: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
let char_count = content.chars().count();
|
||||
let max_chars = input
|
||||
.arg_value("maxChars")
|
||||
.and_then(|v| v.as_u64())
|
||||
.unwrap_or(0) as usize;
|
||||
let (result_content, truncated) = if max_chars > 0 && char_count > max_chars {
|
||||
(content.chars().take(max_chars).collect::<String>(), true)
|
||||
} else {
|
||||
(content, false)
|
||||
};
|
||||
return Ok(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.page_ai_context.v1",
|
||||
"source": "local_fs",
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"format": "markdown",
|
||||
"detail": "simple",
|
||||
"scope": "full",
|
||||
"content": result_content,
|
||||
"truncated": truncated,
|
||||
"blocks": json!([]),
|
||||
"warnings": json!([])
|
||||
}));
|
||||
}
|
||||
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let scope = input
|
||||
.arg_string("scope")
|
||||
.unwrap_or_else(|| "full".into())
|
||||
@@ -617,6 +656,57 @@ fn escape_xml(value: &str) -> String {
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_search_replace_exact() {
|
||||
assert_eq!(
|
||||
search_replace("第一段内容。\n第二段内容。", "第一段内容", "替换后").unwrap(),
|
||||
"替换后。\n第二段内容。"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_replace_not_found() {
|
||||
let result = search_replace("第一段内容。", "不存在的文本", "替换");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("无法匹配"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_search_replace_full_content() {
|
||||
// 全文替换:search 等于全文
|
||||
let result = search_replace("全文内容", "全文内容", "新全文").unwrap();
|
||||
assert_eq!(result, "新全文");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocks_to_markdown_with_ids() {
|
||||
let blocks = json!([
|
||||
{"blockId": "b1", "text": "第一段", "type": "paragraph"},
|
||||
{"blockId": "b2", "text": "第二段", "type": "paragraph"}
|
||||
]);
|
||||
let blocks: Vec<Value> = blocks.as_array().unwrap().clone();
|
||||
let md = blocks_to_markdown(&blocks, true);
|
||||
assert!(md.contains("第一段 <!-- block:b1 -->"));
|
||||
assert!(md.contains("第二段 <!-- block:b2 -->"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blocks_to_markdown_heading() {
|
||||
let blocks = json!([
|
||||
{"blockId": "h1", "text": "标题", "type": "heading"},
|
||||
{"blockId": "p1", "text": "正文", "type": "paragraph"}
|
||||
]);
|
||||
let blocks: Vec<Value> = blocks.as_array().unwrap().clone();
|
||||
let md = blocks_to_markdown(&blocks, false);
|
||||
assert!(md.contains("## 标题"));
|
||||
assert!(md.contains("正文"));
|
||||
}
|
||||
}
|
||||
|
||||
// ── mnote.doc.markdown_edit ──────────────────────────────────────────
|
||||
|
||||
pub async fn doc_markdown_edit(
|
||||
@@ -626,14 +716,34 @@ pub async fn doc_markdown_edit(
|
||||
) -> Result<Value, WebError> {
|
||||
let document_id = input.effective_document_id().unwrap_or_default();
|
||||
let workspace_id = input.effective_workspace_id();
|
||||
let is_local_file = document_id.starts_with('/') || document_id.starts_with("./");
|
||||
|
||||
// 1. 读取当前文档内容(markdown 形式)
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
let current_md = blocks_to_markdown(&blocks, true); // with_ids 用于操作后定位
|
||||
let (current_md, source) = if is_local_file {
|
||||
use std::fs;
|
||||
// full_content 模式时允许文件不存在(创建新文件)
|
||||
let has_full = input.arg_value("full_content").is_some();
|
||||
let content = match fs::read_to_string(&document_id) {
|
||||
Ok(c) => c,
|
||||
Err(_) if has_full => String::new(), // 创建模式:空内容
|
||||
Err(error) => {
|
||||
return Err(WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
format!("无法读取本地文件 {document_id}: {error}"),
|
||||
)
|
||||
.with_context(context));
|
||||
}
|
||||
};
|
||||
(content, "local_fs")
|
||||
} else {
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
(blocks_to_markdown(&blocks, true), "convex")
|
||||
};
|
||||
|
||||
// 2. 解析 operations
|
||||
let operations: Vec<Value> = if let Some(full) = input.arg_string("full_content") {
|
||||
let use_full_content = input.arg_string("full_content");
|
||||
let operations: Vec<Value> = if let Some(full) = &use_full_content {
|
||||
// 全文替换模式
|
||||
vec![json!({"search": current_md.trim(), "replace": full.trim()})]
|
||||
} else {
|
||||
@@ -682,11 +792,17 @@ pub async fn doc_markdown_edit(
|
||||
.unwrap_or_default();
|
||||
|
||||
if search.is_empty() {
|
||||
failed.push(json!({
|
||||
"index": idx,
|
||||
"reason": "search 不能为空",
|
||||
"search": search
|
||||
}));
|
||||
// full_content 模式且当前内容为空:直接使用替换文本
|
||||
if use_full_content.is_some() && current_md.trim().is_empty() {
|
||||
md = replace.clone();
|
||||
applied += 1;
|
||||
} else {
|
||||
failed.push(json!({
|
||||
"index": idx,
|
||||
"reason": "search 不能为空",
|
||||
"search": search
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -721,52 +837,51 @@ pub async fn doc_markdown_edit(
|
||||
String::from("无操作已应用")
|
||||
};
|
||||
|
||||
// 5. 通过 block apply 写回(将 markdown 修改转换为 block 操作)
|
||||
// Phase A 简化:使用 mnote.page.save 或 rebuild blocks
|
||||
// 更精确的实现(Phase B):计算 block-level diff 并用 doc_apply_block_ops
|
||||
let revision_before = aggregate
|
||||
.pointer("/body/revision")
|
||||
.cloned()
|
||||
.unwrap_or(Value::Null);
|
||||
// 5. 写回(本地文件直接 fs::write,Convex 文档通过 block ops apply)
|
||||
let apply_result = if is_local_file {
|
||||
use std::fs;
|
||||
fs::write(&document_id, &md).map_err(|error| {
|
||||
WebError::bad_request_code(
|
||||
"mnote_tool_bad_request",
|
||||
format!("无法写入本地文件 {document_id}: {error}"),
|
||||
)
|
||||
.with_context(context)
|
||||
})?;
|
||||
json!({"written": true, "path": document_id.clone()})
|
||||
} else {
|
||||
// 构建 block ops:将修改后的 markdown 重新注入
|
||||
let aggregate = aggregate_value(state, context, input).await?;
|
||||
let blocks = block_projection_blocks(&aggregate);
|
||||
let block_ops = build_block_ops_from_markdown_edit(&blocks, &operations, applied);
|
||||
|
||||
// 构建 block ops:将修改后的 markdown 重新注入
|
||||
let block_ops = build_block_ops_from_markdown_edit(&blocks, &operations, applied);
|
||||
let apply_input = ToolCallInput {
|
||||
tool_name: "mnote.doc.apply_block_ops".into(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
document_id: Some(document_id.clone()),
|
||||
actor_id: input.actor_id.clone(),
|
||||
profile: input.profile.clone(),
|
||||
session_id: input.session_id.clone(),
|
||||
run_id: input.run_id.clone(),
|
||||
tool_call_id: Some(format!("md_edit_{}", context.trace.request_id)),
|
||||
trace_id: input.trace_id.clone(),
|
||||
idempotency_key: Some(format!("md_edit_{}", context.trace.request_id)),
|
||||
dry_run: input.dry_run,
|
||||
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
|
||||
args: Some(json!({
|
||||
"operations": block_ops,
|
||||
"allowedTargetBlockIds": json!([])
|
||||
})),
|
||||
};
|
||||
|
||||
let apply_input = ToolCallInput {
|
||||
tool_name: "mnote.doc.apply_block_ops".into(),
|
||||
workspace_id: workspace_id.clone(),
|
||||
document_id: Some(document_id.clone()),
|
||||
actor_id: input.actor_id.clone(),
|
||||
profile: input.profile.clone(),
|
||||
session_id: input.session_id.clone(),
|
||||
run_id: input.run_id.clone(),
|
||||
tool_call_id: Some(format!("md_edit_{}", context.trace.request_id)),
|
||||
trace_id: input.trace_id.clone(),
|
||||
idempotency_key: Some(format!(
|
||||
"md_edit_{}",
|
||||
context.trace.request_id
|
||||
)),
|
||||
dry_run: input.dry_run,
|
||||
capability_scope: Some(vec!["block.write".into(), "page.write".into()]),
|
||||
args: Some(json!({
|
||||
"operations": block_ops,
|
||||
"allowedTargetBlockIds": json!([])
|
||||
})),
|
||||
crate::hermes_tools::block::doc_apply_block_ops(state, context, &apply_input).await?
|
||||
};
|
||||
|
||||
let apply_result =
|
||||
crate::hermes_tools::block::doc_apply_block_ops(state, context, &apply_input).await?;
|
||||
|
||||
Ok(json!({
|
||||
"ok": true,
|
||||
"schema": "mnote.doc.markdown_edit.v1",
|
||||
"source": "convex",
|
||||
"source": source,
|
||||
"documentId": document_id,
|
||||
"workspaceId": workspace_id,
|
||||
"revision": {
|
||||
"before": revision_before,
|
||||
"after": apply_result.pointer("/revision").cloned().unwrap_or(Value::Null)
|
||||
},
|
||||
"operationsApplied": applied,
|
||||
"operationsFailed": failed.len(),
|
||||
"failedOperations": failed,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::app::AppState;
|
||||
use crate::context::RequestContext;
|
||||
use crate::error::WebError;
|
||||
use crate::hermes_tools::{block, doc, ToolCallInput};
|
||||
use crate::hermes_tools::{doc, ToolCallInput};
|
||||
use axum::extract::{Extension, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::Json;
|
||||
@@ -185,6 +185,7 @@ fn extract_markdown_operations_from_model_text(text: &str) -> Result<Vec<Value>,
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn extract_operations_from_model_text(text: &str) -> Result<Vec<Value>, WebError> {
|
||||
let parsed = parse_model_json(text)?;
|
||||
if let Some(content) = parsed
|
||||
@@ -289,7 +290,7 @@ async fn call_block_edit_model(
|
||||
.get("pageText")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
let allowed = ai_context
|
||||
let _allowed_block_ids = ai_context
|
||||
.get("allowedTargetBlockIds")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| json!([]));
|
||||
@@ -405,6 +406,7 @@ fn looks_like_block_edit(message: &str) -> bool {
|
||||
.any(|needle| message.contains(needle))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn direct_block_edit_operations(message: &str) -> Option<Vec<Value>> {
|
||||
let mut operations = Vec::new();
|
||||
for clause in message
|
||||
@@ -441,6 +443,7 @@ fn direct_block_edit_operations(message: &str) -> Option<Vec<Value>> {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn quoted_segments(value: &str) -> Vec<String> {
|
||||
let mut segments = Vec::new();
|
||||
let mut start: Option<char> = None;
|
||||
|
||||
Reference in New Issue
Block a user