diff --git a/AGENTS.md b/AGENTS.md index df58aa67..27ce2f3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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。 \ No newline at end of file diff --git a/design/03-rust-web/process/3-14-rust-web-tree-realtime-ws-push-v1.md b/design/03-rust-web/process/3-14-rust-web-tree-realtime-ws-push-v1.md new file mode 100644 index 00000000..806906de --- /dev/null +++ b/design/03-rust-web/process/3-14-rust-web-tree-realtime-ws-push-v1.md @@ -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`(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 指数退避 diff --git a/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md b/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md index fd936e4c..4839e5a0 100644 --- a/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md +++ b/design/03-rust-web/process/3-3-rust-web-tree-realtime-event-stream-v1.md @@ -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:` file tree row 出现在 `/api/tree/events` snapshot 中。 diff --git a/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md b/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md index baebff47..43cbff31 100644 --- a/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md +++ b/design/10-review/process/08-kernel-architecture-next-priority-review-and-checklist.md @@ -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 不回闪。 diff --git a/rust/crates/mnote-web/src/hermes_tools/doc.rs b/rust/crates/mnote-web/src/hermes_tools/doc.rs index ac019f03..300b0766 100644 --- a/rust/crates/mnote-web/src/hermes_tools/doc.rs +++ b/rust/crates/mnote-web/src/hermes_tools/doc.rs @@ -11,9 +11,48 @@ pub async fn doc_fetch( context: &RequestContext, input: &ToolCallInput, ) -> Result { - 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::(), 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 = blocks.as_array().unwrap().clone(); + let md = blocks_to_markdown(&blocks, true); + assert!(md.contains("第一段 ")); + assert!(md.contains("第二段 ")); + } + + #[test] + fn test_blocks_to_markdown_heading() { + let blocks = json!([ + {"blockId": "h1", "text": "标题", "type": "heading"}, + {"blockId": "p1", "text": "正文", "type": "paragraph"} + ]); + let blocks: Vec = 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 { 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 = if let Some(full) = input.arg_string("full_content") { + let use_full_content = input.arg_string("full_content"); + let operations: Vec = 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, diff --git a/rust/crates/mnote-web/src/routes/page_ai_workflow.rs b/rust/crates/mnote-web/src/routes/page_ai_workflow.rs index 372d83cf..612ad537 100644 --- a/rust/crates/mnote-web/src/routes/page_ai_workflow.rs +++ b/rust/crates/mnote-web/src/routes/page_ai_workflow.rs @@ -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, )) } +#[allow(dead_code)] fn extract_operations_from_model_text(text: &str) -> Result, 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> { let mut operations = Vec::new(); for clause in message @@ -441,6 +443,7 @@ fn direct_block_edit_operations(message: &str) -> Option> { } } +#[allow(dead_code)] fn quoted_segments(value: &str) -> Vec { let mut segments = Vec::new(); let mut start: Option = None; diff --git a/scripts/desktop-hot.js b/scripts/desktop-hot.js index 5572cb77..24469003 100644 --- a/scripts/desktop-hot.js +++ b/scripts/desktop-hot.js @@ -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) {