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:
lix-2026
2026-05-17 15:57:40 +08:00
parent 9d8e361e43
commit 3f43020603
7 changed files with 291 additions and 67 deletions
+162 -47
View File
@@ -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('\'', "&apos;")
}
#[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::writeConvex 文档通过 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;