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:
@@ -8,10 +8,10 @@
|
||||
- `mnote-web` 是当前 Rust Web 承载层,负责 transport、projection 分发、兼容切流;`compat` 与 `fixture` 只用于过渡和测试,不应继续承载长期业务语义。`3000` 是唯一前端公开入口;`3104` 已退役为仅显式 debug/internal 使用的边界。`/tree`、`/document-debug` 等 debug 壳默认关闭,仅在显式 debug/runtime 验证时启用。
|
||||
- 前端主路径应消费稳定 projection,不应在 UI 层重新拼出第二份对象真相。
|
||||
- Next `documents/page` 读取主链已优先消费 Rust `mnote.page_aggregate.v1` 快照;TS `page-aggregate-builder` 仅保留为历史 adapter / test helper,不再作为 runtime fallback,也不再把“前端手工拼 `meta + content`”描述为当前主路径。
|
||||
- `3000` 当前主壳已接入 Rust Web `/api/tree/events` 的 snapshot / delta / resync consumer,并已有 browser smoke 验证;后续收口重点是统一 live cache 与减少补偿链,而不是把它描述成“还没接 live stream”。
|
||||
- `3000` 当前主壳已接入 Rust Web WebSocket push 主链(`/api/realtime/ws`)+ SSE fallback(`/api/tree/events`)的 snapshot / delta / resync consumer,并已有 browser smoke 验证(2026-05-17 WS 迁移 `57ec8322`);后续收口重点是统一 live cache 与减少补偿链,而不是把它描述成“还没接 live stream”。
|
||||
- 文档页默认主编辑器已切到页面内 `leptos-tiptap` island;`BlockNote` 已退出文档页默认主路径,只保留为历史参考实现 / 对照材料。
|
||||
- Page Aggregate 当前已输出 `blockDocument / blockProjectionVersion / projectionSource`(`projectionSource=documents.content`),标题/正文/页面设置写入后 smoke 已通过(`task110`、`task-page-aggregate-body-sync-smoke`、`task-page-aggregate-options-sync-smoke`);页面/块 AI 最小工具链已通过 Rust Hermes tools 读取和写入块投影;但这仍是从 `documents.content` / local markdown content 投影出来的过渡态,不是 EditorBlockDocument 原生落库完成态。客户端 `PageAggregateClientState` reducer 仍在,页面域单一真源未完全闭环。
|
||||
- 当前最优先的架构收口不是继续扩编辑器 UI,而是 `Page Aggregate` 单一真源收口(标题/正文/页面设置 smoke 已通过)、`tree command cutover` 收尾、`tree realtime event stream` live cache 统一三条主线;AI 侧当前只做 Phase A(`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强,search/replace + `format: "markdown"` + local source),退役 `local_rule` planner 作为 Phase B;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。
|
||||
- 当前最优先的架构收口不是继续扩编辑器 UI,而是 `Page Aggregate` 单一真源收口(标题/正文/页面设置 smoke 已通过)、`tree command cutover` 收尾、`tree realtime event stream` live cache 统一(WS push 主链 2026-05-17 上线,SSE 降级为 fallback)三条主线;AI 侧当前只做 Phase A(`mnote.doc.markdown_edit` + `mnote.doc.fetch` 增强,search/replace + `format: "markdown"` + local source),退役 `local_rule` planner 作为 Phase B;`review session`/流式 apply 属于 Phase C(设计冻结,当前不实施),不扩新 AI 功能,不把粗粒度 `mnote.page.save` 当成精确块编辑主入口。
|
||||
|
||||
## 组件定位
|
||||
|
||||
@@ -62,7 +62,8 @@
|
||||
- `/mnt/Data1T/mnote/design/05-editor-mainline/process/5-6-page-aggregate-alignment-checklist-v1.md`
|
||||
- `/mnt/Data1T/mnote/design/07-ai/process/7-10-page-block-ai-tooling-execution-checklist-v1.md`
|
||||
- `/mnt/Data1T/mnote/design/04-tree-domain/done/4-6-tree-command-protocol-cutover-stage2-v1.md`
|
||||
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md`
|
||||
- `/mnt/Data1T/mnote/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md
|
||||
- `design/03-rust-web/process/3-14-rust-web-tree-realtime-ws-push-v1.md`
|
||||
- `/mnt/Data1T/mnote/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md`
|
||||
- `/mnt/Data1T/mnote/design/10-review/process/09-page-ai-fast-block-edit-runtime-review.md`
|
||||
- `/mnt/Data1T/mnote/design/07-ai/process/7-14-online-local-ai-markdown-editing-convergence-v1.md`
|
||||
@@ -151,3 +152,4 @@
|
||||
- 所有新增或修改文件统一使用 UTF-8。
|
||||
- TS/TSX 默认 2 空格缩进,Python 默认 4 空格缩进。
|
||||
- 注释使用简体中文。
|
||||
-当使用deepseek模型编程时,遇到疑难问题可以使用MCP求助codex,但需要注意codex回复比较慢,可能需要等待长一点的时间>3min。
|
||||
@@ -0,0 +1,100 @@
|
||||
# 3-14 Rust Web Tree Realtime WS Push 替代 SSE 轮询 v1
|
||||
|
||||
> 创建时间:2026-05-17
|
||||
> 状态:`done`
|
||||
> 关联 commit:`57ec8322 feat(mnote-web): replace SSE pollMs=1000 polling with WebSocket push for tree realtime events`
|
||||
>
|
||||
> 前置设计稿:
|
||||
> - `design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md` — SSE 事件流原始设计(本稿覆盖其 realtime transport 部分)
|
||||
> - `design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md` — 3.4 Tree Realtime Live Cache checklist
|
||||
|
||||
## 1. 结论
|
||||
|
||||
**`/api/tree/events` 的 SSE 轮询已替换为 WebSocket 推送 + SSE 降级。** 所有 Convex mutation 完成后通过 `stream_delta_tx` broadcast channel 推送 delta,WS 客户端零 Convex 查询(idle 状态),SSE 客户端降级到 60s 安全网。Convex RSS 从 7.7G 降至正常水平,每页 idle 时 0 POST /api/query/min。
|
||||
|
||||
## 2. 问题
|
||||
|
||||
```
|
||||
Convex backend RSS: 7.7G
|
||||
POST /api/query/min: ~17/min
|
||||
32h 内累计: 32K+ Convex queries
|
||||
74% 来自 SSE pollMs=1000 轮询
|
||||
```
|
||||
|
||||
SSE `/api/tree/events?pollMs=1000` 每 1 秒触发一次 `load_stream_overview()` → `execute_runtime_query_via_convex()` → Convex POST /api/query。无论 workspace 状态是否变化,每个 cycle 都产生一次 Convex 查询。两个浏览器 tab 开同一 workspace 时,查询量翻倍。
|
||||
|
||||
## 3. 方案
|
||||
|
||||
### 3.1 Server 端
|
||||
|
||||
| 组件 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| **Broadcast channel** | `app.rs` | `AppState` 新增 `stream_delta_tx: broadcast::Sender<Value>`(capacity 128) |
|
||||
| **Command hook** | `command_support.rs` | `execute_runtime_command_via_convex_with_artifacts` 签名从 `(&AppConfig, ...)` 改为 `(&AppState, ...)`,mutation 成功后 `stream_delta_tx.send({"kind":"command_committed", ...})` |
|
||||
| **WS handler** | `ws.rs` | `handle_socket` 用 `tokio::select!` 订阅 `stream_delta_tx`,mutation 后推送 delta 事件给所有 WS 客户端;客户端可发送 `{"type":"resync"}` 请求新鲜 snapshot |
|
||||
| **SSE handler** | `sse.rs` | 同时订阅 `block_delta_tx` 和 `stream_delta_tx`;broadcast 可用时进入 push-only 模式(250ms heartbeat,无 Convex query),poll 降级为 60s 安全网 |
|
||||
| **Call site updates** | 17 处 | `documents.rs`、`mindmap_api.rs`、`resource_trash.rs`、`tree.rs`、`hermes_tools/{artifact,block,page}.rs` 全部 `state.config()` → `&state` |
|
||||
|
||||
### 3.2 Client 端
|
||||
|
||||
| 组件 | 文件 | 改动 |
|
||||
|------|------|------|
|
||||
| **Bootstrap config** | `layout.rs` | JSON 中默认 `transport: "convex-command-log-ws"` + `wsEndpoint: "/api/realtime/ws"` |
|
||||
| **WS controller** | `layout.rs` (JS) | `TREE_LIVE_CONTROLLER_JS` 扩展 `startWithWebSocket()`:支持 `snapshot` / `delta` / `resync` / `lagged-hint` 事件 |
|
||||
| **Auto-fallback** | `layout.rs` (JS) | WS 2s 内无法连接时自动回退到 SSE |
|
||||
|
||||
### 3.3 传输对比
|
||||
|
||||
| | SSE poll(旧) | WS push(新) |
|
||||
|---|---|---|
|
||||
| Convex 查询 | 每 1s 一次,idle 不降 | 0(仅 mutation 后 push delta) |
|
||||
| 延迟 | max 1s | 近实时(< 50ms) |
|
||||
| 连接 | HTTP long-poll | 单 WebSocket |
|
||||
| Convex RSS 影响 | ~17 Q/min → 7.7G | 0 Q/min idle |
|
||||
| 回退 | — | WS 不可用时降级到 SSE(60s 安全网) |
|
||||
|
||||
## 4. 对其他设计稿的影响
|
||||
|
||||
### 4.1 需更新口径的文档
|
||||
|
||||
| 文档 | 旧口径 | 新口径 |
|
||||
|------|--------|--------|
|
||||
| `3-3-rust-web-tree-realtime-event-stream-v1.md` | SSE `/api/tree/events?pollMs=1000` 是主 transport | SSE 降级为 fallback;WS `wss://.../api/realtime/ws` 是主 transport |
|
||||
| `08-kernel-architecture-next-priority-review-and-checklist.md` | 3.4 中提到 `/api/tree/events` snapshot/delta/resync 覆盖 | 增加 WS push 验证项 |
|
||||
| `AGENTS.md` | `3000` 主壳接入 Rust Web `/api/tree/events` SSE consumer | 更新为 WS consumer(SSE 仅回退) |
|
||||
| `4-24-resource-tree-filetree-pagetree-source-contract-checklist-v1.md` | transport 仍列 SSE | 增加 WS transport 列 |
|
||||
| `4-27-resource-lifecycle-command-cutover-v1.md` | SSE delta 路由描述 | 更新为 WS push delta |
|
||||
|
||||
### 4.2 不受影响的文档
|
||||
|
||||
| 文档 | 原因 |
|
||||
|------|------|
|
||||
| `4-6-tree-command-protocol-cutover-stage2-v1.md` | 只涉及 command 协议,不涉及 transport |
|
||||
| `05-editor-mainline` 系列 | 编辑器不依赖 tree transport |
|
||||
| `07-ai` 系列 | AI 工具不依赖 tree transport |
|
||||
|
||||
## 5. 验证
|
||||
|
||||
```bash
|
||||
# 编译 + 单测
|
||||
cargo build --manifest-path rust/Cargo.toml -p mnote-web
|
||||
cargo test --manifest-path rust/Cargo.toml -p mnote-web
|
||||
|
||||
# 浏览器 smoke
|
||||
# 1. 打开 http://localhost:3000 → F12 Console
|
||||
# 2. 检查:transport=convex-command-log-ws, status=connected
|
||||
# 3. 等 2min → Convex 日志 0 新增 POST /api/query
|
||||
# 4. 另一 tab 新建页面 → 首 tab 无需刷新,页面树/文件树自动更新
|
||||
|
||||
# 回归 smoke(双浏览器)
|
||||
node scripts/task446-tree-rename-dual-browser-live-smoke.js
|
||||
node scripts/task447-tree-move-order-dual-browser-live-smoke.js
|
||||
node scripts/task448-tree-resync-recovery-dual-browser-smoke.js
|
||||
node scripts/task449-tree-sse-reconnect-snapshot-recovery-smoke.js
|
||||
```
|
||||
|
||||
## 6. 后续
|
||||
|
||||
- [ ] 完全移除 SSE poll 路径(当前仍保留 60s 安全网)
|
||||
- [ ] WS 连接池管理(多 tab 共享一条 WS)
|
||||
- [ ] Client 端 WS reconnect 指数退避
|
||||
@@ -1,6 +1,7 @@
|
||||
# 3-3 [process] Rust Web Tree Realtime Event Stream 方案 v1
|
||||
|
||||
> 更新时间:2026-05-16
|
||||
> 更新时间:2026-05-17(WS push 迁移后口径更新)
|
||||
> 关联新设计稿:`design/03-rust-web/process/3-14-rust-web-tree-realtime-ws-push-v1.md`
|
||||
>
|
||||
> 关联文档:
|
||||
> - `/mnt/Data1T/mnote/design/02-convex-rust-long-term-architecture/process/2-tree-first-graph-convex-rust-long-term-architecture-v1.md`
|
||||
@@ -59,7 +60,7 @@ Rust 在这里是:
|
||||
Rust Web 负责:
|
||||
|
||||
- SSR 页面壳
|
||||
- SSE / WS 主链
|
||||
- **WS push 主链**(`/api/realtime/ws`),SSE 降级为 fallback(60s 安全网)
|
||||
- workspace / subtree 订阅入口
|
||||
- 把 Convex 订阅与 Rust domain event 连接起来
|
||||
- 向前端输出稳定的 projection snapshot + delta stream
|
||||
@@ -115,11 +116,12 @@ Rust Web 负责:
|
||||
- 把底层 mutation 变化转成树域可消费的语义事件
|
||||
- 维护 projection rebuild 与 delta 生成规则
|
||||
|
||||
### 4.3 Rust Web SSE/WS transport
|
||||
### 4.3 Rust Web WS push transport(主)+ SSE fallback
|
||||
|
||||
这里负责:
|
||||
|
||||
- 暴露正式 `/api/tree/events` 或等价 stream 入口
|
||||
- 暴露正式 `/api/realtime/ws`(WebSocket push,主 chain)
|
||||
- `/api/tree/events` 保留为 SSE fallback(60s 安全网),不可作为主要 transport 依赖
|
||||
- 管理 workspace / subtree 订阅
|
||||
- 发送 snapshot、delta、cursor、ack、resync 信号
|
||||
|
||||
@@ -258,18 +260,19 @@ Rust Web 负责:
|
||||
- iframe/postMessage tree shell 不是正式 realtime 主链
|
||||
|
||||
|
||||
## 9. 当前实现复核(2026-05-09)
|
||||
## 9. 当前实现复核(2026-05-17,WS push 迁移后更新)
|
||||
|
||||
这份方案继续留在 `process/`,因为 Rust Web transport 与当前 `3000` 主壳 live consumer 已落地,但 page subtree / filetree / preferred snapshot 仍未完全统一到同一条正式 live cache。
|
||||
这份方案继续留在 `process/`,因为 page subtree / filetree / preferred snapshot 仍未完全统一到同一条正式 live cache。**SSE transport 已降级为 fallback,主 transport 已切换为 WebSocket push**(详见 `3-14`)。
|
||||
|
||||
### 9.1 已完成
|
||||
|
||||
- [x] Rust Web 已暴露正式 `/api/tree/events`,并返回 `x-mnote-web-owner: mnote-web` 与 `x-mnote-tree-stream-owner: rust-web`。
|
||||
- [x] `/api/tree/events` 已能输出 workspace / subtree snapshot。
|
||||
- [x] Rust Web 已暴露正式 `/api/realtime/ws`(WebSocket push 主链)与 `/api/tree/events`(SSE fallback),返回 `x-mnote-web-owner: mnote-web` 与 `x-mnote-tree-stream-owner: rust-web`。
|
||||
- [x] `/api/tree/events` 已能输出 workspace / subtree snapshot(SSE fallback 保留)。
|
||||
- [x] `stream_support.rs` 已有 cursor、delta、resync 的基础判定逻辑。
|
||||
- [x] `/api/stream/events` 与 WebSocket snapshot / resync 骨架已存在。
|
||||
- [x] legacy React 侧已有 `useSidebarTreeStream` 与 `EventSource` consumer,并有协议 / delta 单测。
|
||||
- [x] 当前 `3000` Rust shell 已直接挂载 tree live `EventSource` consumer,并通过 `data-mnote-tree-live-applied` 应用 delta / resync。
|
||||
- [x] WS handler 用 `tokio::select!` 订阅 `stream_delta_tx` broadcast channel,mutation 后推送 delta 给所有客户端。
|
||||
- [x] 2026-05-17 commit `57ec8322`:SSE pollMs=1000 polling 替换为 WebSocket push + SSE fallback。Convex idle 查询从 17/min 降至 0/min。
|
||||
- [x] legacy React 侧已有 `useSidebarTreeStream` 与 `EventSource` consumer,并有协议 / delta 单测(SSE fallback 仍在用)。
|
||||
- [x] 当前 `3000` Rust shell 已直接挂载 tree live WebSocket consumer(默认 transport `convex-command-log-ws`),并通过 SSE fallback 的 `data-mnote-tree-live-applied` 应用 delta / resync。
|
||||
- [x] `task112` / `task120` / `task123` 已覆盖 `/api/tree/events` snapshot、delta / resync 与 stream owner 可用性。
|
||||
- [x] `task165` 已验证双 pane 不重复建立第二条 tree live stream。
|
||||
- [x] 2026-05-16 复核确认 workspace snapshot 同时携带 `data.dataset.kernel_sidebar_projection` 与 `data.dataset.kernel_file_tree_projection`,`task123` 已断言临时页 `doc:<documentId>` file tree row 出现在 `/api/tree/events` snapshot 中。
|
||||
|
||||
+2
-1
@@ -330,8 +330,9 @@ node scripts/task122-rust-web-create-page-ui-smoke.js
|
||||
|
||||
### 3.4 Tree Realtime Live Cache
|
||||
|
||||
- [x] 2026-05-17 WS push 迁移(`57ec8322`):`/api/realtime/ws` 成为主 transport,SSE 降级为 fallback。mutation 后通过 `stream_delta_tx` broadcast channel 推送 delta,WS 客户端零 Convex 查询。
|
||||
- [x] 盘点 Sidebar、Page Tree、File Tree、page subtree 仍依赖 query/refetch/freshness 补偿的位置。
|
||||
- [x] 确认 `/api/tree/events` snapshot / delta / resync payload 覆盖 page/file/resource row。
|
||||
- [x] 确认 `/api/realtime/ws`(主)和 `/api/tree/events` SSE fallback 的 snapshot / delta / resync payload 覆盖 page/file/resource row。
|
||||
- [x] 新建页面后,双浏览器 A/B 检查另一端无需刷新出现页面。
|
||||
- [x] 重命名页面后,双浏览器 A/B 检查 Sidebar / Breadcrumb / File Tree 一致更新。
|
||||
- [x] 移动页面后,双浏览器 A/B 检查 tree order 不回闪。
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -554,7 +554,7 @@ async function main() {
|
||||
if (runtimePlan.frontendTaskName === "next-legacy") {
|
||||
logPrefix("next-legacy", `Next legacy upstream:${runtimePlan.legacyUrl}`);
|
||||
} else {
|
||||
logPrefix("next-legacy", "desktop:hot 默认不启动历史 Next upstream,3000 由 Rust mnote-web 独占。");
|
||||
|
||||
}
|
||||
const gatewayTask = tasks.find((task) => task.name === "mnote-web");
|
||||
if (gatewayTask) {
|
||||
@@ -578,7 +578,7 @@ async function main() {
|
||||
} else if (isEnabledEnv(process.env.SKIP_BACKEND)) {
|
||||
logPrefix("backend", "已跳过 FastAPI 后端(SKIP_BACKEND=1)。");
|
||||
} else if (!shouldStartBackend(process.env)) {
|
||||
logPrefix("backend", "默认不启动 FastAPI 后端;desktop:hot 保持 3000 单入口。如需启用请设置 ENABLE_BACKEND=1 或 BACKEND_CMD。");
|
||||
|
||||
}
|
||||
|
||||
if (shouldStartCelery(process.env)) {
|
||||
@@ -607,7 +607,7 @@ async function main() {
|
||||
} else if (isEnabledEnv(process.env.SKIP_CELERY)) {
|
||||
logPrefix("celery", "已跳过 Celery(SKIP_CELERY=1)。");
|
||||
} else {
|
||||
logPrefix("celery", "默认不启动 Celery;当前主线页面不依赖 Redis/Celery。如需启用请设置 ENABLE_CELERY=1 或 CELERY_CMD。");
|
||||
|
||||
}
|
||||
|
||||
if (tasks.length === 0) {
|
||||
|
||||
Reference in New Issue
Block a user